diff --git a/docs/development.md b/docs/development.md index 7580043..d6a48e5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -128,12 +128,18 @@ message out of the queue for editing; clicking a pending message does the same. Escape stops the current turn. Pending messages then run in their original order. A failed send preserves the queue for an explicit retry. -`/goal ` starts work toward an objective. `/loop [interval] ` -repeats a prompt, immediately once and then at the given interval. Intervals use -`s`, `m` or `h`, from one second to 24 hours; the default is five minutes. +`/goal ` starts work toward an objective. `/loop 2 ` runs two +consecutive iterations and stops. Counts range from 1 to 1000. A count written +as "2 iterations" or "2 itérations" in the prompt is also recognized. +`/loop 5m ` explicitly schedules repetition, with the delay counted +after each finished turn. Intervals use `s`, `m` or `h`, from one second to +24 hours. A loop without a count or interval is refused; there is no default timer. Both commands belong to Boite and work with every driver. Goals and loops can -coexist with the agent's task list above the composer. Hover or click the task -row to expand it; its button also works from the keyboard and on a phone. +coexist with the agent's task list above the composer. The compact overlay shows +the current task and progress. Only a click expands it; updates and disclosure +do not resize the timeline. Completed tasks and goals fade out on the next user +prompt, and newly reported work brings the task list back. Loop details show +the latest 50 iterations with their outcome and up to 4000 characters of result. The core owns this work, so switching threads or closing a client does not cancel it. A goal continues through scheduled turns until the agent emits @@ -143,6 +149,11 @@ Escape also pauses a loop between runs. The activity bar has pause, resume, remove and manual goal completion controls. A restarted core preserves the activity but requires an explicit resume. +Goal instructions are assembled only when invoking a driver. The journal stores +the visible `/goal` or `/loop` message with its kind and iteration in the text +part. The UI also cleans up goal prompts saved by older cores and hides standalone +completion/blocker markers, including partial markers during streaming. + Tasks come from ACP plans, Codex plan notifications or successful task tools such as Claude's TodoWrite and TaskCreate/TaskUpdate. An agent that reports no tasks gets no invented task list. Pi uses the same successful-tool observation. @@ -237,6 +248,17 @@ from exact events to polling; without the second the focus guard never starts. ## Captures +`tests/e2e/header.test.ts` checks the shared header, sidebar folding and saved +state, project groups, machine menu ordering, and prompt navigation through a +paged, virtualized conversation. It captures desktop, phone and light-theme +layouts on the fake client. The shell suite checks that the thread controls sit +inside the same title bar and that dragging excludes editable controls. + +The prompt outline uses at most 13 entries, keeping the first and last prompts +and seven around the reading position. Distant prompts are grouped behind a +keyboard-accessible list, so every loaded prompt remains reachable. Desktop +markers are 12 px apart; the compact activity panel sits 4 px above the composer. + The fake client is excluded from production bundles. Tests that need it must use the Vite development server. `tests/e2e/settings.test.ts` starts and closes one within the test process; the other end-to-end paths use a real temporary @@ -312,10 +334,11 @@ 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. +Scheduled goal and loop prompts journal the command and objective in `text`, with +activity kind and iteration metadata. The core builds the execution instructions +when starting the driver. Older messages can carry `displayText`, which the UI +still honors when displaying or recalling a prompt. Terminal goal control markers +stay hidden; examples inside answer text or code fences remain visible. Chat status uses two small receipts: core acceptance and the first nonempty assistant activity. Agent protocols do not provide a literal read receipt. diff --git a/docs/images/chat-controls.png b/docs/images/chat-controls.png new file mode 100644 index 0000000..143793c Binary files /dev/null and b/docs/images/chat-controls.png differ diff --git a/docs/images/prompt-navigation.png b/docs/images/prompt-navigation.png new file mode 100644 index 0000000..5efc796 Binary files /dev/null and b/docs/images/prompt-navigation.png differ diff --git a/docs/model-switching.md b/docs/model-switching.md index 62750e6..69d7239 100644 --- a/docs/model-switching.md +++ b/docs/model-switching.md @@ -85,6 +85,13 @@ continuation of an image-bearing history explicitly. ## Storage and concurrent changes +The composer resolves an old `default` or `auto` model alias to the configured +provider preset for the next prompt when one is configured. A named model stays selected. Before sending +on an old thread, the UI verifies the preset against the account's model catalog +and saves it with the thread's selection revision. An unavailable preset or a +concurrent selection change refuses the send; it never silently runs the alias. +The picker also ignores saved presets containing these aliases. + Schema 9 adds `threads.session_generation`, `threads.selection_version` and `turns.execution`. Existing native session IDs survive migration. Execution snapshots record the target at acceptance, and both scheduler and driver use it. diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 0ac0e11..5e756a7 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -438,7 +438,7 @@ export interface ImageAttachment { } export type MessagePart = - | { type: 'text'; text: string; /** User-facing command for internally scheduled prompts. */ displayText?: string } + | { type: 'text'; text: string; displayText?: string; activity?: { kind: 'goal' | 'loop'; iteration: number } } /** 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. */ @@ -514,9 +514,19 @@ export interface AgentTask { } export interface ThreadActivity { - goal: { objective: string; status: 'active' | 'paused' | 'complete'; iterations: number; error: string | null } | null; - loop: { prompt: string; intervalMs: number; status: 'active' | 'paused'; iterations: number; nextRunAt: number | null; error: string | null } | null; + goal: { objective: string; status: 'active' | 'paused' | 'complete'; iterations: number; error: string | null; dismissed?: boolean } | null; + loop: { prompt: string; intervalMs: number; maxIterations?: number | null; status: 'active' | 'paused' | 'complete'; iterations: number; nextRunAt: number | null; error: string | null; history?: ActivityIteration[] } | null; tasks: AgentTask[]; + tasksDismissed?: boolean; +} + +export interface ActivityIteration { + iteration: number; + turnId: TurnId; + status: 'running' | 'done' | 'error' | 'stopped'; + summary: string; + startedAt: Timestamp; + finishedAt: Timestamp | null; } export interface Thread extends ThreadSummary { @@ -902,7 +912,7 @@ export interface PairingGrant { // --------------------------------------------------------------------------- export interface RpcMethods { - 'threads.activity.set': { params: { threadId: ThreadId; goal?: { objective: string } | null; loop?: { prompt: string; intervalMs: number } | null }; result: ThreadActivity }; + 'threads.activity.set': { params: { threadId: ThreadId; goal?: { objective: string } | null; loop?: { prompt: string; intervalMs: number; maxIterations?: number | null } | null }; result: ThreadActivity }; 'threads.activity.control': { params: { threadId: ThreadId; kind: 'goal' | 'loop'; action: 'pause' | 'resume' | 'remove' | 'complete' }; result: ThreadActivity }; 'quotas.list': { params: { refresh?: boolean }; result: AccountQuota[] }; 'quotas.configure': { params: { accountId: AccountId; enabled: boolean }; result: AccountQuota[] }; diff --git a/packages/core/src/activity-prompt.ts b/packages/core/src/activity-prompt.ts new file mode 100644 index 0000000..8c9bc24 --- /dev/null +++ b/packages/core/src/activity-prompt.ts @@ -0,0 +1,10 @@ +/** Instructions for automated turns stay out of the journal's user message. */ +export function activityPrompt(kind: 'goal' | 'loop', text: string, iteration: number): string { + const objective = text.replace(/^\/(?:goal|loop)\s*/, ''); + if (kind === 'loop') return `Iteration ${iteration}. Execute the following task once for this iteration. Boite owns the repetition and stopping count; do not start another loop or repeat the task yourself. Report the result of this iteration.\n${objective}`; + return `Work toward this goal: ${objective}\nContinue until the objective is achieved. When you have verified completion, end your answer with [BOITE_GOAL_COMPLETE] alone on its own line, outside code blocks. If blocked or waiting for user input, explain what is missing and end with [BOITE_GOAL_BLOCKED] alone on its own line, outside code blocks.\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.`; +} + +export function activityResult(text: string): string { + return text.replace(/^\s*\[BOITE_GOAL_(?:COMPLETE|BLOCKED)\]\s*$/gm, '').trim(); +} diff --git a/packages/core/src/activity.ts b/packages/core/src/activity.ts index 8f23181..b280995 100644 --- a/packages/core/src/activity.ts +++ b/packages/core/src/activity.ts @@ -1,6 +1,7 @@ import type { AgentTask, MessagePart, RpcParams, ThreadActivity, Turn } from '@boite/contracts'; import type { Core } from './core.ts'; import { invalidParams, refused } from './errors.ts'; +import { activityResult } from './activity-prompt.ts'; const empty = (): ThreadActivity => ({ goal: null, loop: null, tasks: [] }); @@ -8,7 +9,7 @@ const empty = (): ThreadActivity => ({ goal: null, loop: null, tasks: [] }); export class ActivityStore { private readonly states = new Map(); private readonly timers = new Map>(); - private readonly ownTurns = new Map(); + private readonly ownTurns = new Map(); private readonly generations = new Map(); private closed = false; @@ -16,7 +17,10 @@ export class ActivityStore { for (const thread of core.journal.listThreads()) { const saved = core.journal.getSetting(`activity:${thread.id}`) as ThreadActivity | undefined; if (!saved) continue; - const changed = pauseActivity(saved, 'Core restarted. Resume to continue.'); + let changed = pauseActivity(saved, 'Core restarted. Resume to continue.'); + for (const run of saved.loop?.history ?? []) { + if (run.status === 'running') { run.status = 'error'; run.summary = 'Core restarted before this iteration finished.'; run.finishedAt = Date.now(); changed = true; } + } this.states.set(thread.id, saved); if (changed) this.save(thread.id); } @@ -51,10 +55,13 @@ export class ActivityStore { state.goal = params.goal === null ? null : { objective: params.goal.objective.trim(), status: 'active', iterations: 0, error: null }; } if (params.loop !== undefined) { - if (params.loop !== null && (typeof params.loop.prompt !== 'string' || !params.loop.prompt.trim() || !Number.isInteger(params.loop.intervalMs) || params.loop.intervalMs < 1000 || params.loop.intervalMs > 86_400_000)) throw invalidParams('loop.prompt must be non-empty; loop.intervalMs must be an integer from 1000 to 86400000'); - state.loop = params.loop === null ? null : { prompt: params.loop.prompt.trim(), intervalMs: params.loop.intervalMs, status: 'active', iterations: 0, nextRunAt: Date.now(), error: null }; + if (params.loop !== null) validateLoop(params.loop); + state.loop = params.loop === null ? null : { prompt: params.loop.prompt.trim(), intervalMs: params.loop.intervalMs, maxIterations: params.loop.maxIterations ?? null, status: 'active', iterations: 0, nextRunAt: Date.now(), error: null, history: [] }; + } + for (const kind of ['goal', 'loop'] as const) if (params[kind] !== undefined) { + const key = `${params.threadId}:${kind}`; + this.generations.set(key, (this.generations.get(key) ?? 0) + 1); } - if (params.goal !== undefined) this.generations.set(params.threadId, (this.generations.get(params.threadId) ?? 0) + 1); this.states.set(params.threadId, state); this.save(params.threadId); this.schedule(params.threadId, 0); @@ -69,10 +76,15 @@ export class ActivityStore { const state = this.get(params.threadId); const item = state[params.kind]; if (!item) throw refused(`this thread has no ${params.kind}`); + if (params.action === 'resume' && params.kind === 'loop' && state.loop?.maxIterations && state.loop.iterations >= state.loop.maxIterations) throw refused('this loop has finished all its iterations; start a new loop'); if (params.action === 'complete' && params.kind !== 'goal') throw invalidParams('only a goal can be completed'); if (params.action === 'remove') state[params.kind] = null; else if (params.action === 'complete' && state.goal) state.goal.status = 'complete'; - else { item.status = params.action === 'resume' ? 'active' : 'paused'; item.error = null; } + else { item.status = params.action === 'resume' ? 'active' : 'paused'; item.error = null; if (params.kind === 'goal' && state.goal) state.goal.dismissed = false; } + if (params.action === 'remove' || params.action === 'complete') { + const key = `${params.threadId}:${params.kind}`; + this.generations.set(key, (this.generations.get(key) ?? 0) + 1); + } if (state.loop && state.loop.status !== 'active') state.loop.nextRunAt = null; if (params.kind === 'loop' && params.action === 'resume' && state.loop) state.loop.nextRunAt = Date.now(); this.states.set(params.threadId, state); @@ -83,11 +95,22 @@ export class ActivityStore { tasks(threadId: string, tasks: AgentTask[]): void { const state = this.get(threadId); + if (tasks.some(task => task.status !== 'completed' || !state.tasks.some(old => old.id === task.id && old.text === task.text))) state.tasksDismissed = false; state.tasks = tasks; this.states.set(threadId, state); this.save(threadId); } + /** A new user request retires the finished overlay without deleting its history. */ + userPrompt(threadId: string): void { + const state = this.states.get(threadId); + if (!state) return; + let changed = false; + if (state.tasks.length && state.tasks.every(task => task.status === 'completed') && !state.tasksDismissed) { state.tasksDismissed = true; changed = true; } + if (state.goal?.status === 'complete' && !state.goal.dismissed) { state.goal.dismissed = true; changed = true; } + if (changed) this.save(threadId); + } + private observeTool(threadId: string, part: MessagePart): void { if (part.type !== 'tool' || part.status !== 'done' || !part.input || typeof part.input !== 'object') return; const input = part.input as Record; @@ -115,10 +138,22 @@ export class ActivityStore { private finished(turn: Turn): void { const owned = this.ownTurns.get(turn.id); this.ownTurns.delete(turn.id); - if (turn.status !== 'done') { this.pauseAll(turn.threadId, turn.error ?? 'Turn stopped. Resume to continue.'); return; } const state = this.states.get(turn.threadId); + const current = owned && owned.generation === (this.generations.get(`${turn.threadId}:${owned.kind}`) ?? 0); + if (current && owned.kind === 'loop' && state?.loop) { + const run = state.loop.history?.find(run => run.turnId === turn.id); + if (run) { + run.status = turn.status === 'done' ? 'done' : turn.status === 'stopped' ? 'stopped' : 'error'; + run.finishedAt = turn.finishedAt ?? Date.now(); + run.summary = activityResult(Array.from(this.core.journal.walkTurnMessages(turn.threadId, turn.id)).filter(message => message.role === 'assistant').flatMap(message => message.parts.filter(part => part.type === 'text').map(part => part.text)).join('\n')).slice(0, 4000) || turn.error || ''; + } + if (turn.status === 'done' && state.loop.maxIterations && state.loop.iterations >= state.loop.maxIterations) { state.loop.status = 'complete'; state.loop.nextRunAt = null; } + else if (state.loop.status === 'active') state.loop.nextRunAt = Date.now() + state.loop.intervalMs; + this.save(turn.threadId); + } + if (turn.status !== 'done' && (!owned || current)) { this.pauseAll(turn.threadId, turn.error ?? 'Turn stopped. Resume to continue.'); return; } if (!state) return; - if (owned?.kind === 'goal' && owned.generation === (this.generations.get(turn.threadId) ?? 0) && state.goal?.status === 'active') { + if (owned?.kind === 'goal' && current && state.goal?.status === 'active') { const signal = this.goalResult(turn); if (signal === 'blocked') { state.goal.status = 'paused'; state.goal.error = 'The agent reported a blocker. Read its answer before resuming.'; this.save(turn.threadId); } else if (signal === 'complete') { state.goal.status = 'complete'; this.save(turn.threadId); } @@ -152,19 +187,18 @@ export class ActivityStore { if (state.loop?.status === 'active') this.schedule(threadId, Math.max(0, (state.loop.nextRunAt ?? Date.now()) - Date.now())); return; } - const prompt = kind === 'goal' - ? `Work toward this goal: ${state.goal!.objective}\nContinue until the objective is achieved. When you have verified completion, end your answer with [BOITE_GOAL_COMPLETE] alone on its own line, outside code blocks. If blocked or waiting for user input, explain what is missing and end with [BOITE_GOAL_BLOCKED] alone on its own line, outside code blocks.` - : 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.' - : ''; + const prompt = `/${kind} ${kind === 'goal' ? state.goal!.objective : state.loop!.prompt}`; + const iteration = state[kind]!.iterations + 1; try { - 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 }); + const turn = this.core.threads.startTurn(threadId, prompt, [], undefined, undefined, { kind, iteration }); + this.ownTurns.set(turn.id, { kind, generation: this.generations.get(`${threadId}:${kind}`) ?? 0, iteration }); // Drivers may synchronously report tasks while startTurn runs. const current = this.states.get(threadId)!; current[kind]!.iterations++; - if (kind === 'loop') current.loop!.nextRunAt = Date.now() + current.loop!.intervalMs; + if (kind === 'loop') { + current.loop!.nextRunAt = null; + current.loop!.history = [...(current.loop!.history ?? []), { iteration, turnId: turn.id, status: 'running' as const, summary: '', startedAt: Date.now(), finishedAt: null }].slice(-50); + } this.save(threadId); } catch (error) { this.pauseAll(threadId, error instanceof Error ? error.message : String(error)); } } @@ -195,6 +229,12 @@ export class ActivityStore { close(): void { this.closed = true; for (const threadId of this.states.keys()) this.pauseAll(threadId); } } +export function validateLoop(loop: NonNullable['loop']>): void { + if (typeof loop.prompt !== 'string' || !loop.prompt.trim()) throw invalidParams('loop.prompt must be non-empty text'); + if (loop.maxIterations != null && (!Number.isInteger(loop.maxIterations) || loop.maxIterations < 1 || loop.maxIterations > 1000)) throw invalidParams('loop.maxIterations must be an integer from 1 to 1000'); + if (!Number.isInteger(loop.intervalMs) || loop.intervalMs > 86_400_000 || (loop.intervalMs !== 0 && loop.intervalMs < 1000) || (loop.intervalMs === 0 && !loop.maxIterations)) throw invalidParams('loop.intervalMs must be from 1000 to 86400000, or 0 with maxIterations'); +} + export function taskStatus(value: unknown): AgentTask['status'] { return value === 'completed' ? 'completed' : value === 'in_progress' || value === 'inProgress' ? 'in_progress' : 'pending'; } function createdTaskId(text: string | null): string | null { diff --git a/packages/core/src/threads.ts b/packages/core/src/threads.ts index 0b7e291..f284a59 100644 --- a/packages/core/src/threads.ts +++ b/packages/core/src/threads.ts @@ -1,4 +1,5 @@ import { statSync } from 'node:fs'; +import { activityPrompt } from './activity-prompt.ts'; import { relative, resolve } from 'node:path'; import { ATTACHMENTS_PER_TURN, ATTACHMENT_MAX_BYTES, IMAGE_MIME_TYPES, MESSAGE_PAGE, MESSAGE_PAGE_MAX } from '@boite/contracts'; import type { @@ -570,7 +571,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', displayPrompt?: string): Turn { + startTurn(threadId: ThreadId, prompt: string, attachments: ImageAttachment[] = [], expectedSelectionVersion?: number, operation?: 'compact', activity?: { kind: 'goal' | 'loop'; iteration: number }): 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 +611,7 @@ export class ThreadStore { turnId: turn.id, role: 'user', parts: [ - { type: 'text', text: prompt, ...(displayPrompt ? { displayText: displayPrompt } : {}) }, + { type: 'text', text: prompt, ...(activity ? { activity } : {}) }, ...attachments.map((attachment): MessagePart => ({ type: 'image', mimeType: attachment.mimeType, @@ -626,6 +627,7 @@ export class ThreadStore { this.core.journal.putTurn(turn); this.core.journal.putMessage(message); }); + if (!activity && !operation) this.core.activity.userPrompt(threadId); this.core.bus.emit('message.started', message); this.core.bus.emit('message.completed', { threadId, messageId: message.id, state: 'complete' }); this.setStatus(threadId, 'queued'); @@ -1129,7 +1131,7 @@ export class ThreadStore { } } return { - prompt: message.parts.map((part) => (part.type === 'text' ? part.text : '')).join(''), + prompt: message.parts.map((part) => part.type === 'text' ? part.activity ? activityPrompt(part.activity.kind, part.text, part.activity.iteration) : part.text : '').join(''), attachments, }; } diff --git a/packages/core/test/activity.test.ts b/packages/core/test/activity.test.ts index 62f3e8d..0105bd7 100644 --- a/packages/core/test/activity.test.ts +++ b/packages/core/test/activity.test.ts @@ -88,6 +88,43 @@ test('goal continues across turns, reports tasks and stops only on completion', const thread = await client.call('threads.get', { threadId }); expect(thread.activity?.tasks[0]?.status).toBe('completed'); expect(thread.activity?.goal?.iterations).toBe(2); + const prompt = thread.messages.find(message => message.role === 'user')?.parts[0]; + expect(prompt).toEqual({ type: 'text', text: '/goal Verify the change', activity: { kind: 'goal', iteration: 1 } }); +}); + +test('counted loops run consecutive iterations, retain each result and stop at the requested count', async () => { + const client = await h.connect(); + const { threadId } = await echoThread(h, client); + let count = 0; + restore = setDriver('echo', { protocol: 'echo', startTurn(ctx) { + count++; + expect(ctx.prompt).toContain(`Iteration ${count}.`); + const id = ctx.emit.startMessage('assistant'); + ctx.emit.part(id, 0, { type: 'text', text: `pong ${count}` }); + ctx.emit.complete(id, 'complete'); + return { stop() {}, done: Promise.resolve({ status: 'done', sessionId: null, usage: null }) }; + } }); + await client.call('threads.activity.set', { threadId, loop: { prompt: 'say pong', intervalMs: 0, maxIterations: 2 } }); + await waitFor(() => h.core.activity.get(threadId).loop?.status === 'complete'); + const loop = h.core.activity.get(threadId).loop!; + expect(count).toBe(2); + expect(loop.nextRunAt).toBeNull(); + expect(loop.history?.map(run => [run.iteration, run.status, run.summary])).toEqual([[1, 'done', 'pong 1'], [2, 'done', 'pong 2']]); + await expect(client.call('threads.activity.control', { threadId, kind: 'loop', action: 'resume' })).rejects.toThrow('finished'); + await Bun.sleep(400); + expect(count).toBe(2); +}); + +test('finished tasks retire on a user prompt and new work brings them back', async () => { + const client = await h.connect(); + const { threadId } = await echoThread(h, client); + h.core.activity.tasks(threadId, [{ id: 'one', text: 'Done', status: 'completed' }]); + await client.call('turns.start', { threadId, prompt: 'next request' }); + expect(h.core.activity.get(threadId).tasksDismissed).toBe(true); + h.core.activity.tasks(threadId, [{ id: 'one', text: 'Done', status: 'completed' }]); + expect(h.core.activity.get(threadId).tasksDismissed).toBe(true); + h.core.activity.tasks(threadId, [{ id: 'two', text: 'New task', status: 'in_progress' }]); + expect(h.core.activity.get(threadId).tasksDismissed).toBe(false); }); test('loop repeats, Escape pauses it while idle, resume runs again, remove clears it', async () => { @@ -200,6 +237,25 @@ test('replacing an in-flight goal cannot complete the replacement with the old a await client.call('turns.stop', { threadId }); }); +test.each(['error', 'stopped'] as const)('an obsolete %s turn cannot pause or strand its replacement', async status => { + const client = await h.connect(); + const { threadId } = await echoThread(h, client); + let finish!: () => void; + let count = 0; + restore = setDriver('echo', { protocol: 'echo', startTurn() { + count++; + return { stop() {}, done: count === 1 ? new Promise(resolve => { + finish = () => resolve({ status, sessionId: null, usage: null }); + }) : Promise.resolve({ status: 'done', sessionId: null, usage: null }) }; + } }); + await client.call('threads.activity.set', { threadId, loop: { prompt: 'old', intervalMs: 0, maxIterations: 1 } }); + await waitFor(() => !!finish); + await client.call('threads.activity.set', { threadId, loop: { prompt: 'replacement', intervalMs: 0, maxIterations: 1 } }); + finish(); + await waitFor(() => h.core.activity.get(threadId).loop?.status === 'complete'); + expect(count).toBe(2); + expect(h.core.activity.get(threadId).loop?.history).toHaveLength(1); +}); test('goal messages expose a display command while the driver receives its instructions', async () => { const client = await h.connect(); @@ -215,5 +271,25 @@ test('goal messages expose a display command while the driver receives its instr 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' }); + expect(thread.messages.find(m => m.role === 'user')?.parts[0]).toMatchObject({ text: '/goal Check two tasks', activity: { kind: 'goal', iteration: 1 } }); +}); + +test.each(['remove', 'complete'] as const)('%s invalidates a running goal before its late failure', async action => { + const client = await h.connect(); + const { threadId } = await echoThread(h, client); + let finish!: () => void; + let count = 0; + restore = setDriver('echo', { protocol: 'echo', startTurn() { + count++; + return { stop() {}, done: count === 1 ? new Promise(resolve => { + finish = () => resolve({ status: 'error', sessionId: null, usage: null }); + }) : Promise.resolve({ status: 'done', sessionId: null, usage: null }) }; + } }); + await client.call('threads.activity.set', { threadId, goal: { objective: 'old' } }); + await waitFor(() => !!finish); + await client.call('threads.activity.set', { threadId, loop: { prompt: 'other work', intervalMs: 0, maxIterations: 1 } }); + await client.call('threads.activity.control', { threadId, kind: 'goal', action }); + finish(); + await waitFor(() => h.core.activity.get(threadId).loop?.status === 'complete'); + expect(count).toBe(2); }); diff --git a/packages/ui/src/App.svelte b/packages/ui/src/App.svelte index 64fcfc5..0f0454e 100644 --- a/packages/ui/src/App.svelte +++ b/packages/ui/src/App.svelte @@ -236,9 +236,7 @@
- {#if inShell} - - {/if} +
{#if !store.booted} @@ -467,7 +465,7 @@ .scrim { display: block; position: fixed; - inset: 0; + inset: var(--titlebar) 0 0; z-index: 20; border: none; border-radius: 0; diff --git a/packages/ui/src/app.css b/packages/ui/src/app.css index 39d596d..d27e3fd 100644 --- a/packages/ui/src/app.css +++ b/packages/ui/src/app.css @@ -68,7 +68,7 @@ --text-md: 16px; --text-lg: 20px; - --titlebar: 36px; + --titlebar: 44px; --sidebar: 280px; --panel: 360px; --content: 820px; diff --git a/packages/ui/src/app.test.ts b/packages/ui/src/app.test.ts index b2e74c2..c2d479b 100644 --- a/packages/ui/src/app.test.ts +++ b/packages/ui/src/app.test.ts @@ -215,7 +215,7 @@ test('a draft names its project in the heading and the dropdown moves it to anot expect(heading.textContent).toContain('with approval requests in'); expect(heading.textContent).toContain('notes'); // The heading says the project, so the header chip no longer repeats it. - expect(query('[data-testid=chat] header').textContent).not.toContain('notes'); + expect(query('[data-testid=thread-header]').textContent).not.toContain('notes'); query('[data-testid=draft-project]').click(); await waitFor(() => document.querySelector('[data-testid=draft-project-menu]') !== null); diff --git a/packages/ui/src/components/ChatView.svelte b/packages/ui/src/components/ChatView.svelte index 5bcc2fc..d07cc53 100644 --- a/packages/ui/src/components/ChatView.svelte +++ b/packages/ui/src/components/ChatView.svelte @@ -1,83 +1,16 @@
0} data-testid="status-connection" data-state={store.connection} aria-live="polite"> - + {#if issues}{:else}{/if} {connected} {connected === 1 ? strings.connection.oneMachine : strings.connection.machines} {#if issues}{issues}{/if} diff --git a/packages/ui/src/components/Menu.svelte b/packages/ui/src/components/Menu.svelte index 4c9cfb9..953299e 100644 --- a/packages/ui/src/components/Menu.svelte +++ b/packages/ui/src/components/Menu.svelte @@ -1,5 +1,6 @@
-
+ void jumpToMessage(id)} + hasOlder={store.messagesBefore !== null} loading={store.loadingOlder} loadOlder={() => { if (viewport) { navigationTarget = null; viewport.scrollTop = 0; pinned = false; pullOlder(viewport); } }} /> + +
{#if store.loadingOlder} @@ -463,6 +513,13 @@
{:else} + {@const execution = store.openThread?.turns.find((turn) => turn.id === message.turnId)?.execution} + {#if message.role === 'assistant' && execution} + {@const model = store.modelsOf(execution.providerId, execution.accountId).find((model) => model.id === execution.model)} +
+ {execution.model && isNamedModel(model ?? { id: execution.model, name: execution.model }) ? model?.name ?? execution.model : store.providerOf(execution.providerId)?.name} +
+ {/if} {@const caretAt = message.state === 'streaming' ? lastTextIndex(message) : -1} {@const thought = thoughts.get(message.turnId)} {#if thought?.host === message.id}{/if} @@ -471,8 +528,8 @@ {#if part.type !== 'thinking'}
{#if part.type === 'text'} - {#if part.text.length > 0 || index === caretAt} - + {#if visibleAnswer(part.text).length > 0 || index === caretAt} + {/if} {:else if part.type === 'tool'} @@ -546,6 +603,7 @@
+ diff --git a/packages/ui/src/components/ModelPicker.svelte b/packages/ui/src/components/ModelPicker.svelte index 2b2cbf8..ed946bb 100644 --- a/packages/ui/src/components/ModelPicker.svelte +++ b/packages/ui/src/components/ModelPicker.svelte @@ -1,6 +1,6 @@ -{#if activity && (activity.goal || activity.loop || tasks.length)} -
(hover = true)} onpointerleave={() => (hover = false)}> +{#if activity} +
{#each ['goal', 'loop'] as kind (kind)} - {@const entry = kind === 'goal' ? activity.goal : activity.loop} + {@const entry = kind === 'goal' ? goal : loop} {#if entry}
- {#if kind === 'goal'}{:else}{/if} + {#if kind === 'goal'}{:else}{/if} {kind === 'goal' ? strings.activity.goal : strings.activity.loop} - + + {#if 'objective' in entry}{entry.objective}{:else}{iteration(entry.iterations, entry.maxIterations)}{/if} + {strings.activity[entry.status]} - {#if 'intervalMs' in entry}{fill(strings.activity.every, { interval: interval(entry.intervalMs) })}{/if} -
- + {#if entry.status !== 'complete'} + + {/if} {#if kind === 'goal' && entry.status !== 'complete'} - + {/if} - -
-
-
-
-

{'objective' in entry ? entry.objective : entry.prompt}

-

{fill(strings.activity.iterations, { count: String(entry.iterations) })}{#if 'intervalMs' in entry} · {fill(strings.activity.every, { interval: interval(entry.intervalMs) })}{/if}

-
+
{#if entry.error}

{entry.error}

{/if} {/if} {/each} - {#if tasks.length} - -
-
-
    - {#each tasks as task (task.id)} -
  • - {task.status === 'completed' ? '✓' : task.status === 'in_progress' ? '•' : 'â—‹'} - {task.text} -
  • - {/each} -
+ {#if tasks.length} + + {/if} +
+
+ {#if tasks.length} +
    + {#each tasks as task (task.id)} +
  • + {task.status === 'completed' ? '✓' : task.status === 'in_progress' ? '•' : 'â—‹'} + {task.text} +
  • + {/each} +
+ {/if} + {#if loop} + {#if loop.intervalMs > 0}

{fill(strings.activity.every, { interval: interval(loop.intervalMs) })}

{/if} +
    + {#each history as run (run.turnId)} +
  1. {iteration(run.iteration)}{strings.activity[run.status]}
    {#if run.summary}

    {run.summary}

    {/if}
  2. + {:else}
  3. {strings.activity.noHistory}
  4. {/each} +
+ {/if}
{/if} @@ -92,41 +109,43 @@ {/if} + + + + diff --git a/packages/ui/src/components/ThreadActivity.test.ts b/packages/ui/src/components/ThreadActivity.test.ts new file mode 100644 index 0000000..f16f80e --- /dev/null +++ b/packages/ui/src/components/ThreadActivity.test.ts @@ -0,0 +1,85 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import { flushSync, mount, unmount } from 'svelte'; +import type { Thread, ThreadActivity } from '@boite/contracts'; +import { Store } from '../lib/store.svelte'; +import ThreadActivityView from './ThreadActivity.svelte'; + +let view: ReturnType | undefined; +afterEach(() => { + if (view) unmount(view, { outro: false }); + view = undefined; + document.body.innerHTML = ''; +}); +function render(activity: ThreadActivity) { + const store = new Store(); + store.connection = 'ready'; + store.openThread = { id: 'thread', activity } as Thread; + view = mount(ThreadActivityView, { target: document.body, props: { store } }); + flushSync(); + return store; +} +const tasks = () => [ + { id: 'one', text: 'Inspect the parser', status: 'completed' as const }, + { id: 'two', text: 'Check the result', status: 'in_progress' as const } +]; +function toggle() { return document.querySelector('[data-testid=activity-tasks-toggle]')!; } + +test('hover keeps tasks collapsed and only explicit disclosure opens them', () => { + render({ goal: null, loop: null, tasks: tasks() }); + document.querySelector('section')!.dispatchEvent(new Event('pointerenter')); + flushSync(); + expect(toggle().getAttribute('aria-expanded')).toBe('false'); + expect(document.querySelector('progress')!.value).toBe(1); + expect(toggle().textContent).toContain('Check the result'); + toggle().click(); + flushSync(); + expect(toggle().getAttribute('aria-expanded')).toBe('true'); + toggle().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + flushSync(); + expect(toggle().getAttribute('aria-expanded')).toBe('false'); +}); + +test('completed goal is shown once, then dismissed tasks return when the agent adds work', () => { + const store = render({ goal: { objective: 'Fix the parser', status: 'complete', iterations: 2, error: null }, loop: null, tasks: tasks().map(task => ({ ...task, status: 'completed' })) }); + expect(document.body.textContent!.match(/Fix the parser/g)).toHaveLength(1); + expect(document.querySelector('[aria-label=Resume]')).toBeNull(); + flushSync(() => { store.openThread!.activity!.goal!.dismissed = true; store.openThread!.activity!.tasksDismissed = true; }); + expect(document.querySelector('section')!.classList.contains('hidden')).toBe(true); + flushSync(() => { store.openThread!.activity!.tasksDismissed = false; store.openThread!.activity!.tasks = tasks(); }); + expect(document.querySelector('section')!.classList.contains('hidden')).toBe(false); + expect(toggle().textContent).toContain('Check the result'); +}); + +test('loop presents iteration progress and recent results without repeating its prompt or inventing a cadence', () => { + render({ goal: null, tasks: [], loop: { prompt: 'Say pong', intervalMs: 0, maxIterations: 2, status: 'complete', iterations: 2, nextRunAt: null, error: null, history: [ + { iteration: 1, turnId: 'a', status: 'done', summary: 'First pong', startedAt: 1, finishedAt: 2 }, + { iteration: 2, turnId: 'b', status: 'done', summary: 'Second pong', startedAt: 3, finishedAt: 4 } + ] } }); + expect(document.body.textContent).toContain('Iteration 2 of 2'); + expect(document.body.textContent).not.toContain('Say pong'); + expect(document.body.textContent).not.toContain('Every'); + toggle().click(); + flushSync(); + const entries = [...document.querySelectorAll('.history li')].map(el => el.textContent); + expect(entries[0]).toContain('Second pong'); + expect(entries[1]).toContain('First pong'); +}); + +test('pause targets the owning store and disabled control prevents duplicate requests', async () => { + const store = render({ goal: { objective: 'Fix parser', status: 'active', iterations: 1, error: null }, loop: null, tasks: [] }); + const call = vi.fn().mockResolvedValue({ ...store.openThread!.activity, goal: { ...store.openThread!.activity!.goal, status: 'paused' } }); + vi.spyOn(store, 'client', 'get').mockReturnValue({ call } as unknown as Store['client']); + const pause = document.querySelector('[aria-label=Pause]')!; + pause.click(); + flushSync(); + expect(pause.disabled).toBe(true); + pause.click(); + expect(call).toHaveBeenCalledTimes(1); + await Promise.resolve(); + flushSync(); + expect(call).toHaveBeenCalledWith('threads.activity.control', { threadId: 'thread', kind: 'goal', action: 'pause' }); + await vi.waitFor(() => expect(document.querySelector('[aria-label=Resume]')).not.toBeNull()); +}); + + + diff --git a/packages/ui/src/components/ThreadHeader.svelte b/packages/ui/src/components/ThreadHeader.svelte new file mode 100644 index 0000000..190b4cc --- /dev/null +++ b/packages/ui/src/components/ThreadHeader.svelte @@ -0,0 +1,197 @@ + + +
+ {#if thread} + + {#if renaming} + void commitRename()} + placeholder={strings.thread.renamePlaceholder} + data-testid="thread-rename-input" + use:focusOnMount + /> + {:else} + + {/if} + {:else} + + {strings.sidebar.draft} + {/if} + + + + + {#if project && thread} + {project.name} + {/if} + {#if thread?.branch} + + + {thread.branch} + + {/if} + {#if thread}{/if} + + {#if thread && store.owner} + + {/if} +
+ + diff --git a/packages/ui/src/components/TitleBar.svelte b/packages/ui/src/components/TitleBar.svelte index 1256638..00a8495 100644 --- a/packages/ui/src/components/TitleBar.svelte +++ b/packages/ui/src/components/TitleBar.svelte @@ -1,11 +1,22 @@