diff --git a/DESIGN.md b/DESIGN.md index 94ee7437..f60beaa9 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -206,6 +206,7 @@ The system uses Tailwind's 4px spacing scale. Existing values like `p-2`, `gap-2 ### Chat Pane - **Structure**: `ChatMessagesPane` owns scroll; `ChatComposer` is fixed at the bottom of the chat column. +- **Session tasks**: a collapsible, read-only task card sits above the transcript whenever the current session has a plan. Its progress stays visible when collapsed; the expanded list has a bounded, keyboard-focusable scroll area so long plans do not push the composer off screen. Tasks no longer occupy a workspace-panel tab. - **Performance**: message rows use `contain`, `content-visibility: auto`, and intrinsic sizes to reduce long-transcript layout cost. - **States**: loading, empty provider selection, older-message loaders, load-all overlay, grouped tool messages, new-message scroll button. - **Layout**: message and composer width align at `max-w-[54.25rem]`. diff --git a/docs/BROWSER-CUA-VERIFICATION.md b/docs/BROWSER-CUA-VERIFICATION.md index e706522c..13f11180 100644 --- a/docs/BROWSER-CUA-VERIFICATION.md +++ b/docs/BROWSER-CUA-VERIFICATION.md @@ -1,6 +1,30 @@ # Browser and CUA verification -Last verified: 2026-08-21 on Apple Silicon macOS. +Packaged-app baseline: 2026-08-21 on Apple Silicon macOS. Unreleased source +checks are recorded separately below. + +## Unreleased runtime fixes — 2026-09-07 + +- `browser.run` now evaluates page JavaScript with top-level `await` support. + The final expression is returned; ordinary Promise results are awaited too. + Code is evaluated only once, including when it throws a runtime SyntaxError. + Page-context execution does not expose Node.js or Puppeteer variables. +- The existing 64 KiB code, 256 KiB result and time limits remain. Temporary + remote objects are released, closing a session still interrupts pending + scripts, and the evaluator explicitly does not bypass page CSP. +- The adapter passes the run's validated project permission mode into the + browser/computer wrappers. `bypass` skips their additional origin/application + access questions after target validation, without creating session or + persistent grants. Later Ask runs therefore do not inherit bypass access. +- Ask/auto-edits prompts, Chromium's first-download consent, actual `ask` + questions, operating-system permissions and driver restrictions remain. + Cancelled tool calls fail before opening a bridge connection. +- Regression coverage includes the production adapter policy handoff, later + Ask runs, model-supplied policy spoofing, real Chromium top-level await, + Promise results, one-shot errors, CSP enforcement, size limits and interruption. + +These are source/runtime checks, not new packaged-app acceptance. The installed +and published beta.10 app is unchanged until a new versioned build is released. ## Implemented surface diff --git a/docs/V2-SESSION-HANDOFF.md b/docs/V2-SESSION-HANDOFF.md index 83f75441..0556b711 100644 --- a/docs/V2-SESSION-HANDOFF.md +++ b/docs/V2-SESSION-HANDOFF.md @@ -26,6 +26,23 @@ not changed. The older session records below are historical. ## TL;DR +- **Unreleased follow-up: tasks above the conversation.** `ChatTasksPanel` now + shows the session's live todo list above the transcript with collapse, + progress and bounded scrolling. The right-hand Tasks tab is retired; its + persisted open state closes on upgrade. The todo projection hook now lives + under `src/components/chat/hooks/`. This source change is **not included in + the published/installed beta.10** and requires a new build/release to reach + that desktop installation. +- The same unreleased UI follow-up also hides routine `Auto-approved …` + information notices in the chat projection. Raw records, permission policy, + approval controls, warnings and errors are unchanged. +- Unreleased browser/runtime fixes additionally support top-level `await` in + page scripts and pass project bypass mode to browser/computer access checks. + Bypass does not save grants or answer real questions; Ask/auto-edits, + first-download consent and OS restrictions remain. See + `BROWSER-CUA-VERIFICATION.md` for the scope and regression evidence. These + fixes likewise require a new desktop build; beta.10 has not been overwritten. + - **The v2 baseline is complete.** Server/backend/web MVP (Slices 0–4 + 6), the Tauri desktop shell (Slice 5 C1–C6), **and the C7 interactive GUI smoke** are all done and verified. Electron is removed (C9/wave1); the C8 rollback drill is diff --git a/server/GJC-LIVE-SPEC.md b/server/GJC-LIVE-SPEC.md index 1de244ce..7a095d60 100644 --- a/server/GJC-LIVE-SPEC.md +++ b/server/GJC-LIVE-SPEC.md @@ -206,7 +206,10 @@ method or frame changes; the policy travels inside existing payloads: - A call the policy covers (`bypass`, a tool on `allowAlways`, or a file mutation under `auto_edits`) is approved inside the worker and recorded once per tool per run as a `system_notice` ("Auto-approved bash (always allow)"). - Nothing crosses to the host, so the run is never reported as awaiting input. + The browser omits these routine info lines when projecting chat rows; raw + records and permission handling are unchanged. Other info notices, warnings, + errors, and actual approval requests remain visible. + No permission request crosses to the host, so the run is never reported as awaiting input. - Any other gated call is an `ask.presented` event whose message is a `permission_request` with `requestId` prefixed `sdk-permission:`, the runtime's `toolName`, its `rawInput` as `input`, and a `context` naming the @@ -216,6 +219,14 @@ method or frame changes; the policy travels inside existing payloads: persists it to the project's allow-list before forwarding the reply. - `ask` questions keep their `sdk-ask:` prefix and answer semantics. +The app-owned browser and computer tool wrappers receive the same validated +run permission mode as the SDK gate. In `bypass`, target/origin resolution still +runs, but the extra access question is omitted without adding grants to either +allow-list. Ask and auto-edits retain their existing access prompts. This does +not auto-answer `ask` questions, approve Chromium installation, or override OS +permissions or CUA driver restrictions. The mode is captured for the run; no +implicit grant survives into a later Ask run. + ## Process and terminal lifecycle - On POSIX (Linux and macOS), the application starts the Rust core as a detached diff --git a/server/e2e/browser-sidecar.e2e.ts b/server/e2e/browser-sidecar.e2e.ts index 1ee01d19..dbcdc61c 100644 --- a/server/e2e/browser-sidecar.e2e.ts +++ b/server/e2e/browser-sidecar.e2e.ts @@ -194,6 +194,11 @@ test('real Chromium sidecar shares structured actions, tabs, and screencast stat return; } response.setHeader('content-type', 'text/html; charset=utf-8'); + if (request.url === '/csp') { + response.setHeader('content-security-policy', "default-src 'self'; script-src 'self'; object-src 'none'"); + response.end('CSP fixture

restricted evaluation

'); + return; + } if (request.url === '/popup') { response.end('Popup fixture

popup

'); return; @@ -238,6 +243,23 @@ test('real Chromium sidecar shares structured actions, tabs, and screencast stat command: { action: 'run', code: '({ width: window.innerWidth, height: window.innerHeight })' }, }) as { value: { width: number; height: number } }; assert.deepEqual(resized.value, { width: 517, height: 742 }); + const awaited = await sidecar.request('browser.command', 'browser-e2e', { + command: { action: 'run', code: 'await Promise.resolve({ title: document.title, ready: true })' }, + }) as { value: { title: string; ready: boolean } }; + assert.deepEqual(awaited.value, { title: 'Gajae browser fixture', ready: true }); + const runScript = (code: string) => sidecar.request('browser.command', 'browser-e2e', { + command: { action: 'run', code }, + }); + assert.deepEqual(await runScript('Promise.resolve(42)'), { value: 42 }); + assert.deepEqual(await runScript('const value = await Promise.resolve(21); value * 2'), { value: 42 }); + assert.deepEqual(await runScript('const value = await Promise.resolve(7); value * 2'), { value: 14 }); + await assert.rejects(runScript('await Promise.reject(new Error("async fixture failure"))'), /async fixture failure/); + await assert.rejects(runScript('globalThis.failedRunCount = (globalThis.failedRunCount || 0) + 1; throw new SyntaxError("runtime fixture failure")'), /runtime fixture failure/); + assert.deepEqual(await runScript('globalThis.failedRunCount'), { value: 1 }, 'runtime errors must never replay code'); + await assert.rejects(runScript(`globalThis.oversizedRan = true; /*${'x'.repeat(64 * 1024)}*/`), /script is too large/i); + assert.deepEqual(await runScript('typeof globalThis.oversizedRan'), { value: 'undefined' }); + const large = await runScript('"x".repeat(300000)') as { value: string }; + assert.equal(large.value.length, 256 * 1024 + 1); const resizedFrame = await sidecar.waitForEvent('frame', 5_000, resizedFrameStart); assert.equal(resizedFrame.kind === 'event' && resizedFrame.payload.metadata && typeof resizedFrame.payload.metadata === 'object' ? (resizedFrame.payload.metadata as { deviceWidth?: number }).deviceWidth @@ -341,6 +363,17 @@ test('real Chromium sidecar shares structured actions, tabs, and screencast stat command: { action: 'extract', selector: '#result', format: 'text' }, }), { value: 'background preserved' }, 'closing one session preserves the other session'); await assert.rejects(sidecar.request('session.state', 'browser-e2e'), /Open the browser session first/u); + await sidecar.request('session.open', 'csp-e2e', { url: `${url}/csp`, allowDownload: false }); + assert.deepEqual(await sidecar.request('browser.command', 'csp-e2e', { + command: { action: 'run', code: 'await Promise.resolve(document.title)' }, + }), { value: 'CSP fixture' }); + await assert.rejects(sidecar.request('browser.command', 'csp-e2e', { + command: { action: 'run', code: 'eval("1 + 1")' }, + }), /unsafe-eval|Content Security Policy|Refused/i); + await assert.rejects(sidecar.request('browser.command', 'csp-e2e', { + command: { action: 'run', code: 'await new Promise(() => {})', timeoutMs: 50 }, + }), /timed out/); + assert.deepEqual(await sidecar.request('session.close', 'csp-e2e'), { closed: true }); assert.deepEqual(await sidecar.request('session.close', 'browser-e2e'), { closed: false }); const reopened = await sidecar.request('session.open', 'browser-e2e', { url, allowDownload: false }) as { activeTabId: string; tabs: unknown[] }; assert.equal(reopened.tabs.length, 1); diff --git a/server/gjc-automation-tools.test.ts b/server/gjc-automation-tools.test.ts index e7021f0b..c17f7892 100644 --- a/server/gjc-automation-tools.test.ts +++ b/server/gjc-automation-tools.test.ts @@ -412,3 +412,114 @@ test('an unrelated open failure is not turned into a download prompt', async () await bridge.close(); } }); + +test('bypass authorizes browser access for this session without an extra permission question', async () => { + const bridge = await bridgeServer((request) => request.operation === 'authorize' + ? { ok: true, result: { granted: (request.payload as Record)?.scope === 'session', origin: 'https://example.com' } } + : { ok: true, result: { opened: true } }); + let prompts = 0; + try { + const { browser } = createGjcAutomationTools('bypass-session', { + async select() { prompts += 1; return 'Deny'; }, + }, { socketPath: bridge.socketPath, token: TEST_TOKEN }, 'bypass'); + await browser!.execute('bypass-open', { action: 'open', url: 'https://example.com/page' }, undefined); + assert.equal(prompts, 0); + assert.deepEqual(bridge.requests.map((request) => request.operation), ['authorize', 'open']); + assert.ok(bridge.requests.every((request) => request.sessionId === 'bypass-session')); + assert.ok(bridge.requests.every((request) => !(request.payload as Record | undefined)?.scope)); + } finally { await bridge.close(); } +}); + +test('bypass covers computer access without creating session or persistent grants', async () => { + const bridge = await bridgeServer((request) => request.operation === 'authorize' + ? { ok: true, result: { granted: false, application: 'com.apple.TextEdit', label: 'TextEdit' } } + : { ok: true, result: { controlled: true } }); + try { + const { computer } = createGjcAutomationTools('bypass-computer', { + async select() { assert.fail('bypass must not ask again'); }, + }, { socketPath: bridge.socketPath, token: TEST_TOKEN }, 'bypass'); + await computer!.execute('click', { action: 'click', arguments: { pid: 42, x: 10, y: 20 } }, undefined); + assert.deepEqual(bridge.requests.map((request) => request.operation), ['authorize', undefined]); + assert.ok(bridge.requests.every((request) => request.sessionId === 'bypass-computer')); + assert.ok(bridge.requests.every((request) => !(request.payload as Record | undefined)?.scope)); + } finally { await bridge.close(); } +}); + +test('bypass leaves later Ask runs and other sessions ungranted', async () => { + let granted = false; + const bridge = await bridgeServer((request) => { + if ((request.payload as Record | undefined)?.scope) granted = true; + return { ok: true, result: request.operation === 'authorize' ? { granted, origin: 'https://example.com' } : { opened: true } }; + }); + let prompts = 0; + const ui = { async select() { prompts += 1; return 'Deny'; } }; + try { + const transport = { socketPath: bridge.socketPath, token: TEST_TOKEN }; + const bypass = createGjcAutomationTools('same-session', ui, transport, 'bypass'); + await bypass.browser!.execute('first', { action: 'open', url: 'https://example.com' }, undefined); + await bypass.browser!.execute('again', { action: 'act', actions: [{ verb: 'observe' }] }, undefined); + assert.equal(prompts, 0); + assert.equal(granted, false); + for (const sessionId of ['same-session', 'other-session']) { + const ask = createGjcAutomationTools(sessionId, ui, transport, 'ask'); + await assert.rejects(ask.browser!.execute('ask', { action: 'open', url: 'https://example.com' }, undefined), /was denied/); + } + assert.equal(prompts, 2); + assert.equal(granted, false); + } finally { await bridge.close(); } +}); + +test('default and auto-edits modes still ask, and tool parameters cannot enable bypass', async () => { + for (const mode of [undefined, 'ask', 'auto_edits'] as const) { + const bridge = await bridgeServer(() => ({ ok: true, result: { granted: false, origin: 'https://example.com' } })); + let prompts = 0; + try { + const { browser } = createGjcAutomationTools('ask-session', { + async select() { prompts += 1; return 'Deny'; }, + }, { socketPath: bridge.socketPath, token: TEST_TOKEN }, mode); + await assert.rejects(browser!.execute('untrusted-params', { + action: 'open', url: 'https://example.com', permissionMode: 'bypass', permissions: { mode: 'bypass' }, + }, undefined), /was denied/); + assert.equal(prompts, 1); + assert.deepEqual(bridge.requests.map((request) => request.operation), ['authorize']); + } finally { await bridge.close(); } + } +}); + +test('bypass preserves first-download consent and backend authorization failures', async () => { + const bridge = await bridgeServer((request) => request.operation === 'authorize' + ? { ok: true, result: { granted: false, origin: 'https://example.com' } } + : { ok: false, error: 'browser_download_required: Chromium is not installed.' }); + const prompts: string[] = []; + try { + const { browser } = createGjcAutomationTools('download-session', { + async select(title) { prompts.push(title); return 'Not now'; }, + }, { socketPath: bridge.socketPath, token: TEST_TOKEN }, 'bypass'); + await assert.rejects(browser!.execute('download', { action: 'open', url: 'https://example.com' }, undefined), /declined the download/); + assert.equal(prompts.length, 1); + assert.match(prompts[0]!, /needs Chromium/); + assert.ok(bridge.requests.every((request) => (request.payload as Record | undefined)?.allowDownload !== true)); + } finally { await bridge.close(); } + + const rejected = await bridgeServer(() => ({ ok: false, error: 'Computer action requires a resolvable application identity.' })); + try { + const { computer } = createGjcAutomationTools('invalid-target', { + async select() { assert.fail('must preserve the backend rejection'); }, + }, { socketPath: rejected.socketPath, token: TEST_TOKEN }, 'bypass'); + await assert.rejects(computer!.execute('bad-target', { action: 'click', arguments: { pid: 42 } }, undefined), /resolvable application/); + assert.equal(rejected.requests.length, 1); + } finally { await rejected.close(); } +}); + +test('already-cancelled bypass tools do not connect or authorize any action', async () => { + const bridge = await bridgeServer(() => ({ ok: true, result: { granted: true } })); + try { + const tools = createGjcAutomationTools('cancelled-session', { + async select() { assert.fail('cancelled calls must not ask'); }, + }, { socketPath: bridge.socketPath, token: TEST_TOKEN }, 'bypass'); + const signal = AbortSignal.abort(); + await assert.rejects(tools.browser!.execute('cancelled-browser', { action: 'open', url: 'https://example.com' }, signal), /cancelled/); + await assert.rejects(tools.computer!.execute('cancelled-computer', { action: 'click', arguments: { pid: 42 } }, signal), /cancelled/); + assert.deepEqual(bridge.requests, []); + } finally { await bridge.close(); } +}); diff --git a/server/gjc-automation-tools.ts b/server/gjc-automation-tools.ts index 4ddd45c8..e18bbcac 100644 --- a/server/gjc-automation-tools.ts +++ b/server/gjc-automation-tools.ts @@ -5,6 +5,8 @@ import type { AutomationTools } from '@gajae-code/coding-agent/sdk/session'; import type { ExtensionUIContext } from '@gajae-code/coding-agent/extensibility/extensions/types'; import * as z from 'zod/v4'; +import type { GjcPermissionMode } from './gjc-permission-policy.js'; + const browserActionSchema = z.object({ verb: z.enum(['navigate', 'back', 'forward', 'reload', 'click', 'type', 'fill', 'select', 'press', 'scroll', 'wait', 'observe', 'extract', 'screenshot']), ref: z.number().int().positive().optional(), @@ -28,7 +30,7 @@ const browserSchema = z.object({ action: z.enum(['open', 'close', 'act', 'run']), url: z.string().optional(), actions: z.array(browserActionSchema).max(25).optional(), - code: z.string().max(64 * 1024).optional(), + code: z.string().max(64 * 1024).describe('JavaScript in the current page context, not Node.js or Puppeteer. Top-level await is supported; the last expression is returned.').optional(), timeout: z.number().int().min(1).max(300_000).optional(), }); @@ -78,6 +80,7 @@ function bridgeRequest( signal?: AbortSignal, timeoutMs = 310_000, ): Promise { + if (signal?.aborted) return Promise.reject(new Error('Automation request was cancelled.')); if (!transport) return Promise.reject(new Error('App automation bridge is unavailable.')); const id = `tool-${randomUUID()}`; return new Promise((resolve, reject) => { @@ -96,6 +99,7 @@ function bridgeRequest( signal?.addEventListener('abort', abort, { once: true }); socket.setTimeout(timeoutMs, () => finish(new Error('Automation request timed out.'))); socket.on('connect', () => { + if (settled) return; socket.write(`${JSON.stringify({ ...request, id, token: transport.token })}\n`); }); socket.on('data', (chunk) => { @@ -214,6 +218,7 @@ export function createGjcAutomationTools( appSessionId: string, ui: Pick, transport?: GjcAutomationBridgeTransport, + permissionMode: GjcPermissionMode = 'ask', ): AutomationTools { const ensureBrowserAccess = async (url: string | undefined, signal?: AbortSignal): Promise => { const check = await bridgeRequest(transport, { @@ -224,6 +229,9 @@ export function createGjcAutomationTools( }, signal) as BrowserAuthorization; if (check.granted || !check.origin) return; + // The trusted run policy covers this prompt, but must not create grants + // that survive a later run switching back to Ask (even in this session). + if (permissionMode === 'bypass') return; const choice = await ui.select( `Allow the agent to use ${check.origin}?`, [ALLOW_ONCE, ALLOW_ALWAYS, DENY], @@ -317,6 +325,7 @@ export function createGjcAutomationTools( }, signal) as ComputerAuthorization; if (check.granted || !check.application) return; + if (permissionMode === 'bypass') return; const choice = await ui.select( `Allow the agent to control ${check.label ?? check.application}?`, [ALLOW_ONCE, ALLOW_ALWAYS, DENY], diff --git a/server/gjc-bun-sdk-adapter.ts b/server/gjc-bun-sdk-adapter.ts index b43111b3..0738d7a9 100644 --- a/server/gjc-bun-sdk-adapter.ts +++ b/server/gjc-bun-sdk-adapter.ts @@ -723,6 +723,7 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { config.appSessionId, askController.uiContext, this.options.automationBridge, + config.permissions?.mode, )), } : {}), }; diff --git a/server/gjc-sdk-contract.bun.test.ts b/server/gjc-sdk-contract.bun.test.ts index 6af7cccf..681ec5e7 100644 --- a/server/gjc-sdk-contract.bun.test.ts +++ b/server/gjc-sdk-contract.bun.test.ts @@ -2,11 +2,12 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { copyFile, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; +import net from 'node:net'; import { isAbsolute, join, relative } from 'node:path'; import { test } from 'node:test'; import { ACP_BUILTIN_SLASH_COMMANDS } from '@gajae-code/coding-agent/slash-commands/acp-builtins'; -import { createAgentSession, discoverAuthStorage } from '@gajae-code/coding-agent/sdk/session'; +import { createAgentSession, discoverAuthStorage, type AutomationTools } from '@gajae-code/coding-agent/sdk/session'; import { ModelRegistry } from '@gajae-code/coding-agent/config/model-registry'; import { Settings } from '@gajae-code/coding-agent/config/settings'; import { SessionManager } from '@gajae-code/coding-agent/session/session-manager'; @@ -2021,6 +2022,65 @@ test('app automation is injected through the SDK built-in automationTools contra await run; } finally { await f.close(); } }); +test('the production adapter passes bypass to automation without answering real questions', { timeout: 15_000 }, async () => { + const directory = await mkdtemp(join(tmpdir(), 'gjc-automation-mode-')); + const socketPath = join(directory, 'bridge.sock'); + const token = 'a'.repeat(64); + const requests: Array> = []; + const server = net.createServer((socket) => { + let buffer = ''; + socket.setEncoding('utf8'); + socket.on('data', (chunk) => { + buffer += chunk; + const newline = buffer.indexOf('\n'); + if (newline < 0) return; + const incoming = JSON.parse(buffer.slice(0, newline)) as Record; + requests.push(incoming); + const result = incoming.operation === 'authorize' + ? incoming.surface === 'browser' + ? { granted: false, origin: 'https://example.com' } + : { granted: false, application: 'com.apple.TextEdit', label: 'TextEdit' } + : { success: true }; + socket.end(`${JSON.stringify({ id: incoming.id, ok: incoming.token === token, result })}\n`); + }); + }); + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(socketPath, resolve); }); + const f = await fixture('contract-model', undefined, undefined, undefined, undefined, undefined, { + automationBridge: { socketPath, token }, + }); + const runId = 'automation-bypass-mode'; + const run = f.host.handle(request('session.start', runId, { + message: 'hello', options: { ...f.options, permissions: { mode: 'bypass', allowAlways: [] } }, + }, 'automation-app-session')); + let session: FakeAgentSession | undefined; + try { + session = await firstSession(f.sessions); + await session.promptStarted.promise; + const tools = f.factoryOptions[0]!.automationTools as AutomationTools; + await tools.browser!.execute('browser-bypass', { action: 'open', url: 'https://example.com' }, AbortSignal.timeout(5_000)); + await tools.computer!.execute('computer-bypass', { action: 'click', arguments: { pid: 42, x: 1, y: 1 } }, AbortSignal.timeout(5_000)); + assert.equal(methods(f.frames).filter(method => method === 'ask.presented').length, 0); + assert.deepEqual(requests.map(item => item.operation), ['authorize', 'open', 'authorize', undefined]); + assert.ok(requests.every(item => item.sessionId === 'automation-app-session')); + assert.ok(requests.every(item => !(item.payload as Record | undefined)?.scope)); + + const question = session.uiContext!.select('Choose a plan', ['A', 'B']); + void question.catch(() => {}); + await Promise.resolve(); + const message = (f.frames.at(-1)!.payload as Record).message as Record; + assert.equal(message.kind, 'permission_request'); + assert.equal(message.toolName, 'ask'); + await f.host.handle(request('ask.reply', 'answer-plan', { runId, requestId: message.requestId, decision: { allow: true, message: 'B' } }, 'automation-app-session')); + assert.equal((response(f.frames, 'answer-plan').payload as Record).ok, true); + assert.equal(await question, 'B'); + } finally { + session?.complete(); + await run; + await new Promise((resolve) => server.close(() => resolve())); + await f.close(); + await rm(directory, { recursive: true, force: true }); + } +}); test('abort closes the app automation session before reporting success', async () => { const cleanup = deferred(); const closedSessions: string[] = []; diff --git a/server/modules/automation/browser-sidecar-run.test.ts b/server/modules/automation/browser-sidecar-run.test.ts new file mode 100644 index 00000000..6ea2536d --- /dev/null +++ b/server/modules/automation/browser-sidecar-run.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { CDPSession } from 'puppeteer-core'; + +import { evaluateBrowserScript } from './browser-sidecar.js'; + +function cdpFixture(responses: Array | Error>) { + const calls: Array<{ method: string; params: Record }> = []; + const cdp = { + async send(method: string, params: Record) { + calls.push({ method, params }); + if (method === 'Runtime.releaseObjectGroup') return {}; + const response = responses.shift(); + if (response instanceof Error) throw response; + assert.ok(response, `Unexpected command: ${method}`); + return response; + }, + } as unknown as Pick; + return { cdp, calls }; +} + +test('page scripts use top-level-await evaluation without disabling CSP', async () => { + const { cdp, calls } = cdpFixture([{ result: { type: 'number', value: 42 } }]); + const code = 'const answer = await Promise.resolve(42); answer'; + assert.equal(await evaluateBrowserScript(cdp, code), 42); + assert.equal(calls[0]!.method, 'Runtime.evaluate'); + assert.equal(calls[0]!.params.expression, code); + assert.equal(calls[0]!.params.replMode, true); + assert.equal(calls[0]!.params.awaitPromise, true); + assert.equal(calls[0]!.params.allowUnsafeEvalBlockedByCSP, false); + assert.deepEqual(calls.at(-1), { method: 'Runtime.releaseObjectGroup', params: { objectGroup: calls[0]!.params.objectGroup } }); +}); + +test('ordinary Promise completions are awaited rather than serialized as empty objects', async () => { + const { cdp, calls } = cdpFixture([ + { result: { type: 'object', subtype: 'promise', objectId: 'promise-1' } }, + { result: { type: 'object', value: { done: true } } }, + ]); + assert.deepEqual(await evaluateBrowserScript(cdp, 'Promise.resolve({done:true})'), { done: true }); + assert.deepEqual(calls[1], { method: 'Runtime.awaitPromise', params: { promiseObjectId: 'promise-1', returnByValue: true } }); + assert.equal(calls.filter((call) => call.method === 'Runtime.evaluate').length, 1); +}); + +test('object results are read from their handle without rerunning the source', async () => { + const { cdp, calls } = cdpFixture([ + { result: { type: 'object', objectId: 'object-1' } }, + { result: { type: 'object', value: { count: 1 } } }, + ]); + assert.deepEqual(await evaluateBrowserScript(cdp, '({ count: ++window.count })'), { count: 1 }); + assert.equal(calls[1]!.method, 'Runtime.callFunctionOn'); + assert.equal(calls[1]!.params.objectId, 'object-1'); + assert.equal(calls[1]!.params.functionDeclaration, 'function() { return this; }'); + assert.equal(calls.filter((call) => call.method === 'Runtime.evaluate').length, 1); +}); + +test('script and promise errors propagate while their remote objects are released', async () => { + const exception = { exceptionId: 1, text: 'Uncaught', lineNumber: 0, columnNumber: 0, + exception: { type: 'object', subtype: 'error', className: 'SyntaxError', description: 'SyntaxError: script failed' } }; + for (const promise of [false, true]) { + const { cdp, calls } = cdpFixture([ + ...(promise ? [{ result: { type: 'object', subtype: 'promise', objectId: 'promise-error' } }] : []), + { result: { type: 'undefined' }, exceptionDetails: exception }, + ]); + await assert.rejects(evaluateBrowserScript(cdp, 'window.count++; throw new SyntaxError("script failed")'), /script failed/); + assert.equal(calls.filter((call) => call.method === 'Runtime.evaluate').length, 1); + assert.equal(calls.at(-1)!.method, 'Runtime.releaseObjectGroup'); + } +}); + +test('context closure is not mistaken for successful script completion', async () => { + const failure = new Error('Target closed'); + const { cdp, calls } = cdpFixture([failure]); + await assert.rejects(evaluateBrowserScript(cdp, 'await new Promise(() => {})'), error => error === failure); + assert.equal(calls.at(-1)!.method, 'Runtime.releaseObjectGroup'); +}); diff --git a/server/modules/automation/browser-sidecar.ts b/server/modules/automation/browser-sidecar.ts index 3ab88a1a..20d2de78 100644 --- a/server/modules/automation/browser-sidecar.ts +++ b/server/modules/automation/browser-sidecar.ts @@ -27,6 +27,7 @@ import puppeteer, { type Target, } from 'puppeteer-core'; import { PUPPETEER_REVISIONS } from 'puppeteer-core/internal/revisions.js'; +import { createEvaluationError, valueFromPrimitiveRemoteObject } from 'puppeteer-core/internal/cdp/utils.js'; import { DEFAULT_BROWSER_VIEWPORT, @@ -99,6 +100,41 @@ const PROFILE_ROOT = process.env.GAJAE_BROWSER_PROFILE_DIR ?? join(homedir(), '. const MAX_RUN_CODE_BYTES = 64 * 1024; const MAX_RESULT_TEXT = 256 * 1024; +/** Console-style page evaluation: preserve completion values and support top-level await. */ +export async function evaluateBrowserScript(cdp: Pick, code: string): Promise { + const objectGroup = `gajae-browser-run-${randomUUID()}`; + try { + let response = await cdp.send('Runtime.evaluate', { + expression: code, + replMode: true, + awaitPromise: true, + returnByValue: false, + objectGroup, + userGesture: true, + allowUnsafeEvalBlockedByCSP: false, + }); + // Do not retry failed code in another wrapper: it may already have changed the page. + if (response.exceptionDetails) throw createEvaluationError(response.exceptionDetails); + if (response.result.objectId) { + // REPL completion can itself be a Promise. Await the existing value, + // rather than serializing it to {} or executing the source a second time. + response = response.result.subtype === 'promise' + ? await cdp.send('Runtime.awaitPromise', { promiseObjectId: response.result.objectId, returnByValue: true }) + : await cdp.send('Runtime.callFunctionOn', { + objectId: response.result.objectId, + functionDeclaration: 'function() { return this; }', + returnByValue: true, + awaitPromise: true, + objectGroup, + }); + } + if (response.exceptionDetails) throw createEvaluationError(response.exceptionDetails); + return valueFromPrimitiveRemoteObject(response.result); + } finally { + await cdp.send('Runtime.releaseObjectGroup', { objectGroup }).catch(() => {}); + } +} + type AppBrowserLaunchOptions = Omit & { userDataDir: string }; type BrowserLaunchDependencies = { platform?: NodeJS.Platform; @@ -350,7 +386,7 @@ class BrowserRuntime { const timeoutMs = Math.min(Math.max(command.timeoutMs ?? 30_000, 1), 300_000); let timeout: ReturnType | undefined; const value = await Promise.race([ - page.evaluate((source) => (0, eval)(source), command.code), + this.cdp(tab).then((cdp) => evaluateBrowserScript(cdp, command.code)), new Promise((_, reject) => { timeout = setTimeout(() => reject(new Error('run_timeout: Browser script timed out.')), timeoutMs); }), diff --git a/src/components/chat/hooks/useChatMessages.ts b/src/components/chat/hooks/useChatMessages.ts index 8ffec18d..729d61e4 100644 --- a/src/components/chat/hooks/useChatMessages.ts +++ b/src/components/chat/hooks/useChatMessages.ts @@ -39,6 +39,9 @@ const sharedFields = (message: NormalizedMessage) => ({ const cleanUserText = (value: string) => unescapeWithMathProtection(decodeHtmlEntities(value)); const cleanAssistantText = (value: string) => formatUsageLimitText(cleanUserText(value)); +// Match only the worker's routine auto-approval line, never arbitrary prose or warnings. +const AUTO_APPROVAL_NOTICE = /^Auto-approved [^\s()]+ \((?:bypass|always allow|auto-approve edits)\)$/; + /** A call's result: inline on the row, or the `tool_result` row it pairs with. */ type AttachedResult = NonNullable | NormalizedMessage | null | undefined; @@ -163,6 +166,8 @@ function convertRow(message: NormalizedMessage, attachedResult: AttachedResult): } if (message.kind === 'system_notice') { const content = message.content?.trim(); + // Keep the raw record and permission policy intact; omit only this chat row. + if (content && (message.level ?? 'info') === 'info' && AUTO_APPROVAL_NOTICE.test(content)) return output; if (content) output.push({ type: 'assistant', content, timestamp: message.timestamp, isSystemNotice: true, noticeLevel: message.level ?? 'info', ...common }); return output; } diff --git a/src/components/workspace/hooks/useSessionTodos.test.ts b/src/components/chat/hooks/useSessionTodos.test.ts similarity index 98% rename from src/components/workspace/hooks/useSessionTodos.test.ts rename to src/components/chat/hooks/useSessionTodos.test.ts index 4f7ceb6e..d2cccde6 100644 --- a/src/components/workspace/hooks/useSessionTodos.test.ts +++ b/src/components/chat/hooks/useSessionTodos.test.ts @@ -82,6 +82,6 @@ test('the latest structured result wins over folding the ops', () => { assert.deepEqual(phases, [{ name: 'Plan', tasks: [{ content: 'only', status: 'pending', notes: [] }] }]); }); -test('a session without todo_write has no plan', () => { +test('a chat session without todo_write has no plan', () => { assert.deepEqual(sessionTodos([{ kind: 'text', role: 'user', content: 'hi' } as unknown as NormalizedMessage]), []); }); diff --git a/src/components/workspace/hooks/useSessionTodos.ts b/src/components/chat/hooks/useSessionTodos.ts similarity index 98% rename from src/components/workspace/hooks/useSessionTodos.ts rename to src/components/chat/hooks/useSessionTodos.ts index 966d8851..5a57386d 100644 --- a/src/components/workspace/hooks/useSessionTodos.ts +++ b/src/components/chat/hooks/useSessionTodos.ts @@ -113,7 +113,7 @@ export function sessionTodos(messages: readonly NormalizedMessage[]): SessionTod return sawOps ? phases : []; } -/** Latest todo state for a session, re-read when its messages change. */ +/** Latest todo state for the visible chat, re-read when its messages change. */ export function useSessionTodos(sessionStore: SessionStore, sessionId: string | undefined, enabled: boolean): SessionTodoPhase[] { const { getMessages, subscribeSession } = sessionStore; const subscribe = useCallback( diff --git a/src/components/chat/tests/chatMessagesPaneCount.test.tsx b/src/components/chat/tests/chatMessagesPaneCount.test.tsx index 652f82aa..bfde1abe 100644 --- a/src/components/chat/tests/chatMessagesPaneCount.test.tsx +++ b/src/components/chat/tests/chatMessagesPaneCount.test.tsx @@ -5,6 +5,8 @@ import { createElement, type ComponentProps } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import '../../../i18n/config'; +import type { NormalizedMessage } from '../../../stores/useSessionStore'; +import { normalizedToChatMessages } from '../hooks/useChatMessages'; import type { ChatMessage } from '../types/types'; import ChatMessagesPane from '../view/ChatMessagesPane'; @@ -65,3 +67,24 @@ test('locally hidden rows retain the earlier and all-message controls without se assert.match(html, /Get earlier messages/); assert.match(html, /Get all messages/); }); + +test('all chat densities omit auto-approval rows while showing other notices and failures', () => { + const records: NormalizedMessage[] = [ + { kind: 'system_notice', level: 'info', content: 'Auto-approved bash (bypass)' }, + { kind: 'system_notice', level: 'info', content: 'The provider reconnected.' }, + { kind: 'system_notice', level: 'warning', content: 'Permission approval is required.' }, + { kind: 'error', content: 'Tool execution denied.' }, + ].map((message, index) => ({ id: `notice-${index}`, sessionId: 'session', provider: 'gjc', timestamp: '2026-09-07T00:00:00Z', ...message } as NormalizedMessage)); + const messages = normalizedToChatMessages(records); + for (const density of ['compact', 'balanced', 'detailed'] as const) { + const html = renderCount(messages.length, records.length, { + chatMessages: messages, visibleMessages: messages, visibleMessageCount: messages.length, + hasMoreMessages: false, allMessagesLoaded: true, showLoadAllOverlay: false, density, + }); + assert.doesNotMatch(html, /Auto-approved/); + assert.match(html, /The provider reconnected\./); + assert.match(html, /Permission approval is required\./); + assert.match(html, /Tool execution denied\./); + assert.equal((html.match(/role="note"/g) ?? []).length, 3); + } +}); diff --git a/src/components/chat/tests/useChatMessages.test.ts b/src/components/chat/tests/useChatMessages.test.ts index dafbddba..9f77e304 100644 --- a/src/components/chat/tests/useChatMessages.test.ts +++ b/src/components/chat/tests/useChatMessages.test.ts @@ -4,6 +4,7 @@ import test from 'node:test'; import type { NormalizedMessage } from '../../../stores/useSessionStore'; import { normalizedToChatMessages } from '../hooks/useChatMessages'; import { isToolCallRunning } from '../utils/toolActivity'; +import { buildPaneList, isTurnWorkBlockItem } from '../utils/turnWork'; /* * The conversion is what the transcript renders from, and the pane's rows @@ -88,3 +89,85 @@ test('partial tool results preserve their running state through chat conversion' if (toolName === 'Task') assert.equal(finished.subagentState?.isComplete, true); } }); + +test('routine auto-approval info notices are omitted for every automatic policy', () => { + for (const reason of ['bypass', 'always allow', 'auto-approve edits']) { + for (const tool of ['bash', 'edit', 'eval', 'mcp__files__read', 'browser.open']) { + for (const level of [undefined, 'info'] as const) { + const notice = row({ kind: 'system_notice', level, content: ` Auto-approved ${tool} (${reason})\n` }); + assert.deepEqual(normalizedToChatMessages([notice]), [], `${tool}: ${reason}: ${level}`); + } + } + } +}); + +test('hiding a notice does not delete raw records or disturb projection identity', () => { + const notice = Object.freeze(row({ kind: 'system_notice', level: 'info', content: 'Auto-approved bash (bypass)' })); + const user = row({ role: 'user', content: 'Run the check' }); + const answer = row({ role: 'assistant', content: 'The check passed' }); + const transcript = [user, notice, answer]; + const first = normalizedToChatMessages(transcript); + const again = normalizedToChatMessages(transcript); + assert.deepEqual(first.map((message) => message.content), ['Run the check', 'The check passed']); + assert.equal(transcript.length, 3); + assert.equal(transcript[1], notice); + assert.equal(notice.content, 'Auto-approved bash (bypass)'); + assert.equal(again[0], first[0]); + assert.equal(again[1], first[1]); +}); + +test('warning and error notices remain visible even when they use the same words', () => { + for (const level of ['warning', 'error'] as const) { + const notice = row({ kind: 'system_notice', level, content: 'Auto-approved bash (bypass)' }); + const [message] = normalizedToChatMessages([notice]); + assert.equal(message.content, notice.content); + assert.equal(message.isSystemNotice, true); + assert.equal(message.noticeLevel, level); + } +}); + +test('user text, assistant text, streaming text and tool output are never matched as notices', () => { + const content = 'Auto-approved bash (bypass)'; + const rows = [ + row({ role: 'user', content }), + row({ role: 'assistant', content }), + row({ kind: 'stream_delta', content }), + row({ kind: 'error', content }), + row({ kind: 'tool_result', content }), + row({ kind: 'interactive_prompt', content }), + ]; + const messages = normalizedToChatMessages(rows); + assert.equal(messages.length, rows.length); + assert.ok(messages.every((message) => message.content === content)); + assert.equal(messages[3].type, 'error'); + assert.equal(messages[5].isInteractivePrompt, true); +}); + +test('other info notices and mixed or unfamiliar notice text remain visible', () => { + for (const content of [ + 'Permission approval is required.', + 'The provider reconnected.', + 'Auto-approved bash (bypass)\nReview the next permission request.', + 'Auto-approved bash (bypass) — additional context', + 'Auto-approved bash (unknown policy)', + 'Auto-approved (bypass)', + 'Quoted: Auto-approved bash (bypass)', + ]) { + const [message] = normalizedToChatMessages([row({ kind: 'system_notice', level: 'info', content })]); + assert.equal(message.content, content); + assert.equal(message.isSystemNotice, true); + } +}); + +test('an omitted auto-approval notice does not split consecutive tool work into empty rows', () => { + const messages = normalizedToChatMessages([ + row({ role: 'user', content: 'Check it' }), + row({ kind: 'tool_use', toolId: 'read', toolName: 'read', toolInput: { path: 'a.ts' } }), + row({ kind: 'system_notice', level: 'info', content: 'Auto-approved bash (bypass)' }), + row({ kind: 'tool_use', toolId: 'bash', toolName: 'bash', toolInput: { command: 'npm test' } }), + row({ role: 'assistant', content: 'Done' }), + ]); + const blocks = buildPaneList(messages, 'balanced').filter(isTurnWorkBlockItem); + assert.equal(blocks.length, 1); + assert.deepEqual(blocks[0].messages.map((message) => message.toolName), ['read', 'bash']); +}); diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 18e8a630..f4f4d8fd 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -24,6 +24,7 @@ import { useGoalControls } from '../hooks/useGoalControls'; import GoalControls from './GoalControls'; import ChatComposer from './ChatComposer'; import ChatMessagesPane from './ChatMessagesPane'; +import ChatTasksPanel from './ChatTasksPanel'; import CommandResultModal from './CommandResultModal'; import type { ReasoningEffort } from './reasoningEffort'; @@ -318,6 +319,7 @@ function ChatInterface({
{!historicalSession && (session.currentSessionId || selectedSession?.id) && } + {showLanding ? (
diff --git a/src/components/chat/view/ChatTasksPanel.dom.bun.test.tsx b/src/components/chat/view/ChatTasksPanel.dom.bun.test.tsx new file mode 100644 index 00000000..51639440 --- /dev/null +++ b/src/components/chat/view/ChatTasksPanel.dom.bun.test.tsx @@ -0,0 +1,185 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { afterEach, test } from 'node:test'; + +import { act, cleanup, fireEvent, render, within } from '@testing-library/react'; +import { createInstance } from 'i18next'; +import { I18nextProvider } from 'react-i18next'; + +import enCommon from '../../../i18n/locales/en/common.json'; +import koCommon from '../../../i18n/locales/ko/common.json'; +import type { NormalizedMessage, SessionStore } from '../../../stores/useSessionStore'; +import type { SessionTodoPhase } from '../hooks/useSessionTodos'; + +import ChatTasksPanel from './ChatTasksPanel'; + +afterEach(cleanup); + +const plan: SessionTodoPhase[] = [{ + name: 'Implementation', + tasks: [ + { content: 'Read the existing layout', status: 'completed', notes: [] }, + { content: 'Move tasks above the conversation', status: 'in_progress', notes: ['Keep chat scroll independent.'] }, + { content: 'Verify a narrow screen', status: 'pending', notes: [] }, + { content: 'Keep a duplicate right-hand list', status: 'abandoned', notes: [] }, + ], +}]; + +function createStore() { + const messages = new Map(); + const listeners = new Map void>>(); + const empty: NormalizedMessage[] = []; + let sequence = 0; + const store = { + getMessages: (id: string) => messages.get(id) ?? empty, + subscribeSession: (id: string, listener: () => void) => { + const subscriptions = listeners.get(id) ?? new Set(); + listeners.set(id, subscriptions); + subscriptions.add(listener); + return () => { subscriptions.delete(listener); }; + }, + } satisfies Pick; + return { + store: store as SessionStore, + listeners: (id: string) => listeners.get(id)?.size ?? 0, + publish: (id: string, phases: SessionTodoPhase[]) => { + sequence += 1; + messages.set(id, [{ + id: `todo-${sequence}`, sessionId: id, provider: 'gjc', kind: 'tool_use', + timestamp: '2026-09-07T00:00:00Z', toolId: `todo-${sequence}`, toolName: 'todo_write', + toolInput: { ops: [] }, toolResult: { content: 'Updated', isError: false, toolUseResult: { phases } }, + } as unknown as NormalizedMessage]); + listeners.get(id)?.forEach((listener) => listener()); + }, + }; +} + +async function setup(lng = 'en') { + const i18n = createInstance(); + await i18n.init({ lng, fallbackLng: 'en', resources: { en: { translation: enCommon }, ko: { translation: koCommon } }, interpolation: { escapeValue: false } }); + const state = createStore(); + const ui = (sessionId?: string) => ; + return { ...state, ui, i18n }; +} + +test('sessions without tasks leave no empty card or reserved chat height', async () => { + const state = await setup(); + state.publish('a', plan); + const view = render(state.ui()); + assert.equal(view.container.innerHTML, ''); + assert.equal(state.listeners('a'), 0); + view.rerender(state.ui('empty')); + assert.equal(view.container.innerHTML, ''); + act(() => state.publish('empty', [{ name: 'Empty phase', tasks: [] }])); + assert.equal(view.container.innerHTML, ''); +}); + +test('tasks start expanded with progress, phase, notes and non-color status labels', async () => { + const state = await setup(); + state.publish('a', plan); + const view = render(state.ui('a')); + const button = view.getByRole('button', { name: 'Tasks' }); + assert.equal(button.tagName, 'BUTTON'); + assert.equal(button.getAttribute('type'), 'button'); + assert.equal(button.tabIndex, 0); + assert.equal(button.getAttribute('aria-expanded'), 'true'); + assert.ok(document.getElementById(button.getAttribute('aria-controls')!)); + assert.equal(view.getByRole('status').textContent, '1 of 4 done'); + assert.equal(view.getByRole('status').getAttribute('aria-live'), 'polite'); + assert.ok(view.getByRole('heading', { name: 'Implementation' })); + const rows = view.getAllByRole('listitem'); + assert.equal(rows.length, 4); + assert.match(rows[0].textContent!, /Completed:.*Read the existing layout/); + assert.match(rows[1].textContent!, /In progress:.*Move tasks above the conversation/); + assert.match(rows[2].textContent!, /Pending:.*Verify a narrow screen/); + assert.match(rows[3].textContent!, /Abandoned:.*Keep a duplicate right-hand list/); + assert.ok(within(rows[1]).getByText('Keep chat scroll independent.')); + assert.match(view.getByText('Read the existing layout').className, /line-through/); + assert.match(view.getByRole('group', { name: 'Tasks' }).className, /overflow-y-auto.*overscroll-contain/); + assert.equal(view.getByRole('group', { name: 'Tasks' }).tabIndex, 0); +}); + +test('collapsed cards keep current work and progress live without reopening or losing focus', async () => { + const state = await setup(); + state.publish('a', plan); + const view = render(state.ui('a')); + const button = view.getByRole('button', { name: 'Tasks' }); + button.focus(); + fireEvent.click(button); + assert.equal(button.getAttribute('aria-expanded'), 'false'); + assert.equal(view.queryByRole('list'), null); + assert.ok(view.getByText('Move tasks above the conversation')); + assert.equal(view.queryByText('Keep chat scroll independent.'), null); + + const next: SessionTodoPhase[] = [{ name: 'Verification', tasks: [ + { content: 'Move tasks above the conversation', status: 'completed', notes: [] }, + { content: 'Verify a narrow screen', status: 'in_progress', notes: ['Keyboard focus still works.'] }, + ] }]; + act(() => state.publish('a', next)); + assert.equal(view.getByRole('button', { name: 'Tasks' }), button); + assert.equal(button.getAttribute('aria-expanded'), 'false'); + assert.equal(document.activeElement, button); + assert.equal(view.getByRole('status').textContent, '1 of 2 done'); + assert.ok(view.getByText('Verify a narrow screen')); + assert.equal(view.queryByText('Move tasks above the conversation'), null); + fireEvent.click(button); + assert.ok(view.getByText('Keyboard focus still works.')); +}); + +test('session changes reset disclosure state and unsubscribe from the previous plan', async () => { + const state = await setup(); + state.publish('a', plan); + state.publish('b', [{ name: 'Other session', tasks: [{ content: 'Only session B', status: 'pending', notes: [] }] }]); + const view = render(state.ui('a')); + fireEvent.click(view.getByRole('button', { name: 'Tasks' })); + view.rerender(state.ui('b')); + assert.equal(view.getByRole('button', { name: 'Tasks' }).getAttribute('aria-expanded'), 'true'); + assert.ok(view.getByText('Only session B')); + assert.equal(view.queryByText('Move tasks above the conversation'), null); + assert.equal(state.listeners('a'), 0); + assert.equal(state.listeners('b'), 1); + act(() => state.publish('a', [])); + assert.ok(view.getByText('Only session B')); + view.rerender(state.ui()); + assert.equal(view.container.innerHTML, ''); + assert.equal(state.listeners('b'), 0); +}); + +test('a live first plan appears automatically and an authoritative clear removes the card', async () => { + const state = await setup(); + const view = render(state.ui('a')); + assert.equal(view.container.innerHTML, ''); + act(() => state.publish('a', plan)); + assert.equal(view.getByRole('button', { name: 'Tasks' }).getAttribute('aria-expanded'), 'true'); + act(() => state.publish('a', [])); + assert.equal(view.container.innerHTML, ''); +}); + +test('all-completed plans remain inspectable and do not invent a current task', async () => { + const state = await setup(); + state.publish('a', [{ name: '', tasks: [{ content: 'Finished work', status: 'completed', notes: [] }] }]); + const view = render(state.ui('a')); + assert.ok(view.getByText('Finished work')); + fireEvent.click(view.getByRole('button', { name: 'Tasks' })); + assert.equal(view.getByRole('status').textContent, '1 of 1 done'); + assert.equal(view.queryByText('Finished work'), null); +}); + +test('Korean task labels and long content remain intact', async () => { + const state = await setup('ko'); + const longTask = `한글경로/${'아주긴파일이름'.repeat(50)}`; + state.publish('a', [{ name: '구현', tasks: [{ content: longTask, status: 'in_progress', notes: ['검증 결과\n다음 단계'] }] }]); + const view = render(state.ui('a')); + assert.ok(view.getByRole('button', { name: '할 일' })); + assert.equal(view.getByRole('status').textContent, '1개 중 0개 완료'); + assert.match(view.getByRole('listitem').textContent!, /진행 중:/); + assert.match(view.getByText(longTask).className, /wrap-anywhere/); + assert.ok(view.getByText('검증 결과 다음 단계')); +}); + +test('the real chat mounts tasks before the transcript and uses the selected session first', () => { + const source = readFileSync(new URL('./ChatInterface.tsx', import.meta.url), 'utf8'); + assert.ok(source.indexOf(' 0); + assert.ok(source.indexOf(' = { + pending: { Icon: CircleDashed, className: 'text-muted-foreground/60' }, + in_progress: { Icon: LoaderCircle, className: 'animate-spin text-primary' }, + completed: { Icon: CircleCheck, className: 'text-muted-foreground' }, + abandoned: { Icon: CircleX, className: 'text-muted-foreground/50' }, +}; + +function TaskListDisclosure({ phases }: { phases: SessionTodoPhase[] }) { + const { t } = useTranslation(); + const [open, setOpen] = useState(true); + const contentId = useId(); + const label = t('workspace.tabs.tasks'); + const tasks = phases.flatMap((phase) => phase.tasks); + const done = tasks.filter((task) => task.status === 'completed').length; + const currentTask = tasks.find((task) => task.status === 'in_progress') ?? tasks.find((task) => task.status === 'pending'); + + return ( +
+ + + + + {label} + {!open && currentTask && {currentTask.content}} + + + {t('workspace.tasks.progress', { done, total: tasks.length })} + + + + +
+ {phases.filter((phase) => phase.tasks.length > 0).map((phase, phaseIndex) => ( +
+ {phase.name &&

{phase.name}

} +
    + {phase.tasks.map((task, taskIndex) => { + const { Icon, className } = STATUS_ICON[task.status]; + return ( +
  • +
    + + {t(`workspace.tasks.status.${task.status}`)}: + + {task.content} + +
    + {task.status === 'in_progress' && task.notes.map((note, noteIndex) => ( +

    {note}

    + ))} +
  • + ); + })} +
+
+ ))} +
+
+
+
+ ); +} + +/** The runtime's read-only task list stays above the transcript, even with the workspace rail closed. */ +export default function ChatTasksPanel({ sessionId, sessionStore }: { sessionId?: string; sessionStore: SessionStore }) { + const phases = useSessionTodos(sessionStore, sessionId, true); + if (!sessionId || !phases.some((phase) => phase.tasks.length > 0)) return null; + // A different conversation starts with its own expanded disclosure state. + return ; +} diff --git a/src/components/workspace/view/WorkspacePanel.test.tsx b/src/components/workspace/view/WorkspacePanel.test.tsx index 8545a75d..dcf20012 100644 --- a/src/components/workspace/view/WorkspacePanel.test.tsx +++ b/src/components/workspace/view/WorkspacePanel.test.tsx @@ -59,6 +59,8 @@ test('the panel offers only the surfaces the app still owns', () => { assert.doesNotMatch(html, /workspace-tab-files/); assert.doesNotMatch(html, /workspace-tab-editor/); + assert.doesNotMatch(html, /workspace-tab-tasks/); + assert.doesNotMatch(render({ isMobile: true }), /workspace-tab-tasks/); }); test('only the selected tab is reachable with Tab, the rest with arrow keys', () => { diff --git a/src/components/workspace/view/WorkspacePanel.tsx b/src/components/workspace/view/WorkspacePanel.tsx index 16bf79cd..55e8e433 100644 --- a/src/components/workspace/view/WorkspacePanel.tsx +++ b/src/components/workspace/view/WorkspacePanel.tsx @@ -1,6 +1,6 @@ import { Suspense, lazy, useCallback, useRef } from 'react'; import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, MutableRefObject } from 'react'; -import { Activity, FileDiff, Globe2, ListTodo, PanelRightClose, X, type LucideIcon } from 'lucide-react'; +import { Activity, FileDiff, Globe2, PanelRightClose, X, type LucideIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { PillBar, Pill } from '../../../shared/view/ui'; @@ -16,13 +16,11 @@ import { const WorkspaceStatusTab = lazy(() => import('./WorkspaceStatusTab')); const WorkspaceChangesTab = lazy(() => import('./WorkspaceChangesTab')); -const WorkspaceTasksTab = lazy(() => import('./WorkspaceTasksTab')); const BrowserPanel = lazy(() => import('./BrowserPanel')); const TAB_ICONS: Record = { status: Activity, changes: FileDiff, - tasks: ListTodo, browser: Globe2, }; @@ -181,13 +179,6 @@ export default function WorkspacePanel({ active={tab === 'changes'} /> )} - {tab === 'tasks' && ( - - )} {tab === 'browser' && ( = { - pending: { Icon: CircleDashed, className: 'text-muted-foreground/60' }, - in_progress: { Icon: LoaderCircle, className: 'animate-spin text-primary' }, - completed: { Icon: Circle, className: 'text-muted-foreground' }, - abandoned: { Icon: CircleX, className: 'text-muted-foreground/50' }, -}; - -type WorkspaceTasksTabProps = { - readonly sessionId?: string; - readonly sessionStore: SessionStore; - readonly active: boolean; -}; - -/** - * The session's todo list, live: the runtime writes it through todo_write and - * the chat shows only each collapsed call, so this tab is where the current - * state is actually visible while a turn plans and works. - */ -export default function WorkspaceTasksTab({ sessionId, sessionStore, active }: WorkspaceTasksTabProps) { - const { t } = useTranslation(); - const phases = useSessionTodos(sessionStore, sessionId, active); - const total = phases.reduce((count, phase) => count + phase.tasks.length, 0); - const done = phases.reduce((count, phase) => count + phase.tasks.filter((task) => task.status === 'completed').length, 0); - - if (!sessionId || phases.length === 0) { - return ( -
- -

{t('workspace.tasks.empty')}

-

{t('workspace.tasks.emptyHint')}

-
- ); - } - - return ( - -
-

- {t('workspace.tasks.progress', { done, total })} -

- {phases.map((phase) => ( -
- {phase.name && ( -

{phase.name}

- )} -
    - {phase.tasks.map((task) => { - const { Icon, className } = STATUS_ICON[task.status]; - return ( -
  • -
    - - - {task.content} - -
    - {task.status === 'in_progress' && task.notes.map((note) => ( -

    {note}

    - ))} -
  • - ); - })} -
-
- ))} -
-
- ); -} diff --git a/src/components/workspace/workspacePanelState.test.ts b/src/components/workspace/workspacePanelState.test.ts index 720426f1..86edf219 100644 --- a/src/components/workspace/workspacePanelState.test.ts +++ b/src/components/workspace/workspacePanelState.test.ts @@ -55,15 +55,15 @@ test('only the declared tabs are accepted', () => { test('a tab retired with its panel degrades to the default instead of sticking', () => { assert.equal(normalizeWorkspaceTab('files'), null); assert.equal(normalizeWorkspaceTab('editor'), null); + assert.equal(normalizeWorkspaceTab('tasks'), null); }); test('tablist keys move across tabs and wrap in both directions', () => { assert.equal(workspaceTabForKey('status', 'ArrowRight'), 'changes'); - assert.equal(workspaceTabForKey('changes', 'ArrowRight'), 'tasks'); - assert.equal(workspaceTabForKey('tasks', 'ArrowRight'), 'browser'); + assert.equal(workspaceTabForKey('changes', 'ArrowRight'), 'browser'); assert.equal(workspaceTabForKey('browser', 'ArrowRight'), 'status'); assert.equal(workspaceTabForKey('status', 'ArrowLeft'), 'browser'); - assert.equal(workspaceTabForKey('browser', 'ArrowLeft'), 'tasks'); + assert.equal(workspaceTabForKey('browser', 'ArrowLeft'), 'changes'); assert.equal(workspaceTabForKey('browser', 'Home'), 'status'); assert.equal(workspaceTabForKey('status', 'End'), 'browser'); }); @@ -101,6 +101,16 @@ test('a state persisted against a retired tab reopens on the default tab', () => }); }); +test('the retired Tasks tab moves out of the rail without losing the saved width', () => { + const storage = createStorage({ + [WORKSPACE_PANEL_STORAGE_KEY]: JSON.stringify({ open: true, tab: 'tasks', width: 420 }), + }); + const state = readWorkspacePanelState(storage); + assert.deepEqual(state, { open: false, tab: 'status', width: 420 }); + writeWorkspacePanelState(storage, state); + assert.deepEqual(readWorkspacePanelState(storage), state); +}); + test('a corrupt or foreign payload degrades to the default instead of rendering garbage', () => { assert.deepEqual(readWorkspacePanelState(createStorage({ [WORKSPACE_PANEL_STORAGE_KEY]: 'not json' })), DEFAULT_WORKSPACE_PANEL_STATE); assert.deepEqual(readWorkspacePanelState(createStorage({ [WORKSPACE_PANEL_STORAGE_KEY]: '"a string"' })), DEFAULT_WORKSPACE_PANEL_STATE); diff --git a/src/components/workspace/workspacePanelState.ts b/src/components/workspace/workspacePanelState.ts index a4b8a6c3..99322149 100644 --- a/src/components/workspace/workspacePanelState.ts +++ b/src/components/workspace/workspacePanelState.ts @@ -8,7 +8,7 @@ * panel component only has to render what these functions return. */ -export const WORKSPACE_TABS = ['status', 'changes', 'tasks', 'browser'] as const; +export const WORKSPACE_TABS = ['status', 'changes', 'browser'] as const; export type WorkspaceTab = (typeof WORKSPACE_TABS)[number]; @@ -117,7 +117,8 @@ export function readWorkspacePanelState(storage: WorkspaceStorage | null): Works const width = typeof record.width === 'number' ? record.width : DEFAULT_WORKSPACE_PANEL_WIDTH; return { - open: record.open === true, + // Tasks moved into the chat; do not open an unrelated rail on upgrade. + open: record.tab !== 'tasks' && record.open === true, tab: normalizeWorkspaceTab(record.tab) ?? DEFAULT_WORKSPACE_PANEL_STATE.tab, width: clampWorkspacePanelWidth(width), }; diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 95bf3a05..61baec35 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -116,7 +116,13 @@ "tasks": { "empty": "Noch nichts geplant", "emptyHint": "Wenn der Agent in dieser Sitzung eine Aufgabenliste führt, erscheint sie hier.", - "progress": "{{done}} von {{total}} erledigt" + "progress": "{{done}} von {{total}} erledigt", + "status": { + "pending": "Ausstehend", + "in_progress": "In Bearbeitung", + "completed": "Abgeschlossen", + "abandoned": "Verworfen" + } }, "browser": { "address": "Webadresse", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 0704732e..7cd6279e 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -116,7 +116,13 @@ "tasks": { "empty": "Nothing planned yet", "emptyHint": "When the agent keeps a todo list in this session, it shows here.", - "progress": "{{done}} of {{total}} done" + "progress": "{{done}} of {{total}} done", + "status": { + "pending": "Pending", + "in_progress": "In progress", + "completed": "Completed", + "abandoned": "Abandoned" + } }, "browser": { "address": "Web address", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index af82a763..61c1d521 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -116,7 +116,13 @@ "tasks": { "empty": "Rien de prévu pour l’instant", "emptyHint": "Quand l’agent tient une liste de tâches dans cette session, elle s’affiche ici.", - "progress": "{{done}} sur {{total}} terminées" + "progress": "{{done}} sur {{total}} terminées", + "status": { + "pending": "En attente", + "in_progress": "En cours", + "completed": "Terminée", + "abandoned": "Abandonnée" + } }, "browser": { "address": "Adresse Internet", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 87d37ff3..f8594b9e 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -116,7 +116,13 @@ "tasks": { "empty": "Ancora niente in programma", "emptyHint": "Quando l’agente tiene un elenco di attività in questa sessione, appare qui.", - "progress": "{{done}} di {{total}} completate" + "progress": "{{done}} di {{total}} completate", + "status": { + "pending": "In attesa", + "in_progress": "In corso", + "completed": "Completata", + "abandoned": "Abbandonata" + } }, "browser": { "address": "Indirizzo web", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 8b418875..3e6c6fcc 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -114,7 +114,13 @@ "tasks": { "empty": "まだ計画はありません", "emptyHint": "このセッションでエージェントが ToDo リストを管理すると、ここに表示されます。", - "progress": "{{total}} 件中 {{done}} 件完了" + "progress": "{{total}} 件中 {{done}} 件完了", + "status": { + "pending": "未着手", + "in_progress": "進行中", + "completed": "完了", + "abandoned": "取り下げ" + } }, "browser": { "address": "Web アドレス。", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 766c6028..389fd239 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -114,7 +114,13 @@ "tasks": { "empty": "아직 계획된 것이 없습니다", "emptyHint": "이 세션에서 에이전트가 할 일 목록을 관리하면 여기에 표시됩니다.", - "progress": "{{total}}개 중 {{done}}개 완료" + "progress": "{{total}}개 중 {{done}}개 완료", + "status": { + "pending": "대기", + "in_progress": "진행 중", + "completed": "완료", + "abandoned": "제외됨" + } }, "browser": { "address": "웹 주소", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 53c670a6..23b6d247 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -122,7 +122,13 @@ "tasks": { "empty": "Пока ничего не запланировано", "emptyHint": "Когда агент ведёт список задач в этой сессии, он появляется здесь.", - "progress": "Выполнено {{done}} из {{total}}" + "progress": "Выполнено {{done}} из {{total}}", + "status": { + "pending": "Ожидает", + "in_progress": "В работе", + "completed": "Завершено", + "abandoned": "Отменено" + } }, "browser": { "address": "Веб-адрес", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 0668ffe7..2858bfbd 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -116,7 +116,13 @@ "tasks": { "empty": "Henüz planlanan bir şey yok", "emptyHint": "Aracı bu oturumda bir yapılacaklar listesi tutarsa burada görünür.", - "progress": "{{total}} görevden {{done}} tanesi tamam" + "progress": "{{total}} görevden {{done}} tanesi tamam", + "status": { + "pending": "Bekliyor", + "in_progress": "Devam ediyor", + "completed": "Tamamlandı", + "abandoned": "Vazgeçildi" + } }, "browser": { "address": "Web adresi", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index ca540c85..43984efc 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -114,7 +114,13 @@ "tasks": { "empty": "暂无计划", "emptyHint": "当代理在此会话中维护待办列表时,会显示在这里。", - "progress": "已完成 {{done}} / {{total}}" + "progress": "已完成 {{done}} / {{total}}", + "status": { + "pending": "待处理", + "in_progress": "进行中", + "completed": "已完成", + "abandoned": "已放弃" + } }, "browser": { "address": "网址", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 9833ef6e..9ef99aa5 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -114,7 +114,13 @@ "tasks": { "empty": "尚無計畫", "emptyHint": "當代理人在此工作階段維護待辦清單時,會顯示在這裡。", - "progress": "已完成 {{done}} / {{total}}" + "progress": "已完成 {{done}} / {{total}}", + "status": { + "pending": "待處理", + "in_progress": "進行中", + "completed": "已完成", + "abandoned": "已放棄" + } }, "browser": { "address": "網址",