Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 122 additions & 14 deletions desktop/renderer/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,12 +233,27 @@ function addEvent(html: string): HTMLElement {
scrollChat();
return node;
}
// Stick-to-bottom autoscroll: only follow new output when the user is already near the
// bottom, so scrolling up to re-read mid-stream isn't yanked back down. Instant (not
// smooth-animated) per token — that reads as smooth during a stream and avoids jank.
const scrollChat = () => {
const c = $("#chat")!;
if (c.scrollHeight - c.scrollTop - c.clientHeight < 150) requestAnimationFrame(() => { c.scrollTop = c.scrollHeight; });
// Stick-to-bottom autoscroll, rAF-batched for buttery playback under rapid tokens.
// Many scrollChat() calls within one frame coalesce into a SINGLE scrollTop write, so the
// browser never thrashes layout mid-stream. We only follow output while the user is parked
// near the bottom (STICK_PX); the moment they scroll UP to re-read, autoscroll releases and
// stays released until they come back down — so re-reading mid-stream is never yanked.
const STICK_PX = 150;
let scrollPending = false; // a follow-frame is already queued
let lastWroteTop = -1; // the scrollTop value WE last wrote — lets us spot a user scroll-up
const nearBottom = (c: HTMLElement): boolean => c.scrollHeight - c.scrollTop - c.clientHeight < STICK_PX;
const scrollChat = (): void => {
const c = $("#chat");
if (!c) return;
// A user scroll-up since our last programmatic write releases the stick until they return.
if (lastWroteTop >= 0 && c.scrollTop < lastWroteTop - 2 && !nearBottom(c)) return;
if (scrollPending || !nearBottom(c)) return;
scrollPending = true;
requestAnimationFrame(() => {
scrollPending = false;
const cc = $("#chat");
if (cc && nearBottom(cc)) { cc.scrollTop = cc.scrollHeight; lastWroteTop = cc.scrollTop; }
});
};

// P10.1 (ADR-0011): a friendly, honest "what's happening" phase label — an opening guess
Expand All @@ -258,8 +273,78 @@ function phaseForTool(name: string, detail: string): string {
if (/fetch|web|http|browse/.test(n)) return "Searching the web…";
return `Using ${name}…`;
}
// A category icon for a tool, so each consolidated activity step reads at a glance.
function phaseIcon(name: string): string {
const n = name.toLowerCase();
if (/read|grep|glob|search|find|^ls|list/.test(n)) return "search";
if (/edit|write|notebook|patch|apply|create/.test(n)) return "folder";
if (/bash|shell|run|exec|command/.test(n)) return "bolt";
if (/fetch|web|http|browse/.test(n)) return "runs";
return "eye";
}
const fmtClock = (ms: number): string => { const s = Math.max(0, Math.floor(ms / 1000)); return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`; };

// ── Consolidating activity window (the "working / agent thoughts" surface) ──
// Instead of an ever-growing stack of raw .evt chips, the agent's tool calls collapse into
// ONE compact window per turn: a head (live current step + a count) you can expand to see the
// full step list, and a tidy one-line summary on done. Security blocks are NEVER folded in
// here — onBlock keeps emitting its own loud .evt.block chip alongside this window.
interface ThoughtsWin {
el: HTMLElement;
/** Record a tool/activity step. */
step(name: string, detail: string): void;
/** Collapse into the final one-line summary (auto-collapse on done). */
finish(ms: number): void;
}
function createThoughts(): ThoughtsWin {
const win = el(`<div class="thoughts open" data-streaming="1">
<button class="thoughts-head" type="button" aria-expanded="true">
<span class="thoughts-spin">${icon("spark", 13)}</span>
<span class="thoughts-cur">Working…</span>
<span class="thoughts-count" hidden>0</span>
<span class="thoughts-chev">${icon("chevron", 14)}</span>
</button>
<div class="thoughts-body"></div>
</div>`);
const headBtn = $(".thoughts-head", win) as HTMLButtonElement;
const curEl = $(".thoughts-cur", win) as HTMLElement;
const countEl = $(".thoughts-count", win) as HTMLElement;
const body = $(".thoughts-body", win) as HTMLElement;
let steps = 0;
const files = new Set<string>();
const toggle = (open: boolean) => {
win.classList.toggle("open", open);
headBtn.setAttribute("aria-expanded", String(open));
};
headBtn.addEventListener("click", () => toggle(!win.classList.contains("open")));
return {
el: win,
step(name: string, detail: string) {
steps++;
const label = phaseForTool(name, detail);
curEl.textContent = label;
countEl.hidden = false;
countEl.textContent = String(steps);
if (/edit|write|notebook|patch|apply|create/i.test(name) && detail) files.add(detail.trim());
body.appendChild(el(`<div class="thoughts-step">${icon(phaseIcon(name), 13)}<span class="ts-k">${esc(name)}</span><span class="ts-d">${esc(detail)}</span></div>`));
// Keep the newest step in view while expanded, without stealing the page scroll.
if (win.classList.contains("open")) body.scrollTop = body.scrollHeight;
},
finish(ms: number) {
win.removeAttribute("data-streaming");
win.classList.add("done");
toggle(false); // auto-collapse to the tidy summary
const fileBit = files.size ? ` · ${files.size} file${files.size === 1 ? "" : "s"}` : "";
const secs = ms / 1000;
const timeBit = secs >= 0.05 ? ` · ${secs < 10 ? secs.toFixed(1) : Math.round(secs)}s` : "";
curEl.textContent = steps
? `${steps} step${steps === 1 ? "" : "s"}${fileBit}${timeBit}`
: "No tools used";
countEl.hidden = true;
},
};
}

async function send(): Promise<void> {
const ta = $("#input") as HTMLTextAreaElement;
const text = ta.value.trim();
Expand All @@ -272,35 +357,58 @@ async function send(): Promise<void> {
const textEl = $(".text", node) as HTMLElement;
textEl.innerHTML = "";
// P10.1 response activity HUD: live MM:SS timer + semantic phase + running token-cost.
const hud = el(`<div class="hud streaming">${icon("bolt", 12)}<span class="hud-t">00:00</span><span class="hud-sep">·</span><span class="hud-phase"></span><span class="hud-meta"></span></div>`);
const hud = el(`<div class="hud streaming"><span class="hud-ic">${icon("bolt", 12)}</span><span class="hud-t">00:00</span><span class="hud-sep">·</span><span class="hud-phase"></span><span class="hud-meta"></span></div>`);
const streamEl = el(`<div class="stream"></div>`);
textEl.append(streamEl, hud); // status sits BELOW the line that's filling in
streamEl.innerHTML = `<span class="cursor"></span>`;
// The consolidating activity window lives between the answer and the HUD; created lazily
// on the first tool event so a pure-text turn shows nothing extra.
let thoughts: ThoughtsWin | null = null;
let buf = "";
const t0 = Date.now();
let phase = guessPhase(text), sawTool = false, tok = 0, cost = 0;
// Cold start: the timer is already ticking but nothing has arrived — show "Warming up…"
// so the user always sees something meaningful before the first token/tool.
let phase = "Warming up…", sawTool = false, tok = 0, cost = 0;
const phaseEl = $(".hud-phase", hud) as HTMLElement;
const setPhase = (p: string) => {
if (p === phase) return;
phase = p;
// Brief crossfade on phase change — GPU-friendly (opacity only), respects reduced-motion via CSS.
phaseEl.classList.remove("swap"); void phaseEl.offsetWidth; phaseEl.classList.add("swap");
phaseEl.textContent = p;
};
const paintHud = () => {
($(".hud-t", hud) as HTMLElement).textContent = fmtClock(Date.now() - t0);
($(".hud-phase", hud) as HTMLElement).textContent = phase;
if (phaseEl.textContent !== phase) phaseEl.textContent = phase;
($(".hud-meta", hud) as HTMLElement).textContent = tok ? `· ${fmtNum(tok)} tok · ~$${cost.toFixed(4)}` : "";
};
phaseEl.textContent = phase;
paintHud();
const timer = window.setInterval(paintHud, 1000);
let finished = false;
const finishHud = () => {
if (finished) return;
finished = true;
clearInterval(timer);
const ic = $(".ic", hud); if (ic) ic.outerHTML = icon("check", 12);
const ic = $(".hud-ic", hud); if (ic) ic.innerHTML = icon("check", 12);
hud.classList.remove("streaming"); hud.classList.add("done");
phase = "Done"; paintHud();
setPhase("Done"); paintHud();
thoughts?.finish(Date.now() - t0);
};
const onEvent = (e: ChatEvent) => {
if (e.type === "token") { buf += e.text; if (!sawTool) phase = "Responding…"; streamEl.innerHTML = renderMarkdown(buf) + `<span class="cursor"></span>`; paintHud(); scrollChat(); }
else if (e.type === "tool") { sawTool = true; phase = phaseForTool(e.name, e.detail); paintHud(); addEvent(`<div class="evt tool">${icon("eye", 15)}<span class="k">${esc(e.name)}</span><span>${esc(e.detail)}</span></div>`); }
if (e.type === "token") { buf += e.text; if (!sawTool) setPhase("Responding…"); streamEl.innerHTML = renderMarkdown(buf) + `<span class="cursor"></span>`; paintHud(); scrollChat(); }
else if (e.type === "tool") {
sawTool = true; setPhase(phaseForTool(e.name, e.detail)); paintHud();
if (!thoughts) { thoughts = createThoughts(); streamEl.after(thoughts.el); } // window sits below the answer
thoughts.step(e.name, e.detail);
scrollChat();
}
else if (e.type === "block") onBlock(e);
else if (e.type === "usage") { tok = e.used; cost = e.cost; state.liveUsage = { used: e.used, size: e.size, cost: e.cost }; paintHud(); renderStatus(); renderMetricsRail(); }
else if (e.type === "done") { streamEl.innerHTML = renderMarkdown(buf); (node as MsgNode)._md = buf; finishHud(); state.streaming = false; setSendEnabled(); }
};
try { await bridge.sendPrompt(text, onEvent); }
finally { (node as MsgNode)._md = buf; if (state.streaming) { streamEl.innerHTML = renderMarkdown(buf); finishHud(); state.streaming = false; setSendEnabled(); } else clearInterval(timer); void renderSessions(); void refreshBudget(false); }
finally { (node as MsgNode)._md = buf; if (state.streaming) { streamEl.innerHTML = renderMarkdown(buf); finishHud(); state.streaming = false; setSendEnabled(); } else { finishHud(); } void renderSessions(); void refreshBudget(false); }
}

function onBlock(e: Extract<ChatEvent, { type: "block" }>): void {
Expand Down
63 changes: 52 additions & 11 deletions desktop/renderer/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -261,15 +261,22 @@ body.resizing .sidebar,body.resizing .inspector{transition:none}
.stream>:last-child{margin-bottom:0}
/* P10.1 response activity HUD: live timer + semantic phase + running cost */
.hud{display:flex;align-items:center;gap:6px;font-family:var(--mono);font-size:11px;color:var(--txt-3);margin:9px 0 0;line-height:1}
.hud .ic{flex:none}
.hud.streaming .ic{color:var(--accent-2);animation:hudpulse 1.4s var(--ease) infinite}
.hud.done .ic{color:var(--green);animation:none}
.hud .ic{flex:none;display:block}
.hud-ic{flex:none;display:inline-flex;transition:color var(--t) var(--ease)}
.hud.streaming .hud-ic{color:var(--accent-2)}
.hud.streaming .hud-ic .ic{animation:hudpulse 1.5s var(--ease) infinite}
.hud.done .hud-ic{color:var(--green)}
.hud.done .hud-ic .ic{animation:checkpop var(--t-slow) var(--ease-out)}
.hud-t{color:var(--txt-2);font-variant-numeric:tabular-nums}
.hud-sep{opacity:.45}
/* phase label: a tasteful crossfade when it changes; reduced-motion neutralises it via the global rule */
.hud-phase{color:var(--txt-2)}
.hud-phase.swap{animation:phasein 260ms var(--ease-out)}
.hud.done .hud-phase{color:var(--txt-4)}
.hud-meta{color:var(--txt-4)}
@keyframes hudpulse{0%,100%{opacity:1}50%{opacity:.35}}
@keyframes hudpulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.9)}}
@keyframes checkpop{0%{transform:scale(.6);opacity:.4}60%{transform:scale(1.12)}100%{transform:scale(1);opacity:1}}
@keyframes phasein{from{opacity:0;transform:translateY(2px)}to{opacity:1;transform:none}}
.row-ctx{font-family:var(--mono);font-size:9.5px;color:var(--txt-4);background:var(--bg-2);border:1px solid var(--line);border-radius:5px;padding:1px 5px;margin-left:6px;white-space:nowrap}
/* P10.2 cross-model cost & savings ledger */
.ledger-card{background:linear-gradient(180deg,var(--bg-3),var(--bg-2));border:1px solid var(--line);border-radius:11px;
Expand Down Expand Up @@ -323,21 +330,55 @@ body.resizing .sidebar,body.resizing .inspector{transition:none}
background:linear-gradient(150deg,var(--accent),#7d39c4);color:#fff;box-shadow:0 0 22px rgba(198,75,214,.4),inset 0 1px 0 rgba(255,255,255,.18);transform:scale(1.5)}
.chat-hint .h{font-size:18px;font-weight:600;color:var(--txt);margin-top:8px;letter-spacing:-.01em}
.chat-hint .d{font-size:13.5px;max-width:340px;line-height:1.62;color:var(--txt-2)}
.cursor{display:inline-block;width:7px;height:16px;vertical-align:-2px;background:var(--accent-2);
border-radius:2px;animation:blink 1s steps(2) infinite}
@keyframes blink{50%{opacity:0}}
/* streaming caret: a soft pulsing bar (eased fade, not a hard blink) with a faint glow — feels
alive without flicker. Sits flush after the last token; reduced-motion stops the animation. */
.cursor{display:inline-block;width:7px;height:1.05em;vertical-align:-2px;margin-left:1px;border-radius:2px;
background:var(--accent-2);box-shadow:0 0 7px var(--accent-dim);
animation:caret 1.05s var(--ease) infinite;will-change:opacity}
@keyframes caret{0%,42%{opacity:1}70%,100%{opacity:.18}}

/* tool + block chips inside the thread */
/* tool + block chips inside the thread.
Tool calls now consolidate into the .thoughts window (below); the lone .evt that still
renders inline is the SECURITY block — kept loud and never folded into the collapse. */
.evt{display:flex;align-items:center;gap:9px;font-size:12.5px;padding:8px 11px;border-radius:9px;
border:1px solid var(--line);background:var(--bg-1);color:var(--txt-2);width:fit-content;max-width:100%}
border:1px solid var(--line);background:var(--bg-1);color:var(--txt-2);width:fit-content;max-width:100%;
animation:rise var(--t) var(--ease-out)}
.evt .ic{color:var(--txt-3)}
.evt.tool .k{color:var(--cyan);font-weight:600}
.evt.block{border-color:rgba(239,95,95,.4);background:linear-gradient(90deg,var(--red-dim),transparent);
color:#ffd0d0;cursor:pointer;transition:border-color var(--t)}
.evt.block:hover{border-color:var(--red)}
color:#ffd0d0;cursor:pointer;transition:border-color var(--t),box-shadow var(--t)}
.evt.block:hover{border-color:var(--red);box-shadow:0 0 0 3px var(--red-dim)}
.evt.block .ic{color:var(--red)}
.evt.block .reason{color:var(--txt-2)}

/* ── Consolidating activity / "thoughts" window ──
One compact, collapsible surface per turn: a head with the live current step + a count,
expandable to the full step list. Auto-collapses to a one-line summary on done. */
.thoughts{margin:8px 0 2px;border:1px solid var(--line);border-radius:11px;background:var(--bg-1);
width:fit-content;max-width:100%;overflow:hidden;animation:rise var(--t) var(--ease-out)}
.thoughts-head{display:flex;align-items:center;gap:8px;width:100%;text-align:left;cursor:pointer;
background:transparent;border:0;color:var(--txt-2);font-family:var(--mono);font-size:12px;
padding:8px 11px;transition:background var(--t)}
.thoughts-head:hover{background:var(--bg-2)}
.thoughts-spin{flex:none;display:inline-flex;color:var(--accent-2)}
.thoughts[data-streaming] .thoughts-spin .ic{animation:spin 1.4s linear infinite}
.thoughts.done .thoughts-spin{color:var(--green)}
.thoughts-cur{flex:1;min-width:0;color:var(--txt-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.thoughts.done .thoughts-cur{color:var(--txt-3)}
.thoughts-count{flex:none;font-size:10.5px;font-weight:600;color:var(--accent-2);background:var(--accent-dim);
border-radius:999px;padding:1px 7px;min-width:18px;text-align:center}
.thoughts-chev{flex:none;display:inline-flex;color:var(--txt-4);transition:transform var(--t) var(--ease)}
.thoughts.open .thoughts-chev{transform:rotate(90deg)}
/* body: collapses via max-height (also the scroll container while expanded) */
.thoughts-body{max-height:0;overflow:hidden;transition:max-height var(--t-slow) var(--ease)}
.thoughts.open .thoughts-body{max-height:240px;overflow-y:auto;scrollbar-width:thin}
.thoughts-step{display:flex;align-items:center;gap:8px;font-size:12px;font-family:var(--mono);
color:var(--txt-3);padding:5px 11px;border-top:1px solid var(--line-soft);animation:rise var(--t-fast) var(--ease-out)}
.thoughts-step .ic{flex:none;color:var(--txt-4)}
.thoughts-step .ts-k{color:var(--cyan);font-weight:600;flex:none}
.thoughts-step .ts-d{color:var(--txt-3);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@keyframes spin{to{transform:rotate(360deg)}}

.composer-wrap{padding:10px 26px 18px}
.composer{max-width:min(1080px,94vw);margin:0 auto;
background:linear-gradient(180deg,var(--bg-3),var(--bg-2));border:1px solid var(--line);
Expand Down