From 9cb0c53ffe1140bdcb52938ed2e960103a8ac123 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:52:50 +0200 Subject: [PATCH 1/7] fix(ui): clarify chat, goals and process trace --- docs/development.md | 13 + docs/machines.md | 8 +- docs/trace.md | 12 +- packages/contracts/src/index.ts | 2 +- packages/core/src/activity.ts | 2 +- packages/core/src/threads.ts | 4 +- packages/core/test/activity.test.ts | 16 ++ packages/ui/src/app.test.ts | 34 +-- packages/ui/src/components/Composer.svelte | 22 +- packages/ui/src/components/MessageList.svelte | 90 ++++--- packages/ui/src/components/Prose.svelte | 74 ++---- packages/ui/src/components/Prose.test.ts | 5 +- packages/ui/src/components/Sidebar.svelte | 13 +- .../ui/src/components/ThinkingPart.svelte | 48 ++-- packages/ui/src/components/ThreadCard.svelte | 67 ++---- .../ui/src/components/TraceSurface.svelte | 17 +- packages/ui/src/components/TraceTable.svelte | 224 ++++++------------ packages/ui/src/components/TurnSummary.svelte | 45 ++++ packages/ui/src/lib/fake-client.ts | 6 +- packages/ui/src/lib/message-display.test.ts | 29 +++ packages/ui/src/lib/message-display.ts | 47 ++++ packages/ui/src/lib/strings.ts | 15 ++ tests/e2e/composer-activity.test.ts | 2 + tests/e2e/machines.test.ts | 2 +- tests/e2e/readability.test.ts | 94 ++++++++ 25 files changed, 487 insertions(+), 404 deletions(-) create mode 100644 packages/ui/src/components/TurnSummary.svelte create mode 100644 packages/ui/src/lib/message-display.test.ts create mode 100644 packages/ui/src/lib/message-display.ts create mode 100644 tests/e2e/readability.test.ts diff --git a/docs/development.md b/docs/development.md index 1e7ead2..3314312 100644 --- a/docs/development.md +++ b/docs/development.md @@ -303,3 +303,16 @@ original checkout: outside the worktree, outside the branch, outside the commit, and invisible to the review that follows. The tracked docs, this page included, are the ones a worktree can actually change. Check where a file you are about to edit really lives before editing it. + + +## Chat readability + +`bun test tests/e2e/readability.test.ts` checks the sidebar metadata, process +panel, paragraph buffering, reasoning replacement, goal display and command +highlighting through the fake client. It writes desktop, phone and light-theme +captures under `tests/e2e/.artifacts/`. + +Scheduled goal and loop prompts keep their execution instructions in `text` and +carry a separate optional `displayText` on the text part. The journal retains +both; drivers read the execution prompt. The UI shows the command and objective, +including when recalling a sent prompt, and hides standalone goal control markers. diff --git a/docs/machines.md b/docs/machines.md index b65b260..8dd4ba6 100644 --- a/docs/machines.md +++ b/docs/machines.md @@ -56,15 +56,17 @@ All machines can be narrowed to one machine from the filter button beside Settin matches thread titles, project names and machine names. The command palette and the draft's project picker also include every connected host. -Cards have a title row and a second row for the PR, project and machine. PR +Cards have a title row and a second row for the project and machine icon. The +machine name remains in the tooltip and accessible label. An associated PR +appears as a green underlined number immediately before the machine icon; no +placeholder appears when there is no PR. PR metadata comes from the execution machine's `gh pr list`, using the worktree branch or the current branch of the working directory. Non-repositories and detached checkouts have no PR. The core caches results and errors for one minute, coalesces duplicate requests, and runs at most two lookups at once. Each command has a ten-second deadline and runs through the process registry under `pull-request:`. The thread menu can request a refresh. Missing `gh`, -authentication failures and malformed responses appear as unavailable metadata, -not as a claim that no PR exists. A PR link opens in the system browser. +authentication failures and malformed responses use the error notification. A PR link opens in the system browser. ## Isolation and tests diff --git a/docs/trace.md b/docs/trace.md index 2c4185a..27b4ab7 100644 --- a/docs/trace.md +++ b/docs/trace.md @@ -43,11 +43,13 @@ totals. many processes, what percentage of CPU, how many bytes of memory. It is what the gauge in the trace panel reads. -The trace panel shows six columns at a fixed width, executable, duration, CPU, -peak memory, I/O and exit, the four measurements right-aligned on tabular -figures. It shows the base name of the executable, with the full path, the pid -and the command line in the cell's tooltip. The Protection page under Settings -keeps a wider table with a pid column of its own. +The trace panel lists active processes first, then the most recently started. +Each expandable row shows the executable name, running or exit status, duration +and peak memory. Expanding it reveals the command, full path, PID, parent PID, +CPU time and I/O. Unmeasured values remain unknown. The top summary separates +recorded processes from active ones and labels current CPU and memory usage. +The tracking capability disclosure explains the limits of polling hosts. The +Protection page under Settings keeps its wider resource table. ## Caps diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 253754e..7766534 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -436,7 +436,7 @@ export interface ImageAttachment { } export type MessagePart = - | { type: 'text'; text: string } + | { type: 'text'; text: string; /** User-facing command for internally scheduled prompts. */ displayText?: string } /** An image the user sent with the prompt, journalled with the message. */ | { type: 'image'; mimeType: ImageMimeType; data: string; alt: string | null } /** The model's reasoning as the provider streams it, folded in the UI. */ diff --git a/packages/core/src/activity.ts b/packages/core/src/activity.ts index 7361559..ab4ffab 100644 --- a/packages/core/src/activity.ts +++ b/packages/core/src/activity.ts @@ -153,7 +153,7 @@ export class ActivityStore { ? `Work toward this goal: ${state.goal!.objective}\nContinue until the objective is achieved. When you have verified completion, write [BOITE_GOAL_COMPLETE] alone on its own line. If blocked or waiting for user input, explain what is missing and write [BOITE_GOAL_BLOCKED] alone on its own line.` : state.loop!.prompt; try { - const turn = this.core.threads.startTurn(threadId, prompt); + const turn = this.core.threads.startTurn(threadId, prompt, [], undefined, undefined, kind === 'goal' ? `/goal ${state.goal!.objective}` : `/loop ${state.loop!.intervalMs / 1000}s ${state.loop!.prompt}`); this.ownTurns.set(turn.id, { kind, generation: this.generations.get(threadId) ?? 0 }); // Drivers may synchronously report tasks while startTurn runs. const current = this.states.get(threadId)!; diff --git a/packages/core/src/threads.ts b/packages/core/src/threads.ts index 34a898f..2e1f28a 100644 --- a/packages/core/src/threads.ts +++ b/packages/core/src/threads.ts @@ -570,7 +570,7 @@ export class ThreadStore { return this.startTurn(threadId, protocol === 'echo' ? '[compact]' : '/compact', [], expectedSelectionVersion, 'compact'); } - startTurn(threadId: ThreadId, prompt: string, attachments: ImageAttachment[] = [], expectedSelectionVersion?: number, operation?: 'compact'): Turn { + startTurn(threadId: ThreadId, prompt: string, attachments: ImageAttachment[] = [], expectedSelectionVersion?: number, operation?: 'compact', displayPrompt?: string): Turn { const thread = this.require(threadId); this.checkSelection(thread, expectedSelectionVersion); if (thread.archived) throw refused('cannot start a turn on an archived thread', { threadId }); @@ -610,7 +610,7 @@ export class ThreadStore { turnId: turn.id, role: 'user', parts: [ - { type: 'text', text: prompt }, + { type: 'text', text: prompt, ...(displayPrompt ? { displayText: displayPrompt } : {}) }, ...attachments.map((attachment): MessagePart => ({ type: 'image', mimeType: attachment.mimeType, diff --git a/packages/core/test/activity.test.ts b/packages/core/test/activity.test.ts index 59f4d04..4b7e21d 100644 --- a/packages/core/test/activity.test.ts +++ b/packages/core/test/activity.test.ts @@ -142,3 +142,19 @@ test('replacing an in-flight goal cannot complete the replacement with the old a expect(h.core.activity.get(threadId).goal?.objective).toBe('second'); await client.call('turns.stop', { threadId }); }); + + +test('goal messages expose a display command while the driver receives its instructions', async () => { + const client = await h.connect(); + const { threadId } = await echoThread(h, client); + let received = ''; + restore = setDriver('echo', { protocol: 'echo', startTurn(ctx) { + received = ctx.prompt; + return { stop() {}, done: Promise.resolve({ status: 'error', error: 'test stop', sessionId: null, usage: null }) }; + } }); + await client.call('threads.activity.set', { threadId, goal: { objective: 'Check two tasks' } }); + await waitFor(() => received.length > 0); + const thread = await client.call('threads.get', { threadId }); + expect(received).toContain('[BOITE_GOAL_COMPLETE]'); + expect(thread.messages.find(m => m.role === 'user')?.parts[0]).toMatchObject({ text: received, displayText: '/goal Check two tasks' }); +}); diff --git a/packages/ui/src/app.test.ts b/packages/ui/src/app.test.ts index b7ebe91..2a1376c 100644 --- a/packages/ui/src/app.test.ts +++ b/packages/ui/src/app.test.ts @@ -610,7 +610,7 @@ test('the trace panel shows the I/O a process moved, and none for a record that if (!store.panelOpen) query('[data-testid=tab-trace]').click(); await waitFor(() => document.querySelectorAll('[data-testid=trace-row]').length === 3); - expect(query('[data-testid=trace-panel] thead').textContent).toContain('I/O'); + expect(query('[data-testid=trace-panel]').textContent).toContain('I/O'); const cellOf = (pid: number): string => query(`[data-testid=trace-row][data-pid="${pid}"] [data-testid=trace-io]`).textContent?.trim() ?? ''; // 1_240_000 bytes through the same `bytes()` the memory column uses. @@ -621,35 +621,21 @@ test('the trace panel shows the I/O a process moved, and none for a record that expect(query('[data-testid=trace-row][data-pid="21460"]').textContent).toContain('none'); }); -test('the trace table fits the panel: base names, no pid column, nothing scrolling sideways', async () => { +test('trace processes disclose the command, PID and measurements without narrow columns', async () => { await mountOnFake(); await waitFor(() => document.querySelector('[data-thread-id="t-trace"]') !== null); query('[data-thread-id="t-trace"]').click(); await waitFor(() => store.openThread?.id === 't-trace'); - if (!store.panelOpen) query('[data-testid=tab-trace]').click(); await waitFor(() => document.querySelectorAll('[data-testid=trace-row]').length === 3); - - // The pid is not a column any more, only an attribute and a line of the tooltip. - const headers = Array.from(query('[data-testid=trace-panel] thead').querySelectorAll('th')).map( - (th) => th.textContent?.trim() ?? '' - ); - expect(headers).toEqual(['Executable', 'Duration', 'CPU', 'Peak memory', 'I/O', 'Exit']); - - const exe = query('[data-testid=trace-row][data-pid="21140"] td.exe'); - expect(exe.textContent?.trim()).toBe('claude.exe'); - expect(exe.getAttribute('data-exe')).toBe('C:\\tools\\claude\\claude.exe'); - expect(exe.getAttribute('title')).toContain('C:\\tools\\claude\\claude.exe'); - expect(exe.getAttribute('title')).toContain('pid 21140'); - - // Every measurement is right-aligned on tabular figures. - const row = query('[data-testid=trace-row][data-pid="21402"]'); - expect(row.querySelectorAll('td.num').length).toBe(4); - - // jsdom lays nothing out, so both are 0 here: the real widths are in - // scratchpad/trace-width-capture.ts and its capture. - const table = query('[data-testid=trace-table]'); - expect(table.scrollWidth).toBe(table.clientWidth); + const row = query('[data-testid=trace-row][data-pid="21140"]'); + expect(row.open).toBe(false); + expect(row.querySelector('.exe')?.textContent).toBe('claude.exe'); + row.querySelector('summary')!.click(); + expect(row.open).toBe(true); + expect(row.textContent).toContain('21140'); + expect(row.textContent).toContain('CPU time'); + expect(row.querySelector('.command')?.textContent).toContain('claude'); }); const SEEDED_THINKING = 'The table wants a row per process'; diff --git a/packages/ui/src/components/Composer.svelte b/packages/ui/src/components/Composer.svelte index da667cb..cd1556d 100644 --- a/packages/ui/src/components/Composer.svelte +++ b/packages/ui/src/components/Composer.svelte @@ -6,6 +6,7 @@ import { AGENT_PREFIX, appCommands, isAgentCommand, runCommand } from '../lib/commands.svelte'; import { rankItems, type PaletteItem } from '../lib/palette'; import { clearStash, DRAFT_STASH_KEY, readStash, writeStash } from '../lib/prefs'; + import { promptText } from '../lib/message-display'; import { fill, strings } from '../lib/strings'; import type { Choice, PickPatch, Store } from '../lib/store.svelte'; import EffortSlider from './EffortSlider.svelte'; @@ -114,7 +115,7 @@ .map((message) => message.parts .filter((part) => part.type === 'text') - .map((part) => (part.type === 'text' ? part.text : '')) + .map((part) => (part.type === 'text' ? promptText(part) : '')) .join('\n') .trim() ) @@ -262,6 +263,11 @@ })) ]); + let commandToken = $derived(/^\/[^\s]+/.exec(text)?.[0] ?? ''); + let recognized = $derived([...agentItems, ...boiteItems].some(item => item.label === commandToken)); + let inputScroll = $state(0); + let inputWidth = $state(0); + /** Agent commands first, so a tie goes to the agent's own. */ let slashItems = $derived(rankItems(slashQuery ?? '', [...agentItems, ...boiteItems])); @@ -732,7 +738,14 @@ {/if} +
+ {#if recognized} + + {/if} +
-
{@html shown}
+
+ {#each blocks as block, index (index)} +
{@html renderMarkdown(block)}
+ {/each} +
diff --git a/packages/ui/src/components/Prose.test.ts b/packages/ui/src/components/Prose.test.ts index 4a05d29..8efc943 100644 --- a/packages/ui/src/components/Prose.test.ts +++ b/packages/ui/src/components/Prose.test.ts @@ -54,12 +54,13 @@ test('a block that is still being written carries no button yet', async () => { props: { text: 'Run it:\n\n```sh\nbun run --cwd packages/ui te', live: true } }); flushSync(); - // Past the 48 ms the markdown itself is gated on, so the block is really there. + // An unfinished fenced block stays buffered while the complete paragraph is visible. await new Promise((resolve) => setTimeout(resolve, 120)); flushSync(); await tick(); - expect(document.querySelector('pre')).not.toBeNull(); + expect(document.querySelector('pre')).toBeNull(); + expect(query('[data-testid=text-part]').textContent).toContain('Run it:'); expect(document.querySelector('[data-testid=code-copy]')).toBeNull(); }); diff --git a/packages/ui/src/components/Sidebar.svelte b/packages/ui/src/components/Sidebar.svelte index 9444bdc..85c61b1 100644 --- a/packages/ui/src/components/Sidebar.svelte +++ b/packages/ui/src/components/Sidebar.svelte @@ -6,7 +6,6 @@ import { confirm } from '../lib/confirm.svelte'; import { contextMenu } from '../lib/context-menu.svelte'; import { experimentOn } from '../lib/experiments.svelte'; - import { tokens } from '../lib/format'; import { separator } from '../lib/menu'; import { clampSidebar, SIDEBAR_DEFAULT } from '../lib/prefs'; import { fill, strings } from '../lib/strings'; @@ -37,7 +36,6 @@ (b.thread.lastUserMessageAt ?? b.thread.createdAt) - (a.thread.lastUserMessageAt ?? a.thread.createdAt) ) ); - let usageToday = $derived(store.usage ? store.usage.total.inputTokens + store.usage.total.outputTokens : 0); let target = $derived(store.openProject ?? store.projects[0]); let newLabel = $derived( target ? fill(strings.sidebar.newThreadIn, { project: target.name }) : strings.sidebar.newThread @@ -228,12 +226,6 @@ {/if}
(filter = id)} /> - {#if usageToday > 0}{/if} - {#if pullRequest}#{pullRequest.number}{/if} +
+ {#if load} +
{strings.trace.cpu} {percent(load.cpuPercent)}{strings.trace.currentMemory} {bytes(load.memoryBytes)}
+ {/if}
@@ -62,11 +61,9 @@ flex: 1; } - .live { - height: 20px; - font-size: var(--text-xs); - font-variant-numeric: tabular-nums; - } + .totals, .load { font-size: var(--text-xs); color: var(--color-muted-foreground); } + .load { display: flex; flex-wrap: wrap; gap: 14px; padding: 0 12px 8px; } + .load b { color: var(--color-foreground); font-weight: 500; margin-left: 4px; } .scroll { flex: 1; diff --git a/packages/ui/src/components/TraceTable.svelte b/packages/ui/src/components/TraceTable.svelte index 0e00461..ecbb59c 100644 --- a/packages/ui/src/components/TraceTable.svelte +++ b/packages/ui/src/components/TraceTable.svelte @@ -1,173 +1,83 @@
{#if capability} -

- {capability.os} / {capability.mode} - {capability.note} -

+
+ {capability.mode === 'events' ? strings.trace.exact : strings.trace.limited} +

{capability.os} / {capability.mode}: {capability.note}

+
{/if} - {#if records.length === 0}

{strings.trace.empty}

{:else} - - - - - - - - - - - - - - - - - - - - - {#each records as record (`${record.pid}-${record.startedAt}`)} - - - - - - - - - {/each} - -
{strings.trace.exe}{strings.trace.duration}{strings.trace.cpu}{strings.trace.memory}{strings.trace.io}{strings.trace.exit}
{name(record.exe)}{duration(record.startedAt, record.exitedAt)}{millis(record.cpuMs)}{bytes(record.peakMemoryBytes)}{bytes(record.ioBytes)} - {#if record.exitedAt === null} - {strings.trace.live} - {:else} - {record.exitCode ?? strings.common.unknown} - {/if} -
+
+ {#each ordered as record (`${record.pid}-${record.startedAt}`)} +
+ +
+ + {name(record.exe)} + + {record.exitedAt === null ? strings.trace.live : record.exitCode === 0 ? strings.trace.finished : record.exitCode === null ? strings.common.unknown : `${strings.trace.exit} ${record.exitCode}`} + +
+
+ {strings.trace.duration} {millis((record.exitedAt ?? now) - record.startedAt)} + {strings.trace.memory} {bytes(record.peakMemoryBytes)} +
+
+
+
+
{strings.trace.pid}
{record.pid}
+
{strings.trace.parent}
{record.parentPid ?? strings.common.unknown}
+
{strings.trace.cpuTime}
{millis(record.cpuMs)}
+
{strings.trace.io}
{bytes(record.ioBytes)}
+
+

{record.commandLine ?? record.exe}

+ {#if record.commandLine}

{record.exe}

{/if} +
+
+ {/each} +
{/if}
diff --git a/packages/ui/src/components/TurnSummary.svelte b/packages/ui/src/components/TurnSummary.svelte new file mode 100644 index 0000000..bccf770 --- /dev/null +++ b/packages/ui/src/components/TurnSummary.svelte @@ -0,0 +1,45 @@ + + +
+ + {#if turn.status === 'done'}{:else if turn.status === 'error'}{:else if turn.status === 'stopped'}{:else if running}{:else}{/if} + {label} + + {#if turn.startedAt !== null}{millis(Math.max(0, (turn.finishedAt ?? now) - turn.startedAt))}{/if} + {#if tools.length}{tools.length === 1 ? strings.chat.toolCall : fill(strings.chat.toolCount, { count: String(tools.length) })}{/if} + {#if turn.usage} + {fill(strings.chat.inputTokens, { count: tokens(turn.usage.inputTokens) })} + {fill(strings.chat.outputTokens, { count: tokens(turn.usage.outputTokens) })} + {#if turn.usage.costUsdEquivalent !== null}{cost(turn.usage.costUsdEquivalent)}{/if} + {/if} +
+ + diff --git a/packages/ui/src/lib/fake-client.ts b/packages/ui/src/lib/fake-client.ts index 5519418..78ee47f 100644 --- a/packages/ui/src/lib/fake-client.ts +++ b/packages/ui/src/lib/fake-client.ts @@ -1278,7 +1278,7 @@ export class FakeClient implements ObservableClient { return stopped; } - #startTurn(threadId: ThreadId, prompt: string, attachments: ImageAttachment[] = [], operation?: 'compact'): Turn { + #startTurn(threadId: ThreadId, prompt: string, attachments: ImageAttachment[] = [], operation?: 'compact', displayPrompt?: string): Turn { const thread = this.#thread(threadId); if (thread.archived) { throw new RpcFailure({ code: RpcErrorCode.Refused, message: 'cannot start a turn on an archived thread', data: { threadId } }); @@ -1322,7 +1322,7 @@ export class FakeClient implements ObservableClient { role: 'user', // The images ride after the text, the order the core journals them in. parts: [ - { type: 'text', text: prompt }, + { type: 'text', text: prompt, ...(displayPrompt ? { displayText: displayPrompt } : {}) }, ...attachments.map((attachment): MessagePart => ({ type: 'image', mimeType: attachment.mimeType, @@ -1921,7 +1921,7 @@ export class FakeClient implements ObservableClient { return; } try { - const turn = this.#startTurn(threadId, kind === 'goal' ? activity.goal!.objective : activity.loop!.prompt); + const turn = this.#startTurn(threadId, kind === 'goal' ? activity.goal!.objective : activity.loop!.prompt, [], undefined, kind === 'goal' ? `/goal ${activity.goal!.objective}` : `/loop ${activity.loop!.intervalMs / 1000}s ${activity.loop!.prompt}`); this.#activityTurns.set(turn.id, { kind, goal: activity.goal }); activity[kind]!.iterations++; if (kind === 'loop') activity.loop!.nextRunAt = Date.now() + activity.loop!.intervalMs; diff --git a/packages/ui/src/lib/message-display.test.ts b/packages/ui/src/lib/message-display.test.ts new file mode 100644 index 0000000..1c6c0b7 --- /dev/null +++ b/packages/ui/src/lib/message-display.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from 'vitest'; +import { paragraphBlocks, answerText, promptText, currentThought } from './message-display'; + +test('paragraphs wait for a boundary, and completion or cancellation flushes the tail', () => { + expect(paragraphBlocks('First sentence', true)).toEqual([]); + expect(paragraphBlocks('First sentence\n\nSecond par', true)).toEqual(['First sentence']); + expect(paragraphBlocks('First sentence\n\nSecond par', false)).toEqual(['First sentence', 'Second par']); + expect(paragraphBlocks('One\r\n\r\nTwo', true)).toEqual(['One']); +}); +test('blank lines inside fenced code do not expose an incomplete block', () => { + expect(paragraphBlocks('Intro\n\n```ts\nconst a = 1;\n\npartial', true)).toEqual(['Intro']); + expect(paragraphBlocks('```ts\nconst a = 1;\n\n```\n\nTail', true)).toEqual(['```ts\nconst a = 1;\n\n```']); +}); +test('internal goal markers stay out of prose, even during partial arrival', () => { + expect(answerText('Done\n\n[BOITE_GOAL_COMPLETE]', false)).toBe('Done\n\n'); + expect(answerText('Done\n\n[BOITE_GOAL_COM', true)).not.toContain('[BOITE_'); + expect(answerText('A literal [BOITE_GOAL_COMPLETE] in a sentence.', false)).toContain('[BOITE_GOAL_COMPLETE]'); +}); +test('display commands leave the execution prompt intact', () => { + const part = {type: 'text' as const, text: 'private instructions', displayText: '/goal Check it'}; + expect(promptText(part)).toBe('/goal Check it'); + expect(part.text).toBe('private instructions'); + expect(promptText({type:'text',text:'Ordinary prompt'})).toBe('Ordinary prompt'); +}); + +test('a new thought replaces previous bold headings even within one protocol part', () => { + expect(currentThought('**First thought**old**Next thought**new')).toEqual({title:'Next thought',text:'**Next thought**new'}); + expect(currentThought('plain reasoning')).toEqual({title:null,text:'plain reasoning'}); +}); diff --git a/packages/ui/src/lib/message-display.ts b/packages/ui/src/lib/message-display.ts new file mode 100644 index 0000000..dbad136 --- /dev/null +++ b/packages/ui/src/lib/message-display.ts @@ -0,0 +1,47 @@ +import type { MessagePart } from '@boite/contracts'; + +const GOAL_SUFFIX = '\nContinue until the objective is achieved. When you have verified completion, write [BOITE_GOAL_COMPLETE] alone on its own line. If blocked or waiting for user input, explain what is missing and write [BOITE_GOAL_BLOCKED] alone on its own line.'; + +export function promptText(part: Extract): string { + if (part.displayText !== undefined) return part.displayText; + if (part.text.startsWith('Work toward this goal: ') && part.text.endsWith(GOAL_SUFFIX)) { + return '/goal ' + part.text.slice('Work toward this goal: '.length, -GOAL_SUFFIX.length); + } + return part.text; +} + +export function answerText(text: string, live: boolean): string { + const clean = text.replace(/^[ \t]*\[BOITE_GOAL_(?:COMPLETE|BLOCKED)\][ \t]*(?:\r?\n|$)/gm, ''); + return live ? clean.replace(/(?:^|\n)[ \t]*\[BOITE_[^\n]*$/, '') : clean; +} + +/** Only complete paragraphs and fenced blocks enter the timeline while streaming. */ +export function paragraphBlocks(text: string, live: boolean): string[] { + const blocks: string[] = []; + let start = 0; + let offset = 0; + let fence = ''; + for (const line of text.split(/(?<=\n)/)) { + const trimmed = line.trim(); + const marker = /^(?:`{3,}|~{3,})/.exec(trimmed)?.[0]; + if (marker) { + if (!fence) fence = marker; + else if (marker[0] === fence[0] && marker.length >= fence.length && trimmed === marker) fence = ''; + } + offset += line.length; + if (!fence && !trimmed && line.endsWith('\n')) { + const block = text.slice(start, offset).trim(); + if (block) blocks.push(block); + start = offset; + } + } + if (!live && text.slice(start).trim()) blocks.push(text.slice(start).trim()); + return blocks; +} + +/** Codex can append several bold thought headings inside the same part. */ +export function currentThought(text: string): { title: string | null; text: string } { + const headings = [...text.matchAll(/\*\*([^*\n]+)\*\*/g)]; + const last = headings.at(-1); + return { title: last?.[1]?.trim() ?? null, text: last ? text.slice(last.index) : text }; +} diff --git a/packages/ui/src/lib/strings.ts b/packages/ui/src/lib/strings.ts index 37e243d..4db5c98 100644 --- a/packages/ui/src/lib/strings.ts +++ b/packages/ui/src/lib/strings.ts @@ -248,6 +248,13 @@ export const strings = { }, chat: { + working: 'Working', + writing: 'Writing', + stopped: 'Stopped', + toolCall: '1 tool call', + toolCount: '{count} tool calls', + inputTokens: '{count} input tokens', + outputTokens: '{count} output tokens', you: 'You', assistant: 'Agent', system: 'System', @@ -393,6 +400,14 @@ export const strings = { }, trace: { + exact: 'All child processes tracked', + limited: 'Limited process tracking', + finished: 'Finished', + parent: 'Parent PID', + cpuTime: 'CPU time', + currentMemory: 'Memory now', + active: 'active', + recorded: 'recorded', exe: 'Executable', pid: 'PID', started: 'Started', diff --git a/tests/e2e/composer-activity.test.ts b/tests/e2e/composer-activity.test.ts index a14a44c..2cc6f05 100644 --- a/tests/e2e/composer-activity.test.ts +++ b/tests/e2e/composer-activity.test.ts @@ -90,6 +90,8 @@ test('default models can be changed in General on desktop and phone', async () = test('goal and loop commands share the activity panel with disclosed tasks and reversible controls', async () => { await command('/goal Review the queued prompts and verify every interaction'); await page.waitFor(`document.querySelector('${id('activity-goal')}')`); + await page.waitFor(`document.querySelector('.user-text .command')?.textContent === '/goal'`); + expect(await page.evaluate(`document.querySelector('${id('timeline')}').textContent.includes('Continue until the objective')`)).toBe(false); await page.click(`${id('activity-goal')} [aria-label="Pause"]`); await page.waitFor(`document.querySelector('${id('activity-goal')}').textContent.includes('Paused')`); await page.waitFor(`!document.querySelector('${id('composer-stop')}')`); diff --git a/tests/e2e/machines.test.ts b/tests/e2e/machines.test.ts index 1f850a1..6023e0e 100644 --- a/tests/e2e/machines.test.ts +++ b/tests/e2e/machines.test.ts @@ -85,7 +85,7 @@ test('project and recent cards show both hosts, PRs and user-message ordering on await capture('machine-customization.png'); await page.evaluate('location.reload()'); await page.waitFor(`document.querySelectorAll('${id('thread-row')}').length === 8`); - expect(await page.evaluate(`document.querySelector('[data-testid="sidebar"]').textContent.includes('Build server')`)).toBe(true); + expect(await page.evaluate(`!!document.querySelector('[data-testid="sidebar"] .machine[aria-label="Build server"]')`)).toBe(true); expect(await page.evaluate(`document.querySelector('[data-testid="nav-machines"]') === null`)).toBe(true); await page.click(id('nav-settings')); await page.click(id('settings-tab-machines')); diff --git a/tests/e2e/readability.test.ts b/tests/e2e/readability.test.ts new file mode 100644 index 0000000..44f0719 --- /dev/null +++ b/tests/e2e/readability.test.ts @@ -0,0 +1,94 @@ +import { afterAll, beforeAll, expect, test } from 'bun:test'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { BrowserPage, freePort } from './lib/cdp'; +const req = createRequire(join(import.meta.dir, '../../packages/ui/package.json')); +const { createServer } = await import(req.resolve('vite')); +let server: { close(): Promise }; +let page: BrowserPage; +const id = (name: string) => `[data-testid="${name}"]`; +async function capture(name: string) { + await page.evaluate('Promise.all([document.fonts.ready, ...document.getAnimations().filter(a => a.effect?.getTiming().iterations !== Infinity).map(a => a.finished.catch(() => {}))])'); + await page.screenshot(join(import.meta.dir,'.artifacts',`${name}.png`)); +} +async function update(code: string) { + await page.evaluate(`(async () => { const { workspace } = await import('/src/lib/workspace.svelte.ts'); const store = workspace.active; const thread = store.openThread; ${code} })()`); +} +beforeAll(async () => { + const port = await freePort(); + const vite = await createServer({root:join(import.meta.dir,'../../packages/ui'),server:{host:'127.0.0.1',port,strictPort:true}}); + server = vite; await vite.listen(); + page = await BrowserPage.launch({url:`http://127.0.0.1:${port}/?fake=1&machines=1`,windowSize:{width:1300,height:850}}); + await page.waitFor(`document.querySelector('[data-thread-id="t-trace"]')`); + await page.click('[data-thread-id="t-trace"]'); +}, 30000); +afterAll(async () => { await page?.close(); await server?.close(); }); + +test('thread metadata, message identity and expandable trace fit a narrow panel', async () => { + await page.waitFor(`document.querySelectorAll('${id('thread-pr')}').length === 2`); + expect(await page.evaluate(`document.querySelector('${id('usage-pill')}') === null`)).toBe(true); + expect(await page.evaluate(`Array.from(document.querySelectorAll('.metadata')).every(e => !e.textContent.includes('No PR') && !e.textContent.includes('My computer') && !e.textContent.includes('Builder'))`)).toBe(true); + expect(await page.evaluate(`Array.from(document.querySelectorAll('${id('thread-pr')}')).every(e => e.nextElementSibling?.classList.contains('machine') && getComputedStyle(e).textDecorationLine.includes('underline'))`)).toBe(true); + await page.evaluate(`window.__openedPr = null; window.open = (url) => { window.__openedPr = url; return null; }`); + const prUrl = await page.evaluate(`document.querySelector('${id('thread-pr')}').href`); + await page.click(id('thread-pr')); + expect(await page.evaluate(`window.__openedPr`)).toBe(prUrl); + await page.click(id('tab-trace')); + await page.waitFor(`document.querySelectorAll('${id('trace-row')}').length === 3`); + await page.click(`${id('trace-row')}[data-pid="21140"] summary`); + await capture('readability-desktop'); + expect(await page.evaluate(`document.querySelector('${id('trace-row')}[data-pid="21140"]').open`)).toBe(true); + expect(await page.evaluate(`document.querySelector('${id('trace-panel')}').scrollWidth <= document.querySelector('${id('trace-panel')}').clientWidth`)).toBe(true); + expect(await page.evaluate(`document.querySelector('${id('turn-summary')}').textContent`)).toContain('Done'); + await page.send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true}); + await capture('readability-trace-phone'); + expect(await page.evaluate('document.documentElement.scrollWidth <= innerWidth')).toBe(true); + await page.send('Emulation.setDeviceMetricsOverride',{width:1300,height:850,deviceScaleFactor:1,mobile:false}); + await page.click(id('tab-trace')); +}); + +test('paragraphs arrive whole, keep previous nodes and flush when stopped; reasoning replaces itself', async () => { + await update(`const turn = thread.turns[0]; turn.status = 'running'; turn.usage = null; turn.finishedAt = null; turn.startedAt = Date.now(); thread.status = 'running'; const m = thread.messages.at(-1); m.state = 'streaming'; m.parts = [{type:'thinking',text:'**Inspecting files**'}, {type:'text',text:'First complete paragraph.\\n\\nAn unfinished'}];`); + await page.waitFor(`document.querySelector('${id('paragraph')}')?.textContent.includes('First complete paragraph.')`); + expect(await page.evaluate(`document.querySelector('${id('timeline')}').textContent.includes('An unfinished')`)).toBe(false); + await page.evaluate(`window.__firstParagraph = document.querySelector('${id('paragraph')}')`); + await update(`thread.messages.at(-1).parts[1].text += ' paragraph.\\n\\n'; thread.messages.at(-1).parts.push({type:'thinking',text:'**Checking results**'});`); + await page.waitFor(`document.querySelectorAll('${id('paragraph')}').length === 2`); + expect(await page.evaluate(`document.querySelector('${id('paragraph')}') === window.__firstParagraph`)).toBe(true); + expect(await page.evaluate(`document.querySelectorAll('${id('thinking-part')}').length`)).toBe(1); + expect(await page.evaluate(`document.querySelector('${id('thinking-toggle')}').textContent`)).toContain('Checking results'); + await capture('readability-working'); + await update(`thread.messages.at(-1).parts.push({type:'text',text:'Last partial paragraph'});`); + await page.waitFor(`document.querySelector('${id('turn-summary')}').textContent.includes('Writing')`); + expect(await page.evaluate(`document.querySelector('${id('timeline')}').textContent.includes('Last partial paragraph')`)).toBe(false); + await update(`thread.turns[0].status = 'stopped'; thread.turns[0].finishedAt = Date.now(); thread.messages.at(-1).state = 'complete'; thread.status = 'idle';`); + await page.waitFor(`document.querySelector('${id('timeline')}').textContent.includes('Last partial paragraph')`); + expect(await page.evaluate(`document.querySelector('${id('turn-summary')}').textContent`)).toContain('Stopped'); +}); + +test('goal prompts and markers stay readable and recognized commands are accented while typing', async () => { + await update(`thread.messages[0].parts = [{type:'text',text:'Internal instructions for the agent',displayText:'/goal Verify two tasks'}]; thread.messages.at(-1).parts = [{type:'text',text:'Two tasks verified.\\n\\n[BOITE_GOAL_COMPLETE]'}]; thread.turns[0].status = 'done';`); + await page.waitFor(`document.querySelector('.user-text .command')?.textContent === '/goal'`); + expect(await page.evaluate(`document.querySelector('${id('timeline')}').textContent.includes('Internal instructions')`)).toBe(false); + expect(await page.evaluate(`document.querySelector('${id('timeline')}').textContent.includes('[BOITE_GOAL_COMPLETE]')`)).toBe(false); + for (const command of ['/goal', '/loop', '/model', '/effort']) { + await page.evaluate(`(() => { const input = document.querySelector('${id('composer-input')}'); input.value = '${command} sample'; input.dispatchEvent(new Event('input',{bubbles:true})); })()`); + await page.waitFor(`document.querySelector('${id('command-highlight')}')?.textContent === '${command}'`); + } + await page.evaluate(`(() => { const input = document.querySelector('${id('composer-input')}'); input.value = '/unknown sample'; input.dispatchEvent(new Event('input',{bubbles:true})); })()`); + await page.waitFor(`!document.querySelector('${id('command-highlight')}')`); + await page.evaluate(`(() => { const input = document.querySelector('${id('composer-input')}'); input.value = '/goal Verify another task'; input.dispatchEvent(new Event('input',{bubbles:true})); })()`); + await page.evaluate(`(() => { const input = document.querySelector('${id('composer-input')}'); input.value = '/goal ' + 'Check a long prompt that wraps over several lines. '.repeat(40); input.dispatchEvent(new Event('input',{bubbles:true})); })()`); + await page.waitFor(`document.querySelector('${id('composer-input')}').scrollHeight > document.querySelector('${id('composer-input')}').clientHeight`); + await page.evaluate(`(() => { const input = document.querySelector('${id('composer-input')}'); input.scrollTop = input.scrollHeight; input.dispatchEvent(new Event('scroll')); })()`); + await page.waitFor(`document.querySelector('.input-mirror').style.transform === 'translateY(-' + document.querySelector('${id('composer-input')}').scrollTop + 'px)'`); + expect(await page.evaluate(`Math.abs(document.querySelector('.input-mirror').getBoundingClientRect().width - document.querySelector('${id('composer-input')}').clientWidth) < 1`)).toBe(true); + await capture('readability-composer-long'); + await page.evaluate(`(() => { const input = document.querySelector('${id('composer-input')}'); input.value = '/goal Verify another task'; input.dispatchEvent(new Event('input',{bubbles:true})); })()`); + await capture('readability-goal'); + await page.send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true}); + await capture('readability-chat-phone'); + await page.evaluate(`document.documentElement.dataset.theme = 'light'`); + await capture('readability-chat-light'); + expect(await page.evaluate('document.documentElement.scrollWidth <= innerWidth')).toBe(true); +}); From 8ef861025db6a3dd01d9b44db40f6a225e1b1be7 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:43:16 +0200 Subject: [PATCH 2/7] fix(ui): handle older cores and restyle error notifications --- docs/machines.md | 5 ++ packages/ui/src/App.svelte | 30 +++------- .../ui/src/components/NotificationCard.svelte | 55 ++++++++++++++++++ packages/ui/src/components/ThreadCard.svelte | 9 ++- packages/ui/src/lib/pull-request.test.ts | 37 ++++++++++++ packages/ui/src/lib/pull-request.ts | 32 ++++++++++ packages/ui/src/lib/store.svelte.ts | 2 + packages/ui/src/lib/strings.ts | 1 + tests/e2e/notifications.test.ts | 58 +++++++++++++++++++ 9 files changed, 203 insertions(+), 26 deletions(-) create mode 100644 packages/ui/src/components/NotificationCard.svelte create mode 100644 packages/ui/src/lib/pull-request.test.ts create mode 100644 packages/ui/src/lib/pull-request.ts create mode 100644 tests/e2e/notifications.test.ts diff --git a/docs/machines.md b/docs/machines.md index 8dd4ba6..763de5c 100644 --- a/docs/machines.md +++ b/docs/machines.md @@ -68,6 +68,11 @@ has a ten-second deadline and runs through the process registry under `pull-request:`. The thread menu can request a refresh. Missing `gh`, authentication failures and malformed responses use the error notification. A PR link opens in the system browser. +Older cores that do not implement PR lookup are probed once per connection. +Their cards omit the PR link. A manual refresh explains that the hosting +machine needs an update; other RPC errors still appear in a notification. +Reconnecting clears the capability check so an updated core is detected. + ## Isolation and tests Each connection has its own Store. IDs remain native to that core and are never diff --git a/packages/ui/src/App.svelte b/packages/ui/src/App.svelte index ee874f3..64fcfc5 100644 --- a/packages/ui/src/App.svelte +++ b/packages/ui/src/App.svelte @@ -1,5 +1,6 @@ + +
+ +
+ {title} +

{message}

+
+ +
+ + diff --git a/packages/ui/src/components/ThreadCard.svelte b/packages/ui/src/components/ThreadCard.svelte index 6d2ccf0..41c913b 100644 --- a/packages/ui/src/components/ThreadCard.svelte +++ b/packages/ui/src/components/ThreadCard.svelte @@ -8,6 +8,7 @@ import { separator } from '../lib/menu'; import { focusOnMount } from '../lib/actions'; import { strings } from '../lib/strings'; + import { lookupPullRequest } from '../lib/pull-request'; import { ago } from '../lib/format'; import MachineIcon from './MachineIcon.svelte'; import StatusMark from './StatusMark.svelte'; @@ -21,11 +22,13 @@ let pullRequest = $state(null); let prLoading = $state(false); - async function refreshPr() { + async function refreshPr(manual = false) { if (!owner.client || prLoading) return; prLoading = true; try { - pullRequest = await owner.client.call('threads.pullRequest', { threadId: thread.id }); + const result = await lookupPullRequest(owner.client, thread.id); + pullRequest = result.supported ? result.pullRequest : null; + if (!result.supported && manual) owner.error = strings.errors.pullRequestUnsupported; } catch (error) { owner.error = error instanceof Error ? error.message : String(error); } finally { @@ -65,7 +68,7 @@ if (action === 'rename') rename(); if (action === 'retitle') void owner.retitle(thread.id); if (action === 'pin') void owner.pin(thread.id, !thread.pinned); - if (action === 'pr') void refreshPr(); + if (action === 'pr') void refreshPr(true); if (action === 'archive') void owner.archive(thread.id); } ); diff --git a/packages/ui/src/lib/pull-request.test.ts b/packages/ui/src/lib/pull-request.test.ts new file mode 100644 index 0000000..5033f24 --- /dev/null +++ b/packages/ui/src/lib/pull-request.test.ts @@ -0,0 +1,37 @@ +import { expect, test, vi } from 'vitest'; +import { RpcErrorCode } from '@boite/contracts'; +import { RpcFailure, type Client } from './client'; +import { lookupPullRequest, resetPullRequestSupport } from './pull-request'; + +test('one unsupported probe covers concurrent threads on the same core', async () => { + const call = vi.fn().mockRejectedValue(new RpcFailure({code:RpcErrorCode.MethodNotFound,message:'unknown method threads.pullRequest'})); + const client = {call} as unknown as Client; + const results = await Promise.all(['one','two','three'].map(id => lookupPullRequest(client,id))); + expect(results).toEqual([{supported:false},{supported:false},{supported:false}]); + await lookupPullRequest(client,'four'); + expect(call).toHaveBeenCalledTimes(1); +}); + +test('a supported core returns each thread PR and a different core probes independently', async () => { + const pr = {number:42,url:'https://github.com/example/project/pull/42',state:'OPEN'}; + const call = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(pr); + const client = {call} as unknown as Client; + expect(await lookupPullRequest(client,'one')).toEqual({supported:true,pullRequest:null}); + expect(await lookupPullRequest(client,'two')).toEqual({supported:true,pullRequest:pr}); +}); + +test('authorization and transport errors are still reported and can be retried', async () => { + const failure = new RpcFailure({code:RpcErrorCode.Unauthorized,message:'Access denied'}); + const call = vi.fn().mockRejectedValueOnce(failure).mockResolvedValue(null); + const client = {call} as unknown as Client; + await expect(lookupPullRequest(client,'one')).rejects.toBe(failure); + expect(await lookupPullRequest(client,'one')).toEqual({supported:true,pullRequest:null}); +}); + +test('reconnecting after an upgrade probes again', async () => { + const call = vi.fn().mockRejectedValueOnce(new RpcFailure({code:RpcErrorCode.MethodNotFound,message:'unknown method'})).mockResolvedValue(null); + const client = {call} as unknown as Client; + expect(await lookupPullRequest(client,'one')).toEqual({supported:false}); + resetPullRequestSupport(client); + expect(await lookupPullRequest(client,'one')).toEqual({supported:true,pullRequest:null}); +}); diff --git a/packages/ui/src/lib/pull-request.ts b/packages/ui/src/lib/pull-request.ts new file mode 100644 index 0000000..923b1e6 --- /dev/null +++ b/packages/ui/src/lib/pull-request.ts @@ -0,0 +1,32 @@ +import { RpcErrorCode, type ThreadId, type ThreadSummary } from '@boite/contracts'; +import { RpcFailure, type Client } from './client'; + +type Lookup = { supported: false } | { supported: true; pullRequest: ThreadSummary['pullRequest'] }; +// Probe once per connection, including when several sidebar rows mount together. +const support = new WeakMap>(); + +export function resetPullRequestSupport(client: Client): void { + support.delete(client); +} + +export async function lookupPullRequest(client: Client, threadId: ThreadId): Promise { + const known = support.get(client); + if (known && !(await known)) return { supported: false }; + const request = client.call('threads.pullRequest', { threadId }); + if (!known) { + support.set(client, request.then(() => true, (error: unknown) => { + if (error instanceof RpcFailure && error.code === RpcErrorCode.MethodNotFound) return false; + support.delete(client); + return true; + })); + } + try { + return { supported: true, pullRequest: await request }; + } catch (error) { + if (error instanceof RpcFailure && error.code === RpcErrorCode.MethodNotFound) { + support.set(client, Promise.resolve(false)); + return { supported: false }; + } + throw error; + } +} diff --git a/packages/ui/src/lib/store.svelte.ts b/packages/ui/src/lib/store.svelte.ts index fb98d6a..7b9fa80 100644 --- a/packages/ui/src/lib/store.svelte.ts +++ b/packages/ui/src/lib/store.svelte.ts @@ -1,3 +1,4 @@ +import { resetPullRequestSupport } from './pull-request'; import { activityCommand } from './activity-command'; import type { Account, @@ -570,6 +571,7 @@ export class Store { client.onState((state) => { this.connection = state; if (state === 'ready') { + resetPullRequestSupport(client); this.#probeEpoch++; this.#probeAttempts.clear(); this.error = null; diff --git a/packages/ui/src/lib/strings.ts b/packages/ui/src/lib/strings.ts index 4db5c98..0e63c3c 100644 --- a/packages/ui/src/lib/strings.ts +++ b/packages/ui/src/lib/strings.ts @@ -730,6 +730,7 @@ export const strings = { errors: { prefix: 'Error', + pullRequestUnsupported: 'Update Boite on the machine hosting this thread to show its pull request. This core does not support pull request lookup yet.', noEndpoint: 'No core endpoint could be resolved.', connect: 'Could not connect to the core.', clipboard: 'The clipboard refused the text.', diff --git a/tests/e2e/notifications.test.ts b/tests/e2e/notifications.test.ts new file mode 100644 index 0000000..1210ff3 --- /dev/null +++ b/tests/e2e/notifications.test.ts @@ -0,0 +1,58 @@ +import { afterAll, beforeAll, expect, test } from 'bun:test'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { BrowserPage, freePort } from './lib/cdp'; +const req = createRequire(join(import.meta.dir, '../../packages/ui/package.json')); +const { createServer } = await import(req.resolve('vite')); +let server: { close(): Promise }; +let page: BrowserPage; +beforeAll(async () => { + const port = await freePort(); + const vite = await createServer({root:join(import.meta.dir,'../../packages/ui'),server:{host:'127.0.0.1',port,strictPort:true}}); + server = vite; await vite.listen(); + page = await BrowserPage.launch({url:`http://127.0.0.1:${port}/?fake=1`,windowSize:{width:1300,height:850}}); + await page.waitFor(`document.querySelector('[data-thread-id]')`); +}, 30000); +afterAll(async () => { await page?.close(); await server?.close(); }); +test('notification stays readable and can be dismissed', async () => { + await page.evaluate(`(async () => { const {workspace} = await import('/src/lib/workspace.svelte.ts'); workspace.active.error = 'Could not connect to the build machine. Check the connection in Settings and try again. The current conversation is saved.'; })()`); + await page.waitFor(`document.querySelector('[data-testid="error-toast"]')`); + await page.evaluate(`Promise.all(document.getAnimations().filter(a => a.effect?.getTiming().iterations !== Infinity).map(a => a.finished.catch(() => {})))`); + await page.screenshot(join(import.meta.dir,'.artifacts/notification-desktop.png')); + expect(await page.evaluate(`document.querySelector('[data-testid="error-toast"]').textContent`)).toContain('conversation is saved'); + expect(await page.evaluate(`(() => {const p = document.querySelector('.notification-card p'); return p.scrollHeight <= p.clientHeight && getComputedStyle(p).whiteSpace === 'pre-wrap';})()`)).toBe(true); + await page.send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true}); + await page.screenshot(join(import.meta.dir,'.artifacts/notification-phone.png')); + expect(await page.evaluate(`(() => {const r = document.querySelector('[data-testid="error-toast"]').getBoundingClientRect();return r.left >= 0 && r.right <= innerWidth && r.bottom < innerHeight / 2;})()`)).toBe(true); + await page.evaluate(`document.documentElement.dataset.theme = 'light'`); + await page.screenshot(join(import.meta.dir,'.artifacts/notification-light.png')); + await page.click('[data-testid="error-toast"] button'); + await page.waitFor(`!document.querySelector('[data-testid="error-toast"]')`); + expect(await page.evaluate(`(async () => {const {workspace} = await import('/src/lib/workspace.svelte.ts'); return workspace.active.error;})()`)).toBe(null); +}); + +test('older cores do not flood sidebar errors and manual lookup explains the missing feature', async () => { + await page.send('Emulation.setDeviceMetricsOverride',{width:1300,height:850,deviceScaleFactor:1,mobile:false}); + await page.evaluate(`(async () => { + const {workspace} = await import('/src/lib/workspace.svelte.ts'); + const {resetPullRequestSupport} = await import('/src/lib/pull-request.ts'); + const {RpcFailure} = await import('/src/lib/client.ts'); + const store = workspace.active; + const client = store.client; + const call = client.call.bind(client); + window.__prCalls = 0; + client.call = (method, params) => { + if (method === 'threads.pullRequest') { window.__prCalls++; return Promise.reject(new RpcFailure({code:-32601,message:'unknown method threads.pullRequest'})); } + return call(method, params); + }; + resetPullRequestSupport(client); + store.connection = 'connecting'; await new Promise(requestAnimationFrame); store.connection = 'ready'; + })()`); + await page.waitFor(`window.__prCalls === 1`); + expect(await page.evaluate(`document.querySelector('[data-testid="error-toast"]') === null`)).toBe(true); + await page.evaluate(`document.querySelector('[data-testid="thread-row"]').dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,clientX:100,clientY:100}))`); + await page.waitFor(`document.querySelector('[role="menuitem"]')`); + await page.evaluate(`Array.from(document.querySelectorAll('[role="menuitem"]')).find(e => e.textContent.includes('Refresh pull request')).click()`); + await page.waitFor(`document.querySelector('[data-testid="error-toast"]')?.textContent.includes('Update Boite')`); + expect(await page.evaluate(`window.__prCalls`)).toBe(1); +}); From f308bb53c276e6a04f709cfd911bb7b0309cb471 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:17:41 +0200 Subject: [PATCH 3/7] fix(ui): simplify chat status and separate context compaction --- docs/context.md | 23 +++-- docs/development.md | 7 ++ packages/contracts/src/index.ts | 2 + packages/core/src/drivers/codex.ts | 30 +++--- packages/core/src/drivers/types.ts | 2 +- packages/core/src/threads.ts | 6 +- packages/core/test/codex.test.ts | 12 +++ packages/core/test/fixtures/codex-server.ts | 9 +- packages/ui/src/app.test.ts | 11 ++- .../ui/src/components/ContextControl.svelte | 92 ++++++++++++++----- packages/ui/src/components/MessageList.svelte | 21 ++--- packages/ui/src/components/TurnSummary.svelte | 58 ++++-------- packages/ui/src/lib/fake-client.ts | 2 +- packages/ui/src/lib/strings.ts | 11 +++ tests/e2e/chat-context.test.ts | 63 +++++++++++++ tests/e2e/model-controls.test.ts | 4 +- tests/e2e/readability.test.ts | 6 +- 17 files changed, 256 insertions(+), 103 deletions(-) create mode 100644 tests/e2e/chat-context.test.ts diff --git a/docs/context.md b/docs/context.md index 5c01b22..f48c567 100644 --- a/docs/context.md +++ b/docs/context.md @@ -5,12 +5,12 @@ how much room the model gives it, and the header draws it as a ring with the percentage beside it. When the agent compacts its conversation mid-turn, the timeline gets a divider saying how many tokens went. Both come from the agent itself: the core never estimates a context size, and a provider whose protocol -says nothing shows an unknown reading. The button inside the ring requests -manual compaction of the native session. +says nothing shows an unfilled ring. Hovering, focusing or tapping the ring +opens exact counts and a separate manual compaction button. ## What is measured -`ThreadSummary.context` is `{ tokens, window, at }` or null: +`ThreadSummary.context` is `{ tokens, window, at, breakdown? }` or null: - `tokens` is what the last API request of the last turn carried: its input tokens, plus what it read from the prompt cache and what it wrote there. @@ -21,6 +21,9 @@ manual compaction of the native session. With a window the header shows a percentage and the ring; without one it shows the count alone. - `at` is when the core wrote it. +- `breakdown`, when available, contains disjoint input, cached-input and output + counts. The UI displays a single segmented bar and exact counts. Providers + without this detail show used and available capacity only. The core writes the meter at the end of every turn that reports one, through `thread.updated`, and keeps it in the journal (`threads.context`, schema 7), @@ -36,7 +39,11 @@ finite or below zero writes nothing. | Codex | `tokenUsage.last.totalTokens` in `thread/tokenUsage/updated` | `tokenUsage.modelContextWindow` when reported | completed `contextCompaction` items | | OpenCode, Antigravity, Grok, pi | none yet | | pi's manual compaction response | -Codex's count includes the last request's output. Counts remain the last +Codex's count includes the last request's output. When `totalTokens` is absent, +the driver adds the reported input and output counts. Context notifications +arriving after a turn completes are retained while its session stays warm. +Cached input is a subset of input, so the segmented bar subtracts it from the +uncached input segment. Counts remain the last reported reading, not a prediction of the next request. ACP context updates are not read yet; an unknown reading never becomes a guessed percentage. @@ -73,6 +80,8 @@ the transcript reader keeps prompts, answers and tool calls only. ## The colours -The ring is the muted foreground under three quarters of the window, the -foreground from there, and the danger colour past nine tenths, when the next -compaction is close. The tooltip has the exact counts. +The filled portion always uses the chosen accent, from zero to full capacity. +The smaller ring opens details; it never starts compaction. The popup uses +accent for uncached input, green for cached input, yellow for output and a +neutral remainder for free capacity. These are provider token categories, +not an estimated split between system instructions, files and tools. diff --git a/docs/development.md b/docs/development.md index 3314312..7580043 100644 --- a/docs/development.md +++ b/docs/development.md @@ -316,3 +316,10 @@ Scheduled goal and loop prompts keep their execution instructions in `text` and carry a separate optional `displayText` on the text part. The journal retains both; drivers read the execution prompt. The UI shows the command and objective, including when recalling a sent prompt, and hides standalone goal control markers. + +Chat status uses two small receipts: core acceptance and the first nonempty +assistant activity. Agent protocols do not provide a literal read receipt. +The reply has one spinner while running, paused when the document is hidden +and disabled for reduced motion. Finished turns show a check and elapsed time; +usage totals remain in Usage settings. Context details open separately from +compaction. `tests/e2e/chat-context.test.ts` covers these interactions. diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 7766534..0ac0e11 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -327,6 +327,8 @@ export type TitleSource = 'prompt' | 'agent' | 'user'; * Null on a thread whose agent never reported it. */ export interface ContextUse { + /** Disjoint counts from the last request, when the provider reports them. */ + breakdown?: { input: number; cache: number; output: number }; tokens: number; window: number | null; at: Timestamp; diff --git a/packages/core/src/drivers/codex.ts b/packages/core/src/drivers/codex.ts index c3c85c4..2b6e6b4 100644 --- a/packages/core/src/drivers/codex.ts +++ b/packages/core/src/drivers/codex.ts @@ -515,8 +515,9 @@ class CodexSession { private exitCode: number | null = null; private exited: Promise | null = null; private idle: Timer | null = null; - /** The turn whose `turn/start` is in flight; notifications outside one are dropped. */ + /** The turn whose `turn/start` is in flight. Context updates also arrive while idle. */ private current: CodexTurn | null = null; + private contextSink: CodexTurn['ctx']['context'] | null = null; private queue: Promise = Promise.resolve(); private running = 0; private closing = false; @@ -597,6 +598,7 @@ class CodexSession { turn.noteSession(threadId); this.current = turn; + this.contextSink = turn.ctx.context; const ctx = turn.ctx; try { // The thread as it stands for this turn, not as it stood when the @@ -800,9 +802,24 @@ class CodexSession { // -- what the agent sends ------------------------------------------------- private onNotification(method: string, raw: unknown): void { + const params = (raw ?? {}) as Record; + if (method === 'thread/tokenUsage/updated') { + if (params['threadId'] !== this.threadId) return; + const usage = params['tokenUsage'] as { last?: CodexTokenUsage; modelContextWindow?: number } | undefined; + const last = usage?.last; + if (!last) return; + if (this.current) this.current.usage = mapUsage(last); + const tokens = last.totalTokens ?? (typeof last.inputTokens === 'number' && typeof last.outputTokens === 'number' ? last.inputTokens + last.outputTokens : null); + if (tokens !== null) { + const cache = last.cachedInputTokens ?? 0; + const breakdown = typeof last.inputTokens === 'number' && typeof last.outputTokens === 'number' + ? { input: last.inputTokens - cache, cache, output: last.outputTokens } : undefined; + this.contextSink?.({tokens, window: usage?.modelContextWindow ?? null, ...(breakdown ? {breakdown} : {})}); + } + return; + } const turn = this.current; if (turn === null) return; - const params = (raw ?? {}) as Record; switch (method) { case 'turn/started': { const record = params['turn'] as CodexTurnRecord | undefined; @@ -836,15 +853,6 @@ class CodexSession { if (view !== null) turn.upsertTool(item.id, view); break; } - case 'thread/tokenUsage/updated': { - const usage = params['tokenUsage'] as { last?: CodexTokenUsage; modelContextWindow?: number } | undefined; - const last = usage?.last; - if (last !== undefined) turn.usage = mapUsage(last); - if (last !== undefined && typeof last.totalTokens === 'number') { - turn.ctx.context({ tokens: last.totalTokens, window: usage?.modelContextWindow ?? null }); - } - break; - } case 'turn/completed': { const record = params['turn'] as CodexTurnRecord | undefined; if (record !== undefined) turn.finish(record); diff --git a/packages/core/src/drivers/types.ts b/packages/core/src/drivers/types.ts index c4b0150..5db5b0a 100644 --- a/packages/core/src/drivers/types.ts +++ b/packages/core/src/drivers/types.ts @@ -91,7 +91,7 @@ export interface TurnContext { * window when the agent names it. The core writes it on the thread and * tells the clients; a driver calls it once per turn, at the end. */ - context(use: { tokens: number; window: number | null }): void; + context(use: Omit): void; requestPermission(toolName: string, input: unknown, description: string | null): PermissionTicket; /** The inline question card. One call per question, and they are asked in order. */ askQuestion(ask: QuestionAsk): QuestionTicket; diff --git a/packages/core/src/threads.ts b/packages/core/src/threads.ts index 2e1f28a..3a5b486 100644 --- a/packages/core/src/threads.ts +++ b/packages/core/src/threads.ts @@ -1163,13 +1163,15 @@ export class ThreadStore { } /** The context meter, whole numbers only: a driver that misreads its agent writes nothing. */ - private noteContext(threadId: ThreadId, use: { tokens: number; window: number | null }): void { + private noteContext(threadId: ThreadId, use: Omit): void { const tokens = Number.isFinite(use.tokens) && use.tokens >= 0 ? Math.round(use.tokens) : null; if (tokens === null) return; const window = use.window !== null && Number.isFinite(use.window) && use.window > 0 ? Math.round(use.window) : null; const thread = this.core.journal.getThread(threadId); if (thread === null) return; - this.save({ ...thread, context: { tokens, window, at: Date.now() } }, 'thread.context'); + const breakdown = use.breakdown && Object.values(use.breakdown).every(n => Number.isFinite(n) && n >= 0) + && Math.abs(use.breakdown.input + use.breakdown.cache + use.breakdown.output - tokens) <= 1 ? use.breakdown : undefined; + this.save({ ...thread, context: { tokens, window, ...(breakdown ? {breakdown} : {}), at: Date.now() } }, 'thread.context'); } private setStatus(threadId: ThreadId, status: ThreadStatus): void { diff --git a/packages/core/test/codex.test.ts b/packages/core/test/codex.test.ts index b237e22..4f749b2 100644 --- a/packages/core/test/codex.test.ts +++ b/packages/core/test/codex.test.ts @@ -306,6 +306,18 @@ describe('codex driver', () => { }; } + test('context reported after completion is retained, including a missing total', async () => { + const client = await startCore({warmProcessMinutes: 1}); + const threadId = await codexThread(client); + const finished = client.next('turn.finished', (turn) => turn.threadId === threadId, 20000); + await client.call('turns.start', { threadId, prompt: '[late-context]' }); + await finished; + await waitFor(() => harness?.core.journal.getThread(threadId)?.context?.tokens === 90, 1500); + const thread = await client.call('threads.get', {threadId}); + expect(thread.context?.window).toBe(200000); + expect(thread.context?.breakdown).toEqual({input:60,cache:20,output:10}); + }); + test('the turn usage carries the tokens the agent reported, with no price', async () => { const client = await startCore(); const threadId = await codexThread(client); diff --git a/packages/core/test/fixtures/codex-server.ts b/packages/core/test/fixtures/codex-server.ts index dadbd62..e37a722 100644 --- a/packages/core/test/fixtures/codex-server.ts +++ b/packages/core/test/fixtures/codex-server.ts @@ -19,10 +19,10 @@ */ import { appendFileSync } from 'node:fs'; -const DIRECTIVE = /\[(command|approve|thought|usage|slow|crash|input)\]/g; +const DIRECTIVE = /\[(command|approve|thought|usage|late-context|slow|crash|input)\]/g; const CHUNKS = 3; -type Directive = 'command' | 'approve' | 'thought' | 'usage' | 'slow' | 'crash' | 'input'; +type Directive = 'command' | 'approve' | 'thought' | 'usage' | 'late-context' | 'slow' | 'crash' | 'input'; let threadCounter = 0; let turnCounter = 0; @@ -305,6 +305,11 @@ async function runTurn(turnId: string, text: string): Promise { say(q1.length === 0 ? 'input refused' : `input answered ${q1}`); break; } + case 'late-context': + notify('turn/completed', { threadId, turn: turnRecord(turnId, 'completed') }); + await Bun.sleep(80); + notify('thread/tokenUsage/updated', { threadId, tokenUsage: { last: { inputTokens: 80, cachedInputTokens: 20, outputTokens: 10 }, modelContextWindow: 200000 } }); + return; case 'usage': notify('thread/tokenUsage/updated', { threadId, diff --git a/packages/ui/src/app.test.ts b/packages/ui/src/app.test.ts index 2a1376c..cbf4ffe 100644 --- a/packages/ui/src/app.test.ts +++ b/packages/ui/src/app.test.ts @@ -580,7 +580,9 @@ test('the header wears the context meter, a compaction is a divider, and a turn const meter = query('[data-testid=context-meter]'); expect(meter.textContent?.trim()).toBe('16%'); expect(meter.dataset.level).toBe('low'); - expect(meter.title).toBe('Context: 31k of 200k tokens (16%) as of the last request'); + query('[data-testid=context-trigger]').click(); + await waitFor(() => document.querySelector('[data-testid=context-popup]') !== null); + expect(query('[data-testid=context-popup]').textContent).toContain((31000).toLocaleString()); expect(query('[data-testid=compaction-part]').textContent?.replace(/\s+/g, ' ').trim()).toBe( 'Context compacted, 184k to 31k tokens' ); @@ -592,13 +594,14 @@ test('the header wears the context meter, a compaction is a divider, and a turn query('[data-testid=composer-send]').click(); await waitFor(() => store.openThread?.status === 'idle' && (store.openThread?.context?.tokens ?? 0) > 31_000); // 31_000 + 600 + 13 * 4 = 31_652 of 200_000: still 16 percent, the tooltip moved. - expect(query('[data-testid=context-meter]').title).toBe('Context: 32k of 200k tokens (16%) as of the last request'); + expect(query('[data-testid=context-meter]').dataset.percent).toBe('16'); // A thread whose agent never reported wears no meter. query('[data-thread-id="t-bench"]').click(); await waitFor(() => store.openThread?.id === 't-bench'); - expect(query('[data-testid=context-meter]').textContent).toContain('?'); - expect(query('[data-testid=context-meter]').title).toBe('Context usage not reported'); + query('[data-testid=context-trigger]').click(); + await waitFor(() => document.querySelector('[data-testid=context-popup]') !== null); + expect(query('[data-testid=context-popup]').textContent).toContain('No measurement received'); }); test('the trace panel shows the I/O a process moved, and none for a record that measured nothing', async () => { diff --git a/packages/ui/src/components/ContextControl.svelte b/packages/ui/src/components/ContextControl.svelte index 6f16d64..c7d7341 100644 --- a/packages/ui/src/components/ContextControl.svelte +++ b/packages/ui/src/components/ContextControl.svelte @@ -1,47 +1,95 @@ -
-
+ { if (popup.open) place(); }} onpointerdown={event => { if (event.target instanceof Node && !root?.contains(event.target)) popup.hide(); }} onkeydown={event => { if (event.key === 'Escape' && popup.open) { popup.hide(); event.stopPropagation(); } }} /> + +
+ -
- {percent !== null ? `${percent}%` : context ? formatTokens(context.tokens) : '?'} + {#if context}{percent !== null ? `${percent}%` : formatTokens(context.tokens)}{/if} + + {#if popup.shown} + + {/if}
diff --git a/packages/ui/src/components/MessageList.svelte b/packages/ui/src/components/MessageList.svelte index ed7cbe1..d30bcf0 100644 --- a/packages/ui/src/components/MessageList.svelte +++ b/packages/ui/src/components/MessageList.svelte @@ -9,7 +9,7 @@ -
- - {#if turn.status === 'done'}{:else if turn.status === 'error'}{:else if turn.status === 'stopped'}{:else if running}{:else}{/if} - {label} - - {#if turn.startedAt !== null}{millis(Math.max(0, (turn.finishedAt ?? now) - turn.startedAt))}{/if} - {#if tools.length}{tools.length === 1 ? strings.chat.toolCall : fill(strings.chat.toolCount, { count: String(tools.length) })}{/if} - {#if turn.usage} - {fill(strings.chat.inputTokens, { count: tokens(turn.usage.inputTokens) })} - {fill(strings.chat.outputTokens, { count: tokens(turn.usage.outputTokens) })} - {#if turn.usage.costUsdEquivalent !== null}{cost(turn.usage.costUsdEquivalent)}{/if} - {/if} -
+ hidden = document.hidden} /> +{#if turn.status !== 'queued'} +
+ {#if turn.status === 'done'}{:else if turn.status === 'error'}{:else if turn.status === 'stopped'}{:else}{/if} + {#if turn.startedAt !== null && turn.finishedAt !== null}{millis(Math.max(0, turn.finishedAt - turn.startedAt))}{/if} +
+{/if} diff --git a/packages/ui/src/lib/fake-client.ts b/packages/ui/src/lib/fake-client.ts index 78ee47f..88b7001 100644 --- a/packages/ui/src/lib/fake-client.ts +++ b/packages/ui/src/lib/fake-client.ts @@ -2772,7 +2772,7 @@ export class FakeClient implements ObservableClient { finished.context = { tokens: 84_000, window: 200_000, at: T0 + 60_000 }; finished.branch = 'boite/trace'; finished.pullRequest = { number: 84, url: 'https://github.com/example/project/pull/84', state: 'OPEN' }; - unread.context = { tokens: 31_000, window: 200_000, at: T0 + 340_000 }; + unread.context = { tokens: 31_000, breakdown: {input: 18000, cache: 10000, output: 3000}, window: 200_000, at: T0 + 340_000 }; for (const thread of [finished, running, waiting, unread]) this.#threads.set(thread.id, thread); if (this.#long) { diff --git a/packages/ui/src/lib/strings.ts b/packages/ui/src/lib/strings.ts index 0e63c3c..fd8c5f3 100644 --- a/packages/ui/src/lib/strings.ts +++ b/packages/ui/src/lib/strings.ts @@ -217,6 +217,15 @@ export const strings = { }, thread: { + contextDetails: 'Context', + contextInput: 'Input', + contextCache: 'Cached input', + contextOutput: 'Output', + contextUsed: 'Used', + contextFree: 'Available', + contextNoBreakdown: 'This agent does not report a breakdown.', + contextNoReading: 'No measurement received from this agent yet.', + contextMeasured: 'Last measurement', none: 'No thread open', noneBody: 'Pick one on the left, or start a new one.', trace: 'Trace', @@ -248,6 +257,8 @@ export const strings = { }, chat: { + accepted: 'Request accepted', + responseStarted: 'Agent activity received', working: 'Working', writing: 'Writing', stopped: 'Stopped', diff --git a/tests/e2e/chat-context.test.ts b/tests/e2e/chat-context.test.ts new file mode 100644 index 0000000..2e17885 --- /dev/null +++ b/tests/e2e/chat-context.test.ts @@ -0,0 +1,63 @@ +import { afterAll, beforeAll, expect, test } from 'bun:test'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { BrowserPage, freePort } from './lib/cdp'; +const req = createRequire(join(import.meta.dir, '../../packages/ui/package.json')); +const { createServer } = await import(req.resolve('vite')); +let server: { close(): Promise }; +let page: BrowserPage; +beforeAll(async () => { + const port = await freePort(); + const vite = await createServer({root:join(import.meta.dir,'../../packages/ui'),server:{host:'127.0.0.1',port,strictPort:true}}); + server = vite; await vite.listen(); + page = await BrowserPage.launch({url:`http://127.0.0.1:${port}/?fake=1`,windowSize:{width:1300,height:850}}); + await page.waitFor(`document.querySelector('[data-thread-id]')`); +}, 30000); +afterAll(async () => { await page?.close(); await server?.close(); }); + +async function update(code: string) { + await page.evaluate(`(async () => { const {workspace} = await import('/src/lib/workspace.svelte.ts'); const store = workspace.active; const thread = store.openThread; ${code} })()`); +} +async function capture(name: string) { + await page.evaluate(`Promise.all(document.getAnimations().filter(a => a.effect?.getTiming().iterations !== Infinity).map(a => a.finished.catch(() => {})))`); + await page.screenshot(join(import.meta.dir,'.artifacts',name + '.png')); +} +test('receipts follow actual activity, response indicator stays left and completion contains only duration', async () => { + await update(`window.__answer = JSON.parse(JSON.stringify(thread.messages.at(-1)));`); + await update(`thread.messages = thread.messages.filter(m => m.role === 'user'); const turn = thread.turns[0]; turn.status = 'running'; turn.startedAt = Date.now(); turn.finishedAt = null; thread.status = 'running';`); + await page.waitFor(`document.querySelector('[data-testid="turn-summary"][data-status="running"]')`); + expect(await page.evaluate(`document.querySelector('.author') === null`)).toBe(true); + expect(await page.evaluate(`document.querySelectorAll('.receipts .received').length`)).toBe(1); + expect(await page.evaluate(`document.querySelector('[data-testid="turn-summary"]').textContent.trim()`)).toBe(''); + expect(await page.evaluate(`document.querySelector('[data-testid="turn-summary"]').getBoundingClientRect().left < document.querySelector('.bubble').getBoundingClientRect().left`)).toBe(true); + await page.send('Emulation.setEmulatedMedia', {features:[{name:'prefers-reduced-motion',value:'reduce'}]}); + expect(await page.evaluate(`getComputedStyle(document.querySelector('[data-testid="turn-summary"] svg')).animationName`)).toBe('none'); + await page.send('Emulation.setEmulatedMedia', {features:[]}); + await page.evaluate(`Object.defineProperty(document,'hidden',{value:true,configurable:true}); document.dispatchEvent(new Event('visibilitychange'));`); + await page.waitFor(`getComputedStyle(document.querySelector('[data-testid="turn-summary"] svg')).animationPlayState === 'paused'`); + await page.evaluate(`delete document.hidden; document.dispatchEvent(new Event('visibilitychange'));`); + await capture('quiet-chat-waiting'); + await update(`const answer = window.__answer; answer.parts = [{type:'text',text:'A complete answer.'}]; answer.state = 'streaming'; thread.messages.push(answer);`); + await page.waitFor(`document.querySelectorAll('.receipts .received').length === 2`); + await update(`const turn = thread.turns[0]; turn.status = 'done'; turn.finishedAt = turn.startedAt + 3800; thread.status = 'idle'; thread.messages.at(-1).state = 'complete';`); + await page.waitFor(`document.querySelector('[data-testid="turn-summary"]').textContent.includes('3.8')`); + expect(await page.evaluate(`document.querySelector('[data-testid="turn-summary"]').textContent.trim()`)).toBe('3.8 s'); + await capture('quiet-chat-done'); +}); +test('context opens on hover, shows exact segments, and compaction needs its own click', async () => { + await update(`thread.context = {tokens:31000,window:200000,at:Date.now(),breakdown:{input:18000,cache:10000,output:3000}}; thread.sessionId = 'test-session'; window.__compactCalls = 0; store.compact = async () => {window.__compactCalls++;};`); + await page.evaluate(`document.querySelector('[data-testid="context-meter"]').dispatchEvent(new MouseEvent('mouseenter'))`); + await page.waitFor(`document.querySelector('[data-testid="context-popup"]')`); + expect(await page.evaluate(`window.__compactCalls`)).toBe(0); + expect(await page.evaluate(`document.querySelectorAll('[data-testid="context-popup"] .bar span').length`)).toBe(3); + expect(await page.evaluate(`document.querySelector('[data-testid="context-popup"]').textContent.includes((31000).toLocaleString())`)).toBe(true); + await capture('quiet-context-desktop'); + await page.send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true}); + await capture('quiet-context-phone'); + expect(await page.evaluate(`(() => {const r = document.querySelector('[data-testid="context-popup"]').getBoundingClientRect(); return r.left >= 0 && r.right <= innerWidth;})()`)).toBe(true); + await page.evaluate(`document.documentElement.dataset.theme = 'light'`); + await capture('quiet-context-light'); + await page.click('[data-testid="context-compact"]'); + await page.waitFor(`!document.querySelector('[data-testid="context-popup"]')`); + expect(await page.evaluate(`window.__compactCalls`)).toBe(1); +}); diff --git a/tests/e2e/model-controls.test.ts b/tests/e2e/model-controls.test.ts index c44a219..c81bb5b 100644 --- a/tests/e2e/model-controls.test.ts +++ b/tests/e2e/model-controls.test.ts @@ -90,11 +90,13 @@ test('favorites survive reload, reasoning has discrete stops, and the context ri await capture('favorites-phone.png'); await page.send('Emulation.clearDeviceMetricsOverride', {}); await page.navigate(url); + await page.waitFor(`document.querySelector('[data-testid=context-trigger]')`); + await page.click('[data-testid=context-trigger]'); await page.waitFor(`document.querySelector('[data-testid=context-compact]') && !document.querySelector('[data-testid=context-compact]').disabled`); await capture('context-compact-before.png'); await page.click('[data-testid=context-compact]'); await page.waitFor(`document.querySelector('[data-testid=compaction-part][data-trigger=manual]')`); - await page.waitFor(`document.querySelector('[data-testid=context-meter]')?.title.includes('7,8k') || document.querySelector('[data-testid=context-meter]')?.title.includes('7.8k') || document.querySelector('[data-testid=context-meter]')?.title.includes('8k')`); + await page.waitFor(`document.querySelector('[data-testid=context-meter]')?.dataset.percent === '4'`); await capture('context-compact-after.png'); await page.click('[data-testid=composer-picker]'); await page.waitFor(`document.querySelector('[data-testid=favorite-model]')`); diff --git a/tests/e2e/readability.test.ts b/tests/e2e/readability.test.ts index 44f0719..887f427 100644 --- a/tests/e2e/readability.test.ts +++ b/tests/e2e/readability.test.ts @@ -39,7 +39,7 @@ test('thread metadata, message identity and expandable trace fit a narrow panel' await capture('readability-desktop'); expect(await page.evaluate(`document.querySelector('${id('trace-row')}[data-pid="21140"]').open`)).toBe(true); expect(await page.evaluate(`document.querySelector('${id('trace-panel')}').scrollWidth <= document.querySelector('${id('trace-panel')}').clientWidth`)).toBe(true); - expect(await page.evaluate(`document.querySelector('${id('turn-summary')}').textContent`)).toContain('Done'); + expect(await page.evaluate(`document.querySelector('${id('turn-summary')}').getAttribute('aria-label')`)).toBe('Done'); await page.send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true}); await capture('readability-trace-phone'); expect(await page.evaluate('document.documentElement.scrollWidth <= innerWidth')).toBe(true); @@ -59,11 +59,11 @@ test('paragraphs arrive whole, keep previous nodes and flush when stopped; reaso expect(await page.evaluate(`document.querySelector('${id('thinking-toggle')}').textContent`)).toContain('Checking results'); await capture('readability-working'); await update(`thread.messages.at(-1).parts.push({type:'text',text:'Last partial paragraph'});`); - await page.waitFor(`document.querySelector('${id('turn-summary')}').textContent.includes('Writing')`); + await page.waitFor(`document.querySelector('${id('turn-summary')}').dataset.status === 'running'`); expect(await page.evaluate(`document.querySelector('${id('timeline')}').textContent.includes('Last partial paragraph')`)).toBe(false); await update(`thread.turns[0].status = 'stopped'; thread.turns[0].finishedAt = Date.now(); thread.messages.at(-1).state = 'complete'; thread.status = 'idle';`); await page.waitFor(`document.querySelector('${id('timeline')}').textContent.includes('Last partial paragraph')`); - expect(await page.evaluate(`document.querySelector('${id('turn-summary')}').textContent`)).toContain('Stopped'); + expect(await page.evaluate(`document.querySelector('${id('turn-summary')}').getAttribute('aria-label')`)).toBe('Stopped'); }); test('goal prompts and markers stay readable and recognized commands are accented while typing', async () => { From 04600419e81170149bbe54a67b930e7cb0dc3628 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:53:36 +0200 Subject: [PATCH 4/7] fix(ui): explain goal incompatibility on the hosting core --- packages/ui/src/app.test.ts | 25 +++++++++++++++++++++++++ packages/ui/src/lib/store.svelte.ts | 8 +++++++- packages/ui/src/lib/strings.ts | 1 + tests/e2e/notifications.test.ts | 24 ++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/app.test.ts b/packages/ui/src/app.test.ts index cbf4ffe..a76089f 100644 --- a/packages/ui/src/app.test.ts +++ b/packages/ui/src/app.test.ts @@ -1352,3 +1352,28 @@ test('machines coexist and disconnecting a remote leaves the primary connected', expect(store.connection).toBe('ready'); expect(workspace.active).toBe(store); }); + +test('an older core names the host that needs goals support and keeps the unsent prompt', async () => { + await mountOnFake(); + await waitFor(() => store.openThread !== null); + store.core!.hostname = 'Older host'; + const client = store.client!; + const call = client.call.bind(client); + const { RpcFailure } = await import('./lib/client'); + const spy = vi.spyOn(client, 'call').mockImplementation((method, params) => { + if (method === 'threads.activity.set') return Promise.reject(new RpcFailure({code:-32601,message:'unknown method threads.activity.set'})); + return call(method, params); + }); + try { + const field = query('[data-testid=composer-input]'); + field.value = '/goal Verify two tasks'; + field.dispatchEvent(new Event('input', {bubbles:true})); + await waitFor(() => !query('[data-testid=composer-send]').disabled); + query('[data-testid=composer-send]').click(); + await waitFor(() => store.error !== null); + expect(store.error).toContain(store.core!.hostname); + expect(store.error).toContain('Update Boite'); + expect(field.value).toBe('/goal Verify two tasks'); + expect(spy.mock.calls.some(([method]) => method === 'turns.start')).toBe(false); + } finally { spy.mockRestore(); } +}); diff --git a/packages/ui/src/lib/store.svelte.ts b/packages/ui/src/lib/store.svelte.ts index 7b9fa80..964e39b 100644 --- a/packages/ui/src/lib/store.svelte.ts +++ b/packages/ui/src/lib/store.svelte.ts @@ -1,3 +1,4 @@ +import { RpcErrorCode } from '@boite/contracts'; import { resetPullRequestSupport } from './pull-request'; import { activityCommand } from './activity-command'; import type { @@ -1453,7 +1454,12 @@ export class Store { const activity = activityCommand(prompt); if (activity) { if (attachments.length) throw new Error(strings.activity.noAttachments); - const accepted = await client.call('threads.activity.set', { threadId, ...activity }); + const accepted = await client.call('threads.activity.set', { threadId, ...activity }).catch((error: unknown) => { + if (error instanceof RpcFailure && error.code === RpcErrorCode.MethodNotFound) { + throw new Error(strings.errors.activityUnsupported.replace('{machine}', this.core?.hostname ?? strings.app.name)); + } + throw error; + }); if (this.openThread?.id === threadId) this.openThread.activity = accepted; return true; } diff --git a/packages/ui/src/lib/strings.ts b/packages/ui/src/lib/strings.ts index fd8c5f3..beaa089 100644 --- a/packages/ui/src/lib/strings.ts +++ b/packages/ui/src/lib/strings.ts @@ -741,6 +741,7 @@ export const strings = { errors: { prefix: 'Error', + activityUnsupported: 'This core does not support goals or loops. Update Boite on {machine}, then reconnect. Your command has not been sent.', pullRequestUnsupported: 'Update Boite on the machine hosting this thread to show its pull request. This core does not support pull request lookup yet.', noEndpoint: 'No core endpoint could be resolved.', connect: 'Could not connect to the core.', diff --git a/tests/e2e/notifications.test.ts b/tests/e2e/notifications.test.ts index 1210ff3..9464718 100644 --- a/tests/e2e/notifications.test.ts +++ b/tests/e2e/notifications.test.ts @@ -56,3 +56,27 @@ test('older cores do not flood sidebar errors and manual lookup explains the mis await page.waitFor(`document.querySelector('[data-testid="error-toast"]')?.textContent.includes('Update Boite')`); expect(await page.evaluate(`window.__prCalls`)).toBe(1); }); + +test('unsupported goals identify the host without sending a normal turn', async () => { + await page.evaluate(`(async () => { + const {workspace} = await import('/src/lib/workspace.svelte.ts'); + const {RpcFailure} = await import('/src/lib/client.ts'); + const store = workspace.active; store.core.hostname = 'Older host'; + const client = store.client; const call = client.call.bind(client); + window.__normalTurns = 0; + client.call = (method, params) => { + if (method === 'threads.activity.set') return Promise.reject(new RpcFailure({code:-32601,message:'unknown method threads.activity.set'})); + if (method === 'turns.start') window.__normalTurns++; + return call(method,params); + }; + await store.send('/goal Verify two tasks'); + })()`); + await page.waitFor(`document.querySelector('[data-testid="error-toast"]')?.textContent.includes('Update Boite on Older host')`); + expect(await page.evaluate(`window.__normalTurns`)).toBe(0); + await page.evaluate(`document.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true}));`); + await page.evaluate(`Promise.all(document.getAnimations().filter(a => a.effect?.getTiming().iterations !== Infinity).map(a => a.finished.catch(() => {})))`); + await page.screenshot(join(import.meta.dir,'.artifacts/goal-compat-desktop.png')); + await page.send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true}); + await page.evaluate(`Promise.all(document.getAnimations().filter(a => a.effect?.getTiming().iterations !== Infinity).map(a => a.finished.catch(() => {})))`); + await page.screenshot(join(import.meta.dir,'.artifacts/goal-compat-phone.png')); +}); From bc53e72e88fa2859309c6d90c21ef446b8dbc5e0 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:33:30 +0200 Subject: [PATCH 5/7] Enable Codex task plans and enlarge activity controls --- docs/providers.md | 8 +++++ packages/core/src/activity.ts | 5 ++- packages/core/src/drivers/codex.ts | 2 ++ packages/core/test/activity.test.ts | 2 ++ packages/core/test/codex.test.ts | 22 +++++++----- packages/core/test/fixtures/codex-server.ts | 5 ++- packages/ui/src/app.css | 2 ++ .../ui/src/components/ThreadActivity.svelte | 35 ++++++++++++++----- tests/e2e/composer-activity.test.ts | 2 ++ 9 files changed, 63 insertions(+), 20 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index 2b419f5..e025853 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -295,3 +295,11 @@ question card in the timeline and answers the agent with what the user picked; Codex's `item/tool/requestUserInput` is the one that does today. A question is not a permission mode and is never gated by one: an agent whose approvals are off can still ask. + +### Codex task tracking + +Boite enables `tools.update_plan.enabled` through the per-thread configuration +on both `thread/start` and `thread/resume`. Codex disables this tool by default; +listening for `turn/plan/updated` alone does not make it available to the agent. +The native plan populates the thread's activity tasks. Goal instructions explain +this mapping so the agent uses its planning tools instead of legacy Boite todos. diff --git a/packages/core/src/activity.ts b/packages/core/src/activity.ts index ab4ffab..2c4ff34 100644 --- a/packages/core/src/activity.ts +++ b/packages/core/src/activity.ts @@ -152,8 +152,11 @@ export class ActivityStore { const prompt = kind === 'goal' ? `Work toward this goal: ${state.goal!.objective}\nContinue until the objective is achieved. When you have verified completion, write [BOITE_GOAL_COMPLETE] alone on its own line. If blocked or waiting for user input, explain what is missing and write [BOITE_GOAL_BLOCKED] alone on its own line.` : state.loop!.prompt; + const taskGuidance = kind === 'goal' + ? '\nTrack the work with your native planning tool (Codex: update_plan; Claude: TodoWrite or TaskCreate/TaskUpdate). Boite displays those task updates in this thread. Create the plan before working and update its statuses as you verify results. The Boite goal already exists; do not create a second goal or use legacy Boite todo tools.' + : ''; try { - const turn = this.core.threads.startTurn(threadId, prompt, [], undefined, undefined, kind === 'goal' ? `/goal ${state.goal!.objective}` : `/loop ${state.loop!.intervalMs / 1000}s ${state.loop!.prompt}`); + const turn = this.core.threads.startTurn(threadId, prompt + taskGuidance, [], undefined, undefined, kind === 'goal' ? `/goal ${state.goal!.objective}` : `/loop ${state.loop!.intervalMs / 1000}s ${state.loop!.prompt}`); this.ownTurns.set(turn.id, { kind, generation: this.generations.get(threadId) ?? 0 }); // Drivers may synchronously report tasks while startTurn runs. const current = this.states.get(threadId)!; diff --git a/packages/core/src/drivers/codex.ts b/packages/core/src/drivers/codex.ts index 2b6e6b4..aeb2f95 100644 --- a/packages/core/src/drivers/codex.ts +++ b/packages/core/src/drivers/codex.ts @@ -692,6 +692,7 @@ class CodexSession { approvalPolicy: policy.approvalPolicy, sandbox: policy.sandbox, ...(model === null ? {} : { model }), + config: { 'tools.update_plan.enabled': true }, excludeTurns: true, }); this.threadId = resumed.thread.id; @@ -699,6 +700,7 @@ class CodexSession { } const created = await rpc.request<{ thread: { id: string } }>('thread/start', { + config: { 'tools.update_plan.enabled': true }, cwd: ctx.thread.cwd, approvalPolicy: policy.approvalPolicy, sandbox: policy.sandbox, diff --git a/packages/core/test/activity.test.ts b/packages/core/test/activity.test.ts index 4b7e21d..f990038 100644 --- a/packages/core/test/activity.test.ts +++ b/packages/core/test/activity.test.ts @@ -156,5 +156,7 @@ test('goal messages expose a display command while the driver receives its instr await waitFor(() => received.length > 0); const thread = await client.call('threads.get', { threadId }); expect(received).toContain('[BOITE_GOAL_COMPLETE]'); + expect(received).toContain('Codex: update_plan'); + expect(received).toContain('Boite displays those task updates'); expect(thread.messages.find(m => m.role === 'user')?.parts[0]).toMatchObject({ text: received, displayText: '/goal Check two tasks' }); }); diff --git a/packages/core/test/codex.test.ts b/packages/core/test/codex.test.ts index 4f749b2..b476e03 100644 --- a/packages/core/test/codex.test.ts +++ b/packages/core/test/codex.test.ts @@ -167,16 +167,20 @@ describe('codex driver', () => { expect(getDriver('codex-appserver').protocol).toBe('codex-appserver'); }); - test('native plan events populate thread activity tasks', async () => { - const client = await startCore(); + test('native plan tools are enabled on new and resumed sessions and populate activity', async () => { + const client = await startCore({ warmProcessMinutes: 0 }); const threadId = await codexThread(client); - const finished = client.next('turn.finished', (turn) => turn.threadId === threadId, 20000); - await client.call('turns.start', { threadId, prompt: '[tasks]' }); - expect((await finished).status).toBe('done'); - expect((await client.call('threads.get', { threadId })).activity?.tasks).toEqual([ - { id: '0', text: 'Inspect source', status: 'completed' }, - { id: '1', text: 'Run checks', status: 'in_progress' }, - ]); + for (let i = 0; i < 2; i++) { + harness!.core.activity.tasks(threadId, []); + const finished = client.next('turn.finished', (turn) => turn.threadId === threadId, 20000); + await client.call('turns.start', { threadId, prompt: '[tasks]' }); + expect((await finished).status).toBe('done'); + expect((await client.call('threads.get', { threadId })).activity?.tasks).toEqual([ + { id: '0', text: 'Inspect source', status: 'completed' }, + { id: '1', text: 'Run checks', status: 'in_progress' }, + ]); + } + expect(fakeLog()).toContain('thread/resume'); }); test('a plain prompt streams back as one text part, and the codex thread id is kept', async () => { diff --git a/packages/core/test/fixtures/codex-server.ts b/packages/core/test/fixtures/codex-server.ts index e37a722..6e2660b 100644 --- a/packages/core/test/fixtures/codex-server.ts +++ b/packages/core/test/fixtures/codex-server.ts @@ -28,6 +28,7 @@ let threadCounter = 0; let turnCounter = 0; let itemCounter = 0; let threadId = ''; +let planEnabled = false; /** * The turns an interrupt already arrived for, and what is waiting on one. A * real server knows a turn from the moment it answers `turn/start`, so an @@ -213,7 +214,7 @@ function commandItem(itemId: string, status: string, output: string | null): unk async function runTurn(turnId: string, text: string): Promise { notify('turn/started', { threadId, turn: turnRecord(turnId, 'inProgress') }); - if (text.includes('[tasks]')) notify('turn/plan/updated', { threadId, turnId, plan: [{ step: 'Inspect source', status: 'completed' }, { step: 'Run checks', status: 'inProgress' }] }); + if (planEnabled && text.includes('[tasks]')) notify('turn/plan/updated', { threadId, turnId, plan: [{ step: 'Inspect source', status: 'completed' }, { step: 'Run checks', status: 'inProgress' }] }); const directives = directivesOf(text); const say = (chunk: string): void => { notify('item/agentMessage/delta', { threadId, turnId, itemId: 'msg-1', delta: chunk }); @@ -374,6 +375,7 @@ function handle(method: string, raw: unknown): unknown { // One page, and a null cursor: the driver stops asking on that. return { data: MODELS, nextCursor: null }; case 'thread/start': { + planEnabled = (params['config'] as Record | undefined)?.['tools.update_plan.enabled'] === true; threadCounter += 1; threadId = `codex-fake-${Math.random().toString(16).slice(2, 10)}-${threadCounter}`; log( @@ -382,6 +384,7 @@ function handle(method: string, raw: unknown): unknown { return { thread: threadRecord(), model: 'fake-codex', modelProvider: 'fake', serviceTier: null }; } case 'thread/resume': { + planEnabled = (params['config'] as Record | undefined)?.['tools.update_plan.enabled'] === true; threadId = textOf(params['threadId']); log( `thread/resume ${threadId} approvalPolicy=${textOf(params['approvalPolicy'])} sandbox=${textOf(params['sandbox'])}`, diff --git a/packages/ui/src/app.css b/packages/ui/src/app.css index 0a5cc93..39d596d 100644 --- a/packages/ui/src/app.css +++ b/packages/ui/src/app.css @@ -78,6 +78,8 @@ /* Control heights, one token each: a plain button, a small one, a field. */ --control: 30px; --control-sm: 26px; + --control-lg: 36px; + --control-touch: 40px; --input: 34px; --dur-1: 90ms; diff --git a/packages/ui/src/components/ThreadActivity.svelte b/packages/ui/src/components/ThreadActivity.svelte index db1384a..d880f0b 100644 --- a/packages/ui/src/components/ThreadActivity.svelte +++ b/packages/ui/src/components/ThreadActivity.svelte @@ -37,22 +37,25 @@ {#each ['goal', 'loop'] as kind (kind)} {@const entry = kind === 'goal' ? activity.goal : activity.loop} {#if entry} -
+
{#if kind === 'goal'}{:else}{/if} {kind === 'goal' ? strings.activity.goal : strings.activity.loop} - {strings.activity[entry.status]} + {strings.activity[entry.status]} {#if 'intervalMs' in entry}{fill(strings.activity.every, { interval: interval(entry.intervalMs) })}{/if} - {#if kind === 'goal' && entry.status !== 'complete'} - + {/if} - + +
@@ -89,10 +92,17 @@ {/if} diff --git a/tests/e2e/composer-activity.test.ts b/tests/e2e/composer-activity.test.ts index 2cc6f05..2b6d2ce 100644 --- a/tests/e2e/composer-activity.test.ts +++ b/tests/e2e/composer-activity.test.ts @@ -116,12 +116,14 @@ test('goal and loop commands share the activity panel with disclosed tasks and r await page.waitFor(`document.querySelector('${id('activity-tasks-toggle')}')`); await page.evaluate(`document.querySelector('${id('thread-activity')}').dispatchEvent(new PointerEvent('pointerenter'))`); await page.waitFor(`document.querySelector('${id('activity-tasks-toggle')}').getAttribute('aria-expanded') === 'true'`); + expect(await page.evaluate(`Array.from(document.querySelectorAll('.activity-action')).every(button => button.getBoundingClientRect().width >= 36 && button.getBoundingClientRect().height >= 36)`)).toBe(true); await capture('composer-activity-desktop'); await page.evaluate(`document.querySelector('${id('thread-activity')}').dispatchEvent(new PointerEvent('pointerleave'))`); await page.waitFor(`document.querySelector('${id('activity-tasks-toggle')}').getAttribute('aria-expanded') === 'false'`); await size(true); await page.click(id('activity-tasks-toggle')); await page.waitFor(`document.querySelector('${id('activity-tasks-toggle')}').getAttribute('aria-expanded') === 'true'`); + expect(await page.evaluate(`Array.from(document.querySelectorAll('.activity-action')).every(button => button.getBoundingClientRect().width >= 40 && button.getBoundingClientRect().height >= 40)`)).toBe(true); await capture('composer-activity-phone'); await page.click(`${id('activity-goal')} [aria-label="Remove"]`); await page.waitFor(`!document.querySelector('${id('activity-goal')}')`); From b3092729b1e8b527dbb04ee63906f0f2e1e00216 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:33:18 +0200 Subject: [PATCH 6/7] fix: scope turn usage and stabilize browser checks --- packages/core/src/drivers/codex.ts | 2 +- packages/core/test/codex.test.ts | 11 +++++++++++ packages/core/test/fixtures/codex-server.ts | 2 +- packages/ui/src/components/ThreadCard.svelte | 1 - tests/e2e/chat-context.test.ts | 4 ++-- tests/e2e/lib/cdp.ts | 3 ++- tests/e2e/notifications.test.ts | 2 +- tests/e2e/readability.test.ts | 5 +++-- 8 files changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/core/src/drivers/codex.ts b/packages/core/src/drivers/codex.ts index aeb2f95..726c1cd 100644 --- a/packages/core/src/drivers/codex.ts +++ b/packages/core/src/drivers/codex.ts @@ -810,7 +810,7 @@ class CodexSession { const usage = params['tokenUsage'] as { last?: CodexTokenUsage; modelContextWindow?: number } | undefined; const last = usage?.last; if (!last) return; - if (this.current) this.current.usage = mapUsage(last); + if (this.current && params['turnId'] === this.current.turnId) this.current.usage = mapUsage(last); const tokens = last.totalTokens ?? (typeof last.inputTokens === 'number' && typeof last.outputTokens === 'number' ? last.inputTokens + last.outputTokens : null); if (tokens !== null) { const cache = last.cachedInputTokens ?? 0; diff --git a/packages/core/test/codex.test.ts b/packages/core/test/codex.test.ts index b476e03..c861ead 100644 --- a/packages/core/test/codex.test.ts +++ b/packages/core/test/codex.test.ts @@ -322,6 +322,17 @@ describe('codex driver', () => { expect(thread.context?.breakdown).toEqual({input:60,cache:20,output:10}); }); + test('late usage from a previous turn does not overwrite the current turn', async () => { + const client = await startCore({ warmProcessMinutes: 1 }); + const threadId = await codexThread(client); + await runTurn(client, threadId, '[late-context]'); + const finished = client.next('turn.finished', turn => turn.threadId === threadId, 20000); + await client.call('turns.start', { threadId, prompt: '[slow]' }); + await waitFor(() => harness!.core.threads.get(threadId).context?.tokens === 90); + await client.call('turns.stop', { threadId }); + expect((await finished).usage).toBeNull(); + }); + test('the turn usage carries the tokens the agent reported, with no price', async () => { const client = await startCore(); const threadId = await codexThread(client); diff --git a/packages/core/test/fixtures/codex-server.ts b/packages/core/test/fixtures/codex-server.ts index 6e2660b..fc51627 100644 --- a/packages/core/test/fixtures/codex-server.ts +++ b/packages/core/test/fixtures/codex-server.ts @@ -309,7 +309,7 @@ async function runTurn(turnId: string, text: string): Promise { case 'late-context': notify('turn/completed', { threadId, turn: turnRecord(turnId, 'completed') }); await Bun.sleep(80); - notify('thread/tokenUsage/updated', { threadId, tokenUsage: { last: { inputTokens: 80, cachedInputTokens: 20, outputTokens: 10 }, modelContextWindow: 200000 } }); + notify('thread/tokenUsage/updated', { threadId, turnId, tokenUsage: { last: { inputTokens: 80, cachedInputTokens: 20, outputTokens: 10 }, modelContextWindow: 200000 } }); return; case 'usage': notify('thread/tokenUsage/updated', { diff --git a/packages/ui/src/components/ThreadCard.svelte b/packages/ui/src/components/ThreadCard.svelte index 41c913b..84bb67c 100644 --- a/packages/ui/src/components/ThreadCard.svelte +++ b/packages/ui/src/components/ThreadCard.svelte @@ -228,7 +228,6 @@ } .machine { flex: none; - pointer-events: auto; } .machine.offline { color: var(--color-danger); diff --git a/tests/e2e/chat-context.test.ts b/tests/e2e/chat-context.test.ts index 2e17885..f08a431 100644 --- a/tests/e2e/chat-context.test.ts +++ b/tests/e2e/chat-context.test.ts @@ -12,7 +12,7 @@ beforeAll(async () => { server = vite; await vite.listen(); page = await BrowserPage.launch({url:`http://127.0.0.1:${port}/?fake=1`,windowSize:{width:1300,height:850}}); await page.waitFor(`document.querySelector('[data-thread-id]')`); -}, 30000); +}, 90000); afterAll(async () => { await page?.close(); await server?.close(); }); async function update(code: string) { @@ -43,7 +43,7 @@ test('receipts follow actual activity, response indicator stays left and complet await page.waitFor(`document.querySelector('[data-testid="turn-summary"]').textContent.includes('3.8')`); expect(await page.evaluate(`document.querySelector('[data-testid="turn-summary"]').textContent.trim()`)).toBe('3.8 s'); await capture('quiet-chat-done'); -}); +}, 30000); test('context opens on hover, shows exact segments, and compaction needs its own click', async () => { await update(`thread.context = {tokens:31000,window:200000,at:Date.now(),breakdown:{input:18000,cache:10000,output:3000}}; thread.sessionId = 'test-session'; window.__compactCalls = 0; store.compact = async () => {window.__compactCalls++;};`); await page.evaluate(`document.querySelector('[data-testid="context-meter"]').dispatchEvent(new MouseEvent('mouseenter'))`); diff --git a/tests/e2e/lib/cdp.ts b/tests/e2e/lib/cdp.ts index 918a045..e92f870 100644 --- a/tests/e2e/lib/cdp.ts +++ b/tests/e2e/lib/cdp.ts @@ -230,7 +230,8 @@ export class BrowserPage { await Bun.sleep(POLL_MS); } } - await this.waitFor("document.readyState === 'complete'"); + // A cold Vite dependency build on the Windows runner can outlast the normal DOM wait. + await this.waitFor("document.readyState === 'complete'", 60_000); } async click(selector: string): Promise { diff --git a/tests/e2e/notifications.test.ts b/tests/e2e/notifications.test.ts index 9464718..670a247 100644 --- a/tests/e2e/notifications.test.ts +++ b/tests/e2e/notifications.test.ts @@ -12,7 +12,7 @@ beforeAll(async () => { server = vite; await vite.listen(); page = await BrowserPage.launch({url:`http://127.0.0.1:${port}/?fake=1`,windowSize:{width:1300,height:850}}); await page.waitFor(`document.querySelector('[data-thread-id]')`); -}, 30000); +}, 90000); afterAll(async () => { await page?.close(); await server?.close(); }); test('notification stays readable and can be dismissed', async () => { await page.evaluate(`(async () => { const {workspace} = await import('/src/lib/workspace.svelte.ts'); workspace.active.error = 'Could not connect to the build machine. Check the connection in Settings and try again. The current conversation is saved.'; })()`); diff --git a/tests/e2e/readability.test.ts b/tests/e2e/readability.test.ts index 887f427..8fb7c59 100644 --- a/tests/e2e/readability.test.ts +++ b/tests/e2e/readability.test.ts @@ -21,7 +21,7 @@ beforeAll(async () => { page = await BrowserPage.launch({url:`http://127.0.0.1:${port}/?fake=1&machines=1`,windowSize:{width:1300,height:850}}); await page.waitFor(`document.querySelector('[data-thread-id="t-trace"]')`); await page.click('[data-thread-id="t-trace"]'); -}, 30000); +}, 90000); afterAll(async () => { await page?.close(); await server?.close(); }); test('thread metadata, message identity and expandable trace fit a narrow panel', async () => { @@ -82,7 +82,8 @@ test('goal prompts and markers stay readable and recognized commands are accente await page.waitFor(`document.querySelector('${id('composer-input')}').scrollHeight > document.querySelector('${id('composer-input')}').clientHeight`); await page.evaluate(`(() => { const input = document.querySelector('${id('composer-input')}'); input.scrollTop = input.scrollHeight; input.dispatchEvent(new Event('scroll')); })()`); await page.waitFor(`document.querySelector('.input-mirror').style.transform === 'translateY(-' + document.querySelector('${id('composer-input')}').scrollTop + 'px)'`); - expect(await page.evaluate(`Math.abs(document.querySelector('.input-mirror').getBoundingClientRect().width - document.querySelector('${id('composer-input')}').clientWidth) < 1`)).toBe(true); + // ResizeObserver updates the mirrored width after the textarea gains its scrollbar. + await page.waitFor(`Math.abs(document.querySelector('.input-mirror').getBoundingClientRect().width - document.querySelector('${id('composer-input')}').clientWidth) < 1`); await capture('readability-composer-long'); await page.evaluate(`(() => { const input = document.querySelector('${id('composer-input')}'); input.value = '/goal Verify another task'; input.dispatchEvent(new Event('input',{bubbles:true})); })()`); await capture('readability-goal'); From baee42a4d1307978b73f46e06ba9416ede59e750 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:05:36 +0200 Subject: [PATCH 7/7] fix: wait for reconnect history before sending --- packages/ui/src/app.test.ts | 23 +++++++++++++++++++++++ packages/ui/src/lib/store.svelte.ts | 3 +++ tests/e2e/chat-context.test.ts | 4 ++-- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/app.test.ts b/packages/ui/src/app.test.ts index a76089f..b2e74c2 100644 --- a/packages/ui/src/app.test.ts +++ b/packages/ui/src/app.test.ts @@ -1377,3 +1377,26 @@ test('an older core names the host that needs goals support and keeps the unsent expect(spy.mock.calls.some(([method]) => method === 'turns.start')).toBe(false); } finally { spy.mockRestore(); } }); + +test('sending waits for reconnect history to finish loading', async () => { + await mountOnFake(); + await waitFor(() => store.openThread !== null); + const client = store.client!; + const call = client.call.bind(client); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const spy = vi.spyOn(client, 'call').mockImplementation((method, params) => { + if (method === 'threads.get') return gate.then(() => call(method, params)); + return call(method, params); + }); + try { + const loading = store.reload(); + await waitFor(() => spy.mock.calls.some(([method]) => method === 'threads.get')); + const sending = store.send('after reconnect'); + await Promise.resolve(); + expect(spy.mock.calls.some(([method]) => method === 'turns.start')).toBe(false); + release(); + await loading; + expect(await sending).toBe(true); + } finally { release(); spy.mockRestore(); } +}); diff --git a/packages/ui/src/lib/store.svelte.ts b/packages/ui/src/lib/store.svelte.ts index 964e39b..14ff314 100644 --- a/packages/ui/src/lib/store.svelte.ts +++ b/packages/ui/src/lib/store.svelte.ts @@ -1451,6 +1451,9 @@ export class Store { if (!client || !threadId || this.connection !== 'ready') return false; if (prompt.trim().length === 0 && attachments.length === 0) return false; try { + // Reconnect snapshots must land before a new stream starts mutating the thread. + await this.#reloading; + if (this.#client !== client || this.connection !== 'ready') return false; const activity = activityCommand(prompt); if (activity) { if (attachments.length) throw new Error(strings.activity.noAttachments); diff --git a/tests/e2e/chat-context.test.ts b/tests/e2e/chat-context.test.ts index f08a431..0d6944b 100644 --- a/tests/e2e/chat-context.test.ts +++ b/tests/e2e/chat-context.test.ts @@ -32,7 +32,7 @@ test('receipts follow actual activity, response indicator stays left and complet expect(await page.evaluate(`document.querySelector('[data-testid="turn-summary"]').getBoundingClientRect().left < document.querySelector('.bubble').getBoundingClientRect().left`)).toBe(true); await page.send('Emulation.setEmulatedMedia', {features:[{name:'prefers-reduced-motion',value:'reduce'}]}); expect(await page.evaluate(`getComputedStyle(document.querySelector('[data-testid="turn-summary"] svg')).animationName`)).toBe('none'); - await page.send('Emulation.setEmulatedMedia', {features:[]}); + await page.send('Emulation.setEmulatedMedia', {features:[{name:'prefers-reduced-motion',value:'no-preference'}]}); await page.evaluate(`Object.defineProperty(document,'hidden',{value:true,configurable:true}); document.dispatchEvent(new Event('visibilitychange'));`); await page.waitFor(`getComputedStyle(document.querySelector('[data-testid="turn-summary"] svg')).animationPlayState === 'paused'`); await page.evaluate(`delete document.hidden; document.dispatchEvent(new Event('visibilitychange'));`); @@ -60,4 +60,4 @@ test('context opens on hover, shows exact segments, and compaction needs its own await page.click('[data-testid="context-compact"]'); await page.waitFor(`!document.querySelector('[data-testid="context-popup"]')`); expect(await page.evaluate(`window.__compactCalls`)).toBe(1); -}); +}, 30000);