Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d3df656de | ||
|
|
85e1f1a8f4 | ||
|
|
fd39226883 | ||
|
|
3f2aba4adc |
4 changed files with 206 additions and 18 deletions
61
TODO.md
61
TODO.md
|
|
@ -21,16 +21,67 @@ Pick anything from here when relevant. Cross-cutting design notes live in
|
||||||
- **Model override.** Hard-coded to `haiku` in the turn loop right now.
|
- **Model override.** Hard-coded to `haiku` in the turn loop right now.
|
||||||
Surface as a per-agent override: operator via dashboard, manager via
|
Surface as a per-agent override: operator via dashboard, manager via
|
||||||
`request_apply_commit` setting an attr on the agent's flake (most natural
|
`request_apply_commit` setting an attr on the agent's flake (most natural
|
||||||
place since the flake already carries per-agent env/identity).
|
place since the flake already carries per-agent env/identity). Pair with
|
||||||
|
a **model status** indicator on the agent page (active / queued / last
|
||||||
|
switched) once the override is in place.
|
||||||
|
|
||||||
## UI / UX
|
## UI / UX
|
||||||
|
|
||||||
- **Per-agent UI substance.** Show last N inbox messages, last turn timing,
|
- **Per-agent UI substance.** Show last N inbox messages, last turn timing,
|
||||||
link back to dashboard.
|
link back to dashboard.
|
||||||
|
- **Delivered events history persistence.** The `events::Bus` ring
|
||||||
|
buffer (500 events, in-memory) backfills the terminal on page load
|
||||||
|
but dies on harness restart, and only ever holds the most recent
|
||||||
|
turn or two. Persist to sqlite (`events(agent, id, ts, kind,
|
||||||
|
payload_json)`) so the operator can scroll back through prior
|
||||||
|
turns, and so `/events/history` survives restart. Cap rows per
|
||||||
|
agent or auto-vacuum on age, same trade-off as the bounded broker
|
||||||
|
entry below.
|
||||||
|
- **Granular agent state badge** above the terminal: `idle 💤 / thinking 🧠 /
|
||||||
|
compacting 📦` with an age timer (`thinking · 12s`). Drives a state
|
||||||
|
channel from the harness: idle when waiting on the inbox, thinking
|
||||||
|
while claude's stream is open, compacting when `/compact` is in
|
||||||
|
flight. Replaces the binary "harness alive — turn loop running" line.
|
||||||
|
- **Terminal: slash commands + tab-completion.** Operator-facing
|
||||||
|
in-terminal commands: `/help`, `/model`, `/compact`, `/clear`. Tab
|
||||||
|
completes command names + model names (cf. bitburner-agent's pattern).
|
||||||
|
- **Terminal: multi-line input.** Replace the single-line `<input>` with
|
||||||
|
an auto-growing textarea; Enter sends, Shift+Enter newlines.
|
||||||
|
- **Terminal: cancel-current-turn button.** Explicit "kill claude
|
||||||
|
process for this turn" control. Harness needs to track the
|
||||||
|
in-flight claude child PID and offer a `/cancel` endpoint that sends
|
||||||
|
SIGTERM; UI surfaces a button while the state badge is `thinking`.
|
||||||
|
Slash-command equivalent: `/cancel`.
|
||||||
|
- **`/compact` trigger.** Operator-initiated compaction of the current
|
||||||
|
claude session — `claude --print --continue` with `/compact` over the
|
||||||
|
same session id. Surfaces as a slash command in the terminal + a
|
||||||
|
toolbar button while the state badge is `idle`. Sets state to
|
||||||
|
`compacting` during the run.
|
||||||
|
- **Visuals.** Frosted-glass backdrop blur on the terminal wrap,
|
||||||
|
per-event fade-in slide-up animation on new rows, badge pulse
|
||||||
|
animation on state-badge transitions.
|
||||||
- **xterm.js terminal** embedded per-agent, attached to a PTY exposed by
|
- **xterm.js terminal** embedded per-agent, attached to a PTY exposed by
|
||||||
the harness. Pairs well with the unprivileged-container work — would let
|
the harness. Pairs well with the unprivileged-container work — would let
|
||||||
the operator drop into the container without `nixos-container root-login`.
|
the operator drop into the container without `nixos-container root-login`.
|
||||||
|
|
||||||
|
## Telemetry
|
||||||
|
|
||||||
|
- **Harness stats per agent in sqlite, charted on the agent page.**
|
||||||
|
bitburner-agent samples 18 series; for hyperhive the generally-applicable
|
||||||
|
ones are:
|
||||||
|
- turns/min, tool calls/turn, turn duration p50/p95
|
||||||
|
- claude exit code distribution (ok vs `--compact`-retry vs failure)
|
||||||
|
- inbox depth (current + max-over-window)
|
||||||
|
- messages sent/received per turn (split by recipient: peer / operator /
|
||||||
|
manager / system)
|
||||||
|
- approval queue length (across all agents — dashboard-level)
|
||||||
|
- per-tool usage counts (Read/Edit/Bash/send/recv/…)
|
||||||
|
- time-since-last-turn (helps spot stuck agents)
|
||||||
|
- notes file size growth (cues compaction)
|
||||||
|
Backend: a `stats` table with `(agent, ts, key, value)` written from
|
||||||
|
the harness on `TurnEnd`; `GET /api/stats?since=…` returns the
|
||||||
|
series; agent page renders with a small chart lib (uPlot is light).
|
||||||
|
|
||||||
## Manager → operator question channel
|
## Manager → operator question channel
|
||||||
|
|
||||||
- **TTL / cancel on `ask_operator`.** Questions today block forever; the
|
- **TTL / cancel on `ask_operator`.** Questions today block forever; the
|
||||||
|
|
@ -42,6 +93,14 @@ Pick anything from here when relevant. Cross-cutting design notes live in
|
||||||
|
|
||||||
## Loop substance
|
## Loop substance
|
||||||
|
|
||||||
|
- **`nap` tool.** Agent-side MCP tool `mcp__hyperhive__nap(seconds)` that
|
||||||
|
parks the turn loop for a short while before next-message processing.
|
||||||
|
Use cases: agent decides it has nothing useful to do, or wants to
|
||||||
|
throttle itself between rapid wake events. Implementation: harness
|
||||||
|
records a "wake-not-before" timestamp; `recv_blocking` skips the long
|
||||||
|
poll until that ts; the state badge reads `napping · MM:SS` during.
|
||||||
|
Operator can cancel via the same `/cancel` slash command or a
|
||||||
|
dashboard button.
|
||||||
- **Notes compaction.** `/state/` is bind-mounted persistently and agents
|
- **Notes compaction.** `/state/` is bind-mounted persistently and agents
|
||||||
are told (in the system prompt) to keep `/state/notes.md` for durable
|
are told (in the system prompt) to keep `/state/notes.md` for durable
|
||||||
knowledge — but we don't currently nudge them to compact when notes
|
knowledge — but we don't currently nudge them to compact when notes
|
||||||
|
|
|
||||||
|
|
@ -125,9 +125,14 @@ pre.diff {
|
||||||
max-height: 30em;
|
max-height: 30em;
|
||||||
}
|
}
|
||||||
/* Terminal-ish wrapper holding the live output + prompt input as one
|
/* Terminal-ish wrapper holding the live output + prompt input as one
|
||||||
unit. Crust as bg (almost-black), slightly inset, mauve phosphor glow. */
|
unit. Crust as bg (almost-black), slightly inset, mauve phosphor glow.
|
||||||
|
Frosted-glass backdrop blur: the page bg behind the wrap gets softened,
|
||||||
|
so anything that bleeds through (page banner glow, scroll position)
|
||||||
|
reads as out-of-focus depth instead of sharp competing detail. */
|
||||||
.terminal-wrap {
|
.terminal-wrap {
|
||||||
background: #11111b;
|
background: rgba(17, 17, 27, 0.78);
|
||||||
|
-webkit-backdrop-filter: blur(8px) saturate(120%);
|
||||||
|
backdrop-filter: blur(8px) saturate(120%);
|
||||||
border: 1px solid var(--purple-dim);
|
border: 1px solid var(--purple-dim);
|
||||||
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.7);
|
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.7);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
@ -151,18 +156,21 @@ pre.diff {
|
||||||
.term-input { padding: 0.4em 1em 0.8em; }
|
.term-input { padding: 0.4em 1em 0.8em; }
|
||||||
.term-input .sendform-term {
|
.term-input .sendform-term {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 0.5em;
|
gap: 0.5em;
|
||||||
border-top: 1px dashed var(--purple-dim);
|
border-top: 1px dashed var(--purple-dim);
|
||||||
padding-top: 0.5em;
|
padding-top: 0.5em;
|
||||||
}
|
}
|
||||||
|
.term-input .prompt, .term-input .submit-hint {
|
||||||
|
padding-top: 0.25em;
|
||||||
|
}
|
||||||
.term-input .prompt {
|
.term-input .prompt {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
text-shadow: 0 0 6px rgba(166, 227, 161, 0.6);
|
text-shadow: 0 0 6px rgba(166, 227, 161, 0.6);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.term-input input {
|
.term-input textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|
@ -172,11 +180,15 @@ pre.diff {
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
padding: 0.2em 0;
|
padding: 0.2em 0;
|
||||||
caret-color: var(--green);
|
caret-color: var(--green);
|
||||||
|
resize: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
line-height: 1.4;
|
||||||
|
min-height: 1.4em;
|
||||||
}
|
}
|
||||||
.term-input input::placeholder { color: var(--muted); }
|
.term-input textarea::placeholder { color: var(--muted); }
|
||||||
.term-input .submit-hint { color: var(--muted); font-size: 0.8em; flex: 0 0 auto; }
|
.term-input .submit-hint { color: var(--muted); font-size: 0.8em; flex: 0 0 auto; }
|
||||||
.term-input.disabled .prompt { color: var(--muted); text-shadow: none; }
|
.term-input.disabled .prompt { color: var(--muted); text-shadow: none; }
|
||||||
.term-input.disabled input { color: var(--muted); }
|
.term-input.disabled textarea { color: var(--muted); }
|
||||||
.live {
|
.live {
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
border: 1px solid var(--purple-dim);
|
border: 1px solid var(--purple-dim);
|
||||||
|
|
@ -191,6 +203,26 @@ pre.diff {
|
||||||
margin-left: 0.6em;
|
margin-left: 0.6em;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
text-shadow: 0 0 6px rgba(250, 179, 135, 0.55);
|
text-shadow: 0 0 6px rgba(250, 179, 135, 0.55);
|
||||||
|
animation: badge-pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes badge-pulse {
|
||||||
|
0%, 100% { opacity: 1; text-shadow: 0 0 6px rgba(250, 179, 135, 0.55); }
|
||||||
|
50% { opacity: 0.7; text-shadow: 0 0 14px rgba(250, 179, 135, 0.95); }
|
||||||
|
}
|
||||||
|
/* Per-event fade-in slide-up. Applied to every row the live panel
|
||||||
|
appends; the `.no-anim` modifier lets history-backfill skip the
|
||||||
|
animation (we don't want 100 rows fading in at once on page load). */
|
||||||
|
.live .row,
|
||||||
|
.live details.row {
|
||||||
|
animation: row-fade-in 220ms ease-out both;
|
||||||
|
}
|
||||||
|
.live .row.no-anim,
|
||||||
|
.live details.row.no-anim {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
@keyframes row-fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
details.row {
|
details.row {
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Clear text inputs the operator typed into (the form value was sent).
|
// Clear text inputs the operator typed into (the form value was sent).
|
||||||
f.querySelectorAll('input[type="text"], input:not([type])').forEach((i) => { i.value = ''; });
|
f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; });
|
||||||
// Re-enable the button — refreshState() often skips re-rendering the
|
// Re-enable the button — refreshState() often skips re-rendering the
|
||||||
// form (status unchanged), so without this the spinner sticks and
|
// form (status unchanged), so without this the spinner sticks and
|
||||||
// the operator can't submit again.
|
// the operator can't submit again.
|
||||||
|
|
@ -155,6 +155,47 @@
|
||||||
let lastOutputLen = -1;
|
let lastOutputLen = -1;
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
let termInputRendered = false;
|
let termInputRendered = false;
|
||||||
|
// Filled in by the live-event IIFE below. Used by the slash-command
|
||||||
|
// dispatcher to print local-only rows ('help', errors) and to clear
|
||||||
|
// the terminal on `/clear`.
|
||||||
|
let termAPI = null;
|
||||||
|
|
||||||
|
const SLASH_COMMANDS = [
|
||||||
|
{ name: '/help', desc: 'list slash commands' },
|
||||||
|
{ name: '/clear', desc: 'wipe the terminal panel (local-only)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function handleSlashCommand(line) {
|
||||||
|
if (!termAPI) return false;
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed.startsWith('/')) return false;
|
||||||
|
const [cmd] = trimmed.split(/\s+/);
|
||||||
|
switch (cmd) {
|
||||||
|
case '/help':
|
||||||
|
termAPI.row('note', '· /help');
|
||||||
|
for (const c of SLASH_COMMANDS) {
|
||||||
|
termAPI.row('note', ' ' + c.name.padEnd(10) + ' — ' + c.desc);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
case '/clear':
|
||||||
|
termAPI.clear();
|
||||||
|
termAPI.row('note', '· terminal cleared (local view only — server history kept)');
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
termAPI.row('turn-end-fail', '✗ unknown slash command: ' + cmd + ' — try /help');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cycle through commands when operator hits Tab on a `/…` prefix.
|
||||||
|
function completeSlash(prefix) {
|
||||||
|
const matches = SLASH_COMMANDS.filter((c) => c.name.startsWith(prefix));
|
||||||
|
if (!matches.length) return null;
|
||||||
|
// Cycle: when the current prefix already equals a command name,
|
||||||
|
// advance to the next match.
|
||||||
|
const idx = matches.findIndex((c) => c.name === prefix);
|
||||||
|
return matches[(idx + 1) % matches.length].name;
|
||||||
|
}
|
||||||
|
|
||||||
function renderTermInput(label, online) {
|
function renderTermInput(label, online) {
|
||||||
const slot = $('term-input');
|
const slot = $('term-input');
|
||||||
|
|
@ -165,20 +206,52 @@
|
||||||
action: '/send', method: 'POST',
|
action: '/send', method: 'POST',
|
||||||
class: 'sendform-term', 'data-async': '',
|
class: 'sendform-term', 'data-async': '',
|
||||||
});
|
});
|
||||||
|
const ta = el('textarea', {
|
||||||
|
name: 'body', placeholder: 'message ' + label + '…',
|
||||||
|
required: '', autocomplete: 'off', rows: '1',
|
||||||
|
});
|
||||||
|
// Enter submits, Shift+Enter inserts a newline. Auto-grow up to
|
||||||
|
// ~8 rows of content, then scroll inside the textarea.
|
||||||
|
const MAX_PX = 12 * 16; // ~8 lines @ 1.5 line-height, 1em base
|
||||||
|
const grow = () => {
|
||||||
|
ta.style.height = 'auto';
|
||||||
|
ta.style.height = Math.min(ta.scrollHeight, MAX_PX) + 'px';
|
||||||
|
};
|
||||||
|
ta.addEventListener('input', grow);
|
||||||
|
ta.addEventListener('keydown', (e) => {
|
||||||
|
// Tab-complete slash commands when the buffer starts with `/`.
|
||||||
|
if (e.key === 'Tab' && ta.value.startsWith('/') && !ta.value.includes(' ')) {
|
||||||
|
const next = completeSlash(ta.value);
|
||||||
|
if (next) { e.preventDefault(); ta.value = next; return; }
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||||
|
e.preventDefault();
|
||||||
|
const line = ta.value;
|
||||||
|
if (!line.trim()) return;
|
||||||
|
// Intercept slash commands locally; never send them to the agent.
|
||||||
|
if (line.trim().startsWith('/')) {
|
||||||
|
if (handleSlashCommand(line)) {
|
||||||
|
ta.value = '';
|
||||||
|
grow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
form.requestSubmit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Reset height after async submit clears the value.
|
||||||
|
form.addEventListener('submit', () => setTimeout(grow, 0));
|
||||||
form.append(
|
form.append(
|
||||||
el('span', { class: 'prompt' }, 'operator@' + label + ' ▸'),
|
el('span', { class: 'prompt' }, 'operator@' + label + ' ▸'),
|
||||||
el('input', {
|
ta,
|
||||||
name: 'body', placeholder: 'message ' + label + '…',
|
el('span', { class: 'submit-hint' }, '↵ send · ⇧↵ newline · /help'),
|
||||||
required: '', autocomplete: 'off',
|
|
||||||
}),
|
|
||||||
el('span', { class: 'submit-hint' }, 'enter ↵'),
|
|
||||||
);
|
);
|
||||||
slot.append(form);
|
slot.append(form);
|
||||||
termInputRendered = true;
|
termInputRendered = true;
|
||||||
}
|
}
|
||||||
slot.classList.toggle('disabled', !online);
|
slot.classList.toggle('disabled', !online);
|
||||||
const input = slot.querySelector('input');
|
const ta = slot.querySelector('textarea');
|
||||||
if (input) input.disabled = !online;
|
if (ta) ta.disabled = !online;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track banner activity by reference-counting in-flight turns. A turn
|
// Track banner activity by reference-counting in-flight turns. A turn
|
||||||
|
|
@ -250,10 +323,18 @@
|
||||||
function clearPlaceholder() {
|
function clearPlaceholder() {
|
||||||
if (placeholder) { log.innerHTML = ''; placeholder = null; }
|
if (placeholder) { log.innerHTML = ''; placeholder = null; }
|
||||||
}
|
}
|
||||||
|
// Backfill replays mark rows .no-anim so we don't stagger 100 fade-ins
|
||||||
|
// on page load. Set via `currentNoAnim` before the row helpers fire.
|
||||||
|
let currentNoAnim = false;
|
||||||
|
// Expose the panel API for slash commands (`/help`, `/clear`).
|
||||||
|
termAPI = {
|
||||||
|
row: (cls, text) => row(cls, text),
|
||||||
|
clear: () => { log.innerHTML = ''; placeholder = null; },
|
||||||
|
};
|
||||||
function row(cls, text) {
|
function row(cls, text) {
|
||||||
clearPlaceholder();
|
clearPlaceholder();
|
||||||
const e = document.createElement('div');
|
const e = document.createElement('div');
|
||||||
e.className = 'row ' + (cls || '');
|
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
||||||
e.textContent = text;
|
e.textContent = text;
|
||||||
log.appendChild(e);
|
log.appendChild(e);
|
||||||
log.scrollTop = log.scrollHeight;
|
log.scrollTop = log.scrollHeight;
|
||||||
|
|
@ -262,7 +343,7 @@
|
||||||
function details(cls, summary, body) {
|
function details(cls, summary, body) {
|
||||||
clearPlaceholder();
|
clearPlaceholder();
|
||||||
const d = document.createElement('details');
|
const d = document.createElement('details');
|
||||||
d.className = 'row ' + (cls || '');
|
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
||||||
const s = document.createElement('summary');
|
const s = document.createElement('summary');
|
||||||
s.textContent = summary;
|
s.textContent = summary;
|
||||||
d.appendChild(s);
|
d.appendChild(s);
|
||||||
|
|
@ -394,11 +475,13 @@
|
||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
const events = await resp.json();
|
const events = await resp.json();
|
||||||
let openTurns = 0;
|
let openTurns = 0;
|
||||||
|
currentNoAnim = true;
|
||||||
for (const ev of events) {
|
for (const ev of events) {
|
||||||
handle(ev, { fromHistory: true });
|
handle(ev, { fromHistory: true });
|
||||||
if (ev.kind === 'turn_start') openTurns += 1;
|
if (ev.kind === 'turn_start') openTurns += 1;
|
||||||
else if (ev.kind === 'turn_end') openTurns = Math.max(0, openTurns - 1);
|
else if (ev.kind === 'turn_end') openTurns = Math.max(0, openTurns - 1);
|
||||||
}
|
}
|
||||||
|
currentNoAnim = false;
|
||||||
for (let i = 0; i < openTurns; i++) setBannerActive(true);
|
for (let i = 0; i < openTurns; i++) setBannerActive(true);
|
||||||
if (events.length) row('note', '─── live (older above) ───');
|
if (events.length) row('note', '─── live (older above) ───');
|
||||||
else setPlaceholder('(connected — waiting for events)');
|
else setPlaceholder('(connected — waiting for events)');
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,11 @@ summary:hover { color: var(--purple); }
|
||||||
border: 1px solid var(--amber);
|
border: 1px solid var(--amber);
|
||||||
box-shadow: 0 0 12px -4px var(--amber);
|
box-shadow: 0 0 12px -4px var(--amber);
|
||||||
padding: 0.6em 0.9em;
|
padding: 0.6em 0.9em;
|
||||||
|
animation: questions-pulse 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes questions-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 12px -4px rgba(250, 179, 135, 0.55); }
|
||||||
|
50% { box-shadow: 0 0 22px -2px rgba(250, 179, 135, 0.95); }
|
||||||
}
|
}
|
||||||
.questions li.question {
|
.questions li.question {
|
||||||
padding: 0.4em 0;
|
padding: 0.4em 0;
|
||||||
|
|
@ -261,7 +266,9 @@ summary:hover { color: var(--purple); }
|
||||||
.inbox .msg-sep { color: var(--muted); }
|
.inbox .msg-sep { color: var(--muted); }
|
||||||
.inbox .msg-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
|
.inbox .msg-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
|
||||||
.msgflow {
|
.msgflow {
|
||||||
background: var(--bg-elev);
|
background: rgba(24, 24, 37, 0.78);
|
||||||
|
-webkit-backdrop-filter: blur(8px) saturate(120%);
|
||||||
|
backdrop-filter: blur(8px) saturate(120%);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 0.8em;
|
padding: 0.8em;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
|
|
@ -269,6 +276,13 @@ summary:hover { color: var(--purple); }
|
||||||
max-height: 32em;
|
max-height: 32em;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
.msgflow .msgrow {
|
||||||
|
animation: row-fade-in 220ms ease-out both;
|
||||||
|
}
|
||||||
|
@keyframes row-fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
.msgrow { display: grid; grid-template-columns: auto auto auto auto auto 1fr; gap: 0.6em; align-items: baseline; padding: 0.1em 0; }
|
.msgrow { display: grid; grid-template-columns: auto auto auto auto auto 1fr; gap: 0.6em; align-items: baseline; padding: 0.1em 0; }
|
||||||
.msgrow.sent .msg-arrow { color: var(--cyan); }
|
.msgrow.sent .msg-arrow { color: var(--cyan); }
|
||||||
.msgrow.delivered .msg-arrow { color: var(--green); }
|
.msgrow.delivered .msg-arrow { color: var(--green); }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue