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 fixturerestricted evaluation
');
+ return;
+ }
if (request.url === '/popup') {
response.end('Popup fixturepopup
');
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}
}
+
+
+ ))}
+
+
+
+
+ );
+}
+
+/** 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": "網址",