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 1e7ead2..7580043 100644 --- a/docs/development.md +++ b/docs/development.md @@ -303,3 +303,23 @@ 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. + +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/docs/machines.md b/docs/machines.md index b65b260..763de5c 100644 --- a/docs/machines.md +++ b/docs/machines.md @@ -56,15 +56,22 @@ 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. + +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 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/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..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; @@ -436,7 +438,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..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); + 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 c3c85c4..726c1cd 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 @@ -690,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; @@ -697,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, @@ -800,9 +804,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 && 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; + 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 +855,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 34a898f..3a5b486 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, @@ -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/activity.test.ts b/packages/core/test/activity.test.ts index 59f4d04..f990038 100644 --- a/packages/core/test/activity.test.ts +++ b/packages/core/test/activity.test.ts @@ -142,3 +142,21 @@ 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(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 b237e22..c861ead 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 () => { @@ -306,6 +310,29 @@ 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('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 dadbd62..fc51627 100644 --- a/packages/core/test/fixtures/codex-server.ts +++ b/packages/core/test/fixtures/codex-server.ts @@ -19,15 +19,16 @@ */ 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; 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 }); @@ -305,6 +306,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, turnId, tokenUsage: { last: { inputTokens: 80, cachedInputTokens: 20, outputTokens: 10 }, modelContextWindow: 200000 } }); + return; case 'usage': notify('thread/tokenUsage/updated', { threadId, @@ -369,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( @@ -377,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.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 @@ -
-
+ { 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 705ef14..d30bcf0 100644 --- a/packages/ui/src/components/MessageList.svelte +++ b/packages/ui/src/components/MessageList.svelte @@ -9,7 +9,7 @@ + +
+ +
+ {title} +

{message}

+
+ +
+ + diff --git a/packages/ui/src/components/Prose.svelte b/packages/ui/src/components/Prose.svelte index fa347ea..8641fbb 100644 --- a/packages/ui/src/components/Prose.svelte +++ b/packages/ui/src/components/Prose.svelte @@ -1,61 +1,17 @@ -
{@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} - {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/packages/ui/src/components/ThreadCard.svelte b/packages/ui/src/components/ThreadCard.svelte index db6d565..84bb67c 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'; @@ -19,16 +20,17 @@ let renaming = $state(false); let title = $state(''); let pullRequest = $state(null); - let prError = $state(''); + 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 }); - prError = ''; + 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) { - prError = error instanceof Error ? error.message : String(error); + owner.error = error instanceof Error ? error.message : String(error); } finally { prLoading = false; } @@ -66,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); } ); @@ -107,32 +109,18 @@ {#if thread.pinned}{/if} {ago(thread.lastUserMessageAt ?? thread.createdAt, now)} - - {#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..246e72a --- /dev/null +++ b/packages/ui/src/components/TurnSummary.svelte @@ -0,0 +1,27 @@ + + + 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 5519418..88b7001 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; @@ -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/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/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..14ff314 100644 --- a/packages/ui/src/lib/store.svelte.ts +++ b/packages/ui/src/lib/store.svelte.ts @@ -1,3 +1,5 @@ +import { RpcErrorCode } from '@boite/contracts'; +import { resetPullRequestSupport } from './pull-request'; import { activityCommand } from './activity-command'; import type { Account, @@ -570,6 +572,7 @@ export class Store { client.onState((state) => { this.connection = state; if (state === 'ready') { + resetPullRequestSupport(client); this.#probeEpoch++; this.#probeAttempts.clear(); this.error = null; @@ -1448,10 +1451,18 @@ 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); - 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 37e243d..beaa089 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,15 @@ export const strings = { }, chat: { + accepted: 'Request accepted', + responseStarted: 'Agent activity received', + 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 +411,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', @@ -715,6 +741,8 @@ 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.', clipboard: 'The clipboard refused the text.', diff --git a/tests/e2e/chat-context.test.ts b/tests/e2e/chat-context.test.ts new file mode 100644 index 0000000..0d6944b --- /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]')`); +}, 90000); +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:[{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'));`); + 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'); +}, 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'))`); + 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); +}, 30000); diff --git a/tests/e2e/composer-activity.test.ts b/tests/e2e/composer-activity.test.ts index a14a44c..2b6d2ce 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')}')`); @@ -114,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')}')`); 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/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/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/notifications.test.ts b/tests/e2e/notifications.test.ts new file mode 100644 index 0000000..670a247 --- /dev/null +++ b/tests/e2e/notifications.test.ts @@ -0,0 +1,82 @@ +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]')`); +}, 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.'; })()`); + 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); +}); + +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')); +}); diff --git a/tests/e2e/readability.test.ts b/tests/e2e/readability.test.ts new file mode 100644 index 0000000..8fb7c59 --- /dev/null +++ b/tests/e2e/readability.test.ts @@ -0,0 +1,95 @@ +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"]'); +}, 90000); +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')}').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); + 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')}').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')}').getAttribute('aria-label')`)).toBe('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)'`); + // 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'); + 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); +});