Compare commits

..

5 Commits

Author SHA1 Message Date
7098014261 Merge pane-header-shared: v2.7.7 shared pane-header cluster + chat-resolve WorkspaceState fix 2026-06-01 14:29:00 +00:00
c56d169ef9 feat: shared PaneHeaderActions + chat-resolve WorkspaceState fix (v2.7.7)
In-flight workspace UX work.

- Extract a shared PaneHeaderActions cluster (+/Split/Reopen/History/Close)
  used by ChatTabBar + the Workspace coder/terminal pane headers, replacing the
  divergent per-header copies; SessionLandingPage history + useWorkspacePanes
  tweaks.
- Fix coder-side correctness bug: resolveChatId read sessions.workspace_panes as
  a bare WorkspacePane[] but v2.6.5 widened it to a WorkspaceState envelope, so
  it mis-read panes and clobbered tabNumbers/nextTabNumber/closedPaneStack on
  every pane-chat write. New normalizeWorkspaceState handles either shape and
  preserves the envelope (+ regression test).
- CLAUDE.md doc-sync (coder vitest suite, deploy-by-surface, dual-remote push,
  in-flight-web-WIP staging, release-branch naming).

Web tsc + coder build + coder tests green. Builds on v2.7.6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:28:49 +00:00
b7fb254e5d Merge agent-status-dot: v2.7.6 normalized external-agent status (scoped #10) 2026-06-01 14:04:26 +00:00
59cf082e06 feat: normalized external-agent status (#10 scoped) (v2.7.6)
Scoped half of boocode_code_review_v2 §1 #10 — publish the agent status
BooCoder already observes (the config-injection notify-hook is the documented
follow-on, clean-room from superset ELv2).

- agent_status_updated WS frame (working|blocked|idle|error), server+web parity.
- Published from the dispatcher's turn boundaries (warm-acp/opencode/sdk/pty:
  working at start, idle/error at end) + the permission flow (blocked/working).
  Best-effort, never breaks a turn.
- Clean-room normalizeAgentEvent helper (superset's vendor-event -> Start/blocked
  /Stop collapse, event names as facts) + 25 tests — reused by the follow-on.
- AgentComposerBar status dot (distinct from the WS-liveness dot), tracked per
  (chat,agent) by a useAgentStatus map in CoderPane.

Built by 2 parallel agents vs a pinned frame contract. Server 545 + coder 294
tests passing (25 new); web tsc + builds clean; ws-frames parity green. Clears
the actionable review backlog (#1/#3/#4/#6-#12). Builds on v2.7.5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:04:04 +00:00
6fc3175730 Merge claude-sdk-backend: v2.7.5 Claude SDK backend + clean-room PostgresSessionStore 2026-06-01 13:38:05 +00:00
22 changed files with 1092 additions and 230 deletions

View File

@@ -2,6 +2,14 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
## v2.7.7-pane-header-actions — 2026-06-01
In-flight workspace UX work, committed alongside the v2.7 review batches. Extracts a shared `PaneHeaderActions` cluster (the +/Split/Reopen-closed-pane/Session-history/Close controls) used across the `ChatTabBar` and the desktop coder + terminal pane headers in `Workspace`, replacing the divergent per-header copies, with `SessionLandingPage` history enhancements and `useWorkspacePanes` tweaks. Also fixes a coder-side correctness bug: `resolveChatId` (`apps/coder/src/routes/chat-resolve.ts`) still read `sessions.workspace_panes` as a bare `WorkspacePane[]`, but `v2.6.5-panes-tabs-composer` widened it to a `WorkspaceState` envelope — so it mis-read the panes and, worse, clobbered `tabNumbers`/`nextTabNumber`/`closedPaneStack` back to a bare array on every pane-chat write; a new `normalizeWorkspaceState` accepts either shape and preserves the envelope (with a regression test). Plus a CLAUDE.md doc-sync (apps/coder vitest suite, deploy-by-surface, dual-remote push, in-flight-web-WIP staging, release-branch naming). Web tsc + coder build + coder tests green. Builds on `v2.7.6-agent-status-normalize`.
## v2.7.6-agent-status-normalize — 2026-06-01
The scoped half of `boocode_code_review_v2.md` §1 #10 — normalized external-agent status, surfaced from BooCoder's own dispatch observation (the heavier config-injection notify-hook, clean-room from superset's ELv2 `agent-setup`, is documented as the follow-on). The review's premise ("PTY agents have no status") had partly aged out — warm-ACP/opencode/SDK already carry working/done — so the real gap was that BooCoder never *published* a normalized per-`(chat,agent)` status (blocked-on-permission was invisible; crash/idle weren't pushed). Adds an `agent_status_updated` WS frame (`working|blocked|idle|error`, server+web parity) published from the dispatcher's turn boundaries across all four external paths (warm-acp/opencode/sdk/pty — `working` at start, `idle`/`error` at end) and the permission flow (`blocked` on request, `working` on resolve), best-effort so it never breaks a turn. A clean-room `normalizeAgentEvent` helper (superset's ~30-vendor-event → Start/blocked/Stop collapse, reimplemented with the event names as facts) ships now with 25 tests so the deferred notify-hook injection reuses it verbatim. The `AgentComposerBar` gains a normalized status dot (working=spinner, blocked=amber, idle=gray, error=red) distinct from the WS-liveness dot, fed by a `useAgentStatus` map `CoderPane` tracks per `(chat,agent)`. Built by two parallel agents (data plane + view plane) against a pinned frame contract; server 545 + coder 294 tests passing (25 new), web tsc + builds clean, ws-frames parity green. Clears the actionable review backlog (#1/#3/#4/#6#12). Builds on `v2.7.5-claude-sdk-sessionstore`; openspec `agent-status-normalize`.
## v2.7.5-claude-sdk-sessionstore — 2026-06-01
Lands the Claude Agent SDK direction (`boocode_code_review_v2.md` §1 #9, §6.2 "lean SDK") behind a flag. Adds `@anthropic-ai/claude-agent-sdk@0.3.159` (Commercial Terms — runtime dep, code reference-only) and builds a warm, resumable claude backend to supersede one-shot PTY dispatch — env-gated (`CLAUDE_SDK_BACKEND`, default off) so production claude stays on the unchanged PTY path until a host smoke. **Clean-room `PostgresSessionStore`** implements the SDK's real `SessionStore` type (`append`/`load`/`listSessions`/`delete`/`listSubkeys`) over a new `claude_session_entries` table — typechecked against the installed SDK type, 8 DB-integration tests. **`ClaudeSdkBackend`** (`implements AgentBackend`, mirroring warm-acp/opencode-server) drives one persistent `query()` per `(chat,'claude')` in streaming-input mode via a pushable async-iterable pump, with `sessionStore` + `resume` for cross-turn/cross-restart continuity, a pure `mapSdkMessage``AgentEvent` mapper, `session_id` captured from the `init` message, and `result.usage`/`total_cost_usd` accumulated onto `agent_sessions` (backend CHECK gains `'claude_sdk'`). Built against the REAL SDK 0.3.159 types after installing it — surfacing shapes a blind build would have missed (`SDKPartialAssistantMessage` is `type:'stream_event'` needing `includePartialMessages`; `SDKUserMessage.message` is `MessageParam`; the `SDKResultMessage` error arm). Also fixes a latent test-infra deadlock — three DB-integration suites applying the full schema in parallel under `DATABASE_URL` deadlocked, now serialized via `fileParallelism:false`. ~32 new tests (8 store + 10 mapper + 8 pushable + 6 routing); coder suite 269 passing default / 290 with DB; tsc clean against the SDK types; builds clean. **The live streaming pump + resume + an actual claude turn need a host smoke (`CLAUDE_SDK_BACKEND=1` + claude binary + ANTHROPIC auth) — cannot run from the dev container.** The zod peer-dep wants `^4` (workspace `3.25`) — watch at runtime. Builds on `v2.7.4-mistake-tracker-ledger`; openspec `claude-sdk-sessionstore`.

View File

@@ -35,7 +35,7 @@ npx tsc -p apps/web/tsconfig.app.json --noEmit # web app specifically
docker compose build --no-cache boocode && docker compose up -d
```
Tests: `pnpm -C apps/server test` runs the vitest suite. No test harness on `apps/web` (adding it requires installing vitest as a new devDep). Vitest pinned to `^3` because Vite 5 / vitest 4 are incompatible. No linters configured. Vitest include glob is `src/**/__tests__/**/*.test.ts` (see `apps/server/vitest.config.ts`) — tests outside `src/**/__tests__/` silently won't run; match the per-domain convention (`apps/server/src/services/__tests__/foo.test.ts`).
Tests: `pnpm -C apps/server test` runs the vitest suite. No test harness on `apps/web` (adding it requires installing vitest as a new devDep). Vitest pinned to `^3` because Vite 5 / vitest 4 are incompatible. No linters configured. Vitest include glob is `src/**/__tests__/**/*.test.ts` (see `apps/server/vitest.config.ts`) — tests outside `src/**/__tests__/` silently won't run; match the per-domain convention (`apps/server/src/services/__tests__/foo.test.ts`). `apps/coder` has its own vitest suite too — `pnpm -C apps/coder test` (same `src/**/__tests__/**/*.test.ts` glob; `globals:false`, so import `describe`/`it`/`expect` from `vitest`). Extract pure helpers to unit-test (`backends/turn-guard.ts`, `lifecycle-decisions.ts` are the pattern).
## Architecture
@@ -81,6 +81,7 @@ Route registration: all routes registered in `index.ts` via `register*Routes(app
- **Workspace dependency on `@boocode/server`**: imports `createInferenceRunner`, `createBroker`, `ALL_TOOLS`, `appendMcpTools` from the server's compiled `dist/`. apps/server's `package.json` has an `exports` map with `types` conditions for NodeNext resolution. apps/server must build FIRST.
- Build + deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Env file at `apps/coder/.env.host`. Service file at `/etc/systemd/system/boocoder.service`.
- After `pnpm -C apps/coder build` the host `boocoder.service` keeps running the OLD process until `sudo systemctl restart boocoder` — a stale process shows **new routes 404 with `{error:'not found'}` while old routes still 200** (the `/api` not-found handler returns that shape). Restart, don't re-debug.
- **Deploy by surface:** an `apps/coder` change → `sudo systemctl restart boocoder`; an `apps/web` or `apps/server` change → `docker compose up --build -d boocode` (rebuilds web+server from the working tree). `:9502/api/health` is down ~1520s after a boocoder restart while the startup agent-probe scan runs — retry; an early connection-refused is not a failed deploy.
- Agent dispatch spawns binaries directly using `install_path` from `available_agents` — no `spawn('sh', ['-c', ...])` (fails under systemd). Follows Paseo's pattern: `spawn(fullBinaryPath, argsArray, { cwd })`.
- systemd hardening: only `NoNewPrivileges=true` is safe. `ProtectSystem`, `ProtectHome`, `PrivateTmp` all break agent dispatch (agents need full filesystem access to read configs, write to worktrees).
- `apps/server/tsconfig.json` has `declaration: true` so `.d.ts` files exist for workspace consumers.
@@ -148,12 +149,14 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
## Workflow
- Sam reviews all diffs and commits manually. Do not commit unless explicitly asked.
- Sam often has uncommitted `apps/web` work in flight mid-session — stage your own commits **explicitly by path** (never `git add -A`); and `docker compose up --build -d boocode` builds the working tree, so a container rebuild also ships his uncommitted web changes.
- Cutting a release: name the feature branch DIFFERENTLY from the tag (branch `f1-interrupt-guard`, tag `v2.6.7-interrupt-guard`) — identical branch+tag names trigger `warning: refname ... is ambiguous`.
- Per-batch docs live under `openspec/changes/<slug>/{proposal,tasks,design}.md`. Already-shipped batches are snapshots in `openspec/changes/archived/`. New batches follow the proposal+tasks shape; see `openspec/README.md` for the convention.
- Tag naming: `vMAJOR.MINOR.PATCH-slug` (e.g. `v1.13.13-ws-publish`). Monotonic per minor — the slug describes the batch's content so the tag name alone is enough to recall what shipped. No letter suffixes (`-a`/`-b`), no pseudo-ranges (`v1.11.x`), no slug-only sub-versions sharing a number (`v1.13.15-tools` + `-openspec` + `-agentlint` — split into sequential patches instead).
- `CHANGELOG.md` is the per-tag release log, most-recent on top. When a new tag is created, add a `## <tag> — <YYYY-MM-DD>` section with a 36 sentence paragraph summarizing what shipped, drawn from the commit body. Cross-reference other tags by name when the batch builds on, fixes, or pairs with prior work (e.g. "pairs with `v1.13.12-ws-schemas`", "fixed in `v1.13.5-stability-bundle`"). No nested bullets — one paragraph.
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
- The `boocode` container is `build: .` — it builds web+server from the **working tree**, so uncommitted changes deploy. Web edits are live on the Vite dev server (HMR) but NOT on production (`:9500` / code.indifferentketchup.com) until `docker compose up --build -d boocode`.
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`.
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`. Keep both remotes synced: push `main` + the release tag to `origin` (Gitea, deploy key above) AND `backup` (`git@github.com:indifferentketchup/boocode.git`, default key).
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. `psql` is not on the host PATH — for an interactive query use `docker exec boocode_db psql -U boocode -d boochat -c "..."`. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference.
- Host-side smoke endpoint: `curl http://100.114.205.53:9500/api/...`. The boocode container's port mapping binds to the Tailscale IP, not `0.0.0.0`, so `localhost:9500` doesn't work from the host shell. Same for booterm at `:9501`.

View File

@@ -42,6 +42,7 @@ import { createOrphanWorktreeReaper } from './services/orphan-worktree-reaper.js
import { probeAgents } from './services/agent-probe.js';
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
import { setPermissionHooks } from './services/permission-waiter.js';
import { publishAgentStatus } from './services/agent-status-publish.js';
import { homedir } from 'node:os';
async function main() {
@@ -82,6 +83,21 @@ async function main() {
// Broker: in-memory pub/sub for session + user channel streaming.
const broker = createBroker(app.log);
// agent-status-normalize (#10): the permission hooks carry only taskId +
// sessionId, but the tasks row holds the (chat_id, agent) pair the status frame
// is keyed on. Resolve it best-effort so a blocked/working status accompanies
// every permission_requested/permission_resolved. Returns null when the task
// lacks a chat_id or agent (sessionless creators) — we simply skip the status.
const resolveChatAgent = async (
taskId: string,
): Promise<{ chatId: string; agent: string } | null> => {
const [row] = await sql<{ chat_id: string | null; agent: string | null }[]>`
SELECT chat_id, agent FROM tasks WHERE id = ${taskId}
`;
if (!row?.chat_id || !row.agent) return null;
return { chatId: row.chat_id, agent: row.agent };
};
setPermissionHooks({
onPrompt: async (prompt) => {
await sql`
@@ -96,6 +112,18 @@ async function main() {
...(prompt.input ? { input: prompt.input } : {}),
options: prompt.options.map((o) => ({ option_id: o.optionId, label: o.label })),
} as WsFrame);
// #10: agent is blocked on a human decision.
const ca = await resolveChatAgent(prompt.taskId).catch(() => null);
if (ca) {
publishAgentStatus(
broker.publishFrame,
prompt.sessionId,
ca.chatId,
ca.agent,
'blocked',
'permission_request',
);
}
},
onResolved: async (taskId, sessionId) => {
await sql`
@@ -106,6 +134,18 @@ async function main() {
task_id: taskId,
session_id: sessionId,
} as WsFrame);
// #10: human responded — agent resumes work.
const ca = await resolveChatAgent(taskId).catch(() => null);
if (ca) {
publishAgentStatus(
broker.publishFrame,
sessionId,
ca.chatId,
ca.agent,
'working',
'permission_resolved',
);
}
},
});

View File

@@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest';
import { resolveChatId } from '../chat-resolve.js';
import type { Sql } from '../../db.js';
// Mock the porsager/postgres surface that chat-resolve.ts uses: a tagged-template
// `tx` (dispatched by query substring), `tx.json`, and `sql.begin(fn)` which just
// runs fn(tx). Captures the value written back to workspace_panes so we can assert
// the WorkspaceState envelope survives the UPDATE.
interface MockState {
stored: unknown; // initial sessions.workspace_panes value
existingChatOpen: boolean; // whether `SELECT id FROM chats ...` finds the active chat
newChatId: string;
written?: unknown; // captured tx.json(...) payload from `UPDATE sessions`
inserted: boolean; // whether INSERT INTO chats ran
}
interface MockTx {
(strings: TemplateStringsArray): Promise<unknown>;
json: (v: unknown) => unknown;
}
function mockSql(state: MockState): Sql {
const tx = ((strings: TemplateStringsArray) => {
const q = strings.join('');
if (q.includes('SELECT workspace_panes FROM sessions')) {
return Promise.resolve([{ workspace_panes: state.stored }]);
}
if (q.includes('FROM chats')) {
return Promise.resolve(state.existingChatOpen ? [{ id: 'placeholder' }] : []);
}
if (q.includes('INSERT INTO chats')) {
state.inserted = true;
return Promise.resolve([{ id: state.newChatId }]);
}
if (q.includes('UPDATE sessions')) {
return Promise.resolve([]);
}
return Promise.resolve([]);
}) as unknown as MockTx;
tx.json = (v: unknown) => {
state.written = v;
return v;
};
const sql = {
begin: (fn: (t: Sql) => Promise<unknown>) => fn(tx as unknown as Sql),
};
return sql as unknown as Sql;
}
const ENVELOPE = () => ({
panes: [{ id: 'pane-1', kind: 'coder', chatIds: [] as string[], activeChatIdx: 0 }],
tabNumbers: { 'chat-x': 3 },
nextTabNumber: 7,
closedPaneStack: [{ kind: 'coder', chatIds: ['old'], activeChatIdx: 0 }],
});
describe('resolveChatId — v2.6.5 WorkspaceState envelope', () => {
it('reads panes from the envelope without crashing (regression: panes.findIndex is not a function)', async () => {
const state: MockState = {
stored: ENVELOPE(),
existingChatOpen: false,
newChatId: 'new-chat-1',
inserted: false,
};
const chatId = await resolveChatId(mockSql(state), 'session-1', 'pane-1');
expect(chatId).toBe('new-chat-1');
expect(state.inserted).toBe(true);
});
it('preserves the envelope (tabNumbers/nextTabNumber/closedPaneStack) on write-back', async () => {
const state: MockState = {
stored: ENVELOPE(),
existingChatOpen: false,
newChatId: 'new-chat-1',
inserted: false,
};
await resolveChatId(mockSql(state), 'session-1', 'pane-1');
const w = state.written as Record<string, unknown>;
expect(Array.isArray(w.panes)).toBe(true); // envelope, not a bare array
expect(w.tabNumbers).toEqual({ 'chat-x': 3 });
expect(w.nextTabNumber).toBe(7);
expect(w.closedPaneStack).toEqual([{ kind: 'coder', chatIds: ['old'], activeChatIdx: 0 }]);
});
it('returns the existing open chat when the pane already has one', async () => {
const env = ENVELOPE();
env.panes[0]!.chatIds = ['existing-1'];
const state: MockState = {
stored: env,
existingChatOpen: true,
newChatId: 'should-not-be-used',
inserted: false,
};
const chatId = await resolveChatId(mockSql(state), 'session-1', 'pane-1');
expect(chatId).toBe('existing-1');
expect(state.inserted).toBe(false);
});
it('still accepts a legacy bare WorkspacePane[] array', async () => {
const state: MockState = {
stored: [{ id: 'pane-1', kind: 'coder', chatId: 'legacy-1', chatIds: ['legacy-1'], activeChatIdx: 0 }],
existingChatOpen: true,
newChatId: 'should-not-be-used',
inserted: false,
};
const chatId = await resolveChatId(mockSql(state), 'session-1', 'pane-1');
expect(chatId).toBe('legacy-1');
expect(state.inserted).toBe(false);
});
});

View File

@@ -8,6 +8,36 @@ interface WorkspacePaneRow {
activeChatIdx?: number;
}
// v2.6.5: sessions.workspace_panes widened from a bare WorkspacePane[] to a
// WorkspaceState envelope { panes, tabNumbers, nextTabNumber, closedPaneStack }.
// (See the union validator in apps/server routes/sessions.ts + normalizeWorkspaceState
// in apps/server read_tab_by_number.ts — this is the coder-side mirror.)
interface WorkspaceStateRow {
panes: WorkspacePaneRow[];
tabNumbers: Record<string, number>;
nextTabNumber: number;
closedPaneStack: unknown[];
}
// MIGRATION: the stored value may be the legacy bare array OR the envelope.
// Normalize to a full envelope so callers always read `.panes` as an array and
// write the envelope back intact (preserving tabNumbers/nextTabNumber/closedPaneStack).
export function normalizeWorkspaceState(v: unknown): WorkspaceStateRow {
if (Array.isArray(v)) {
return { panes: v as WorkspacePaneRow[], tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] };
}
if (v && typeof v === 'object' && Array.isArray((v as { panes?: unknown }).panes)) {
const env = v as Partial<WorkspaceStateRow>;
return {
panes: env.panes ?? [],
tabNumbers: env.tabNumbers ?? {},
nextTabNumber: env.nextTabNumber ?? 1,
closedPaneStack: env.closedPaneStack ?? [],
};
}
return { panes: [], tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] };
}
function chatNameForKind(kind: string): string {
if (kind === 'coder' || kind === 'agent') return 'BooCoder';
if (kind === 'terminal') return 'Terminal';
@@ -28,12 +58,13 @@ export async function resolveChatId(
paneId: string,
): Promise<string | null> {
return sql.begin(async (tx) => {
const sessionRows = await tx<{ workspace_panes: WorkspacePaneRow[] }[]>`
const sessionRows = await tx<{ workspace_panes: unknown }[]>`
SELECT workspace_panes FROM sessions WHERE id = ${sessionId} FOR UPDATE
`;
if (sessionRows.length === 0) return null;
const panes = sessionRows[0]!.workspace_panes ?? [];
const state = normalizeWorkspaceState(sessionRows[0]!.workspace_panes);
const panes = state.panes;
const paneIdx = panes.findIndex((p) => p.id === paneId);
if (paneIdx < 0) return null;
@@ -69,9 +100,10 @@ export async function resolveChatId(
: p,
);
const nextState: WorkspaceStateRow = { ...state, panes: nextPanes };
await tx`
UPDATE sessions
SET workspace_panes = ${tx.json(nextPanes as never)},
SET workspace_panes = ${tx.json(nextState as never)},
updated_at = clock_timestamp()
WHERE id = ${sessionId}
`;

View File

@@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest';
import { normalizeAgentEvent } from '../normalize-agent-status.js';
describe('normalizeAgentEvent', () => {
describe('working bucket', () => {
const cases = [
'SessionStart',
'UserPromptSubmit',
'UserPromptSubmitted',
'PostToolUse',
'PostToolUseFailure',
'BeforeAgent',
'AfterTool',
'task_started',
];
for (const name of cases) {
it(`maps ${name} → working`, () => {
expect(normalizeAgentEvent(name)).toBe('working');
});
}
});
describe('blocked bucket', () => {
const cases = [
'PreToolUse',
'Notification',
'PermissionRequest',
'exec_approval_request',
'apply_patch_approval_request',
'request_user_input',
];
for (const name of cases) {
it(`maps ${name} → blocked`, () => {
expect(normalizeAgentEvent(name)).toBe('blocked');
});
}
});
describe('done bucket', () => {
const cases = [
'Stop',
'AfterAgent',
'SessionEnd',
'task_complete',
'agent-turn-complete',
];
for (const name of cases) {
it(`maps ${name} → done`, () => {
expect(normalizeAgentEvent(name)).toBe('done');
});
}
});
describe('unknown / nullish → null', () => {
it('returns null for an unrecognized event', () => {
expect(normalizeAgentEvent('SomeRandomEvent')).toBeNull();
});
it('returns null for empty string', () => {
expect(normalizeAgentEvent('')).toBeNull();
});
it('returns null for undefined', () => {
expect(normalizeAgentEvent(undefined)).toBeNull();
});
});
describe('case- and separator-insensitive matching', () => {
it('matches snake_case spelling of a PascalCase event', () => {
expect(normalizeAgentEvent('session_start')).toBe('working');
expect(normalizeAgentEvent('post_tool_use')).toBe('working');
expect(normalizeAgentEvent('pre_tool_use')).toBe('blocked');
});
it('matches camelCase spelling', () => {
expect(normalizeAgentEvent('userPromptSubmitted')).toBe('working');
expect(normalizeAgentEvent('postToolUse')).toBe('working');
expect(normalizeAgentEvent('preToolUse')).toBe('blocked');
expect(normalizeAgentEvent('sessionEnd')).toBe('done');
});
it('matches arbitrary case', () => {
expect(normalizeAgentEvent('STOP')).toBe('done');
expect(normalizeAgentEvent('notification')).toBe('blocked');
});
});
});

View File

@@ -0,0 +1,55 @@
/**
* agent-status-publish (#10) — builds + publishes the `agent_status_updated`
* WS frame on the per-session channel (the same channel CoderPane subscribes to).
*
* Kept separate from normalize-agent-status.ts so that module stays a pure,
* broker-free helper (trivially unit-testable; reused by the config-injection
* follow-on). The frame contract is pinned in apps/server/src/types/ws-frames.ts
* (`AgentStatusUpdatedFrame`) and mirrored byte-identical in apps/web.
*/
import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames';
import type { AgentStatus } from './normalize-agent-status.js';
// The exact slice of Broker we need — accepting just the bound method keeps call
// sites flexible (pass `broker.publishFrame.bind(broker)` or, since the broker's
// publishFrame doesn't read `this`, `broker.publishFrame` directly).
type PublishFrame = Broker['publishFrame'];
/**
* Best-effort publish of a normalized agent status. The broker's publishFrame
* already fail-closes (validates + logs + drops on bad input, never throws), but
* we additionally swallow any unexpected error so a publish can NEVER break the
* turn it's reporting on.
*
* @param publishFrame the session channel publisher (broker.publishFrame)
* @param sessionId WS subscription channel (CoderPane subscribes per-session)
* @param chatId the (chat) half of the (chat, agent) status key
* @param agent the (agent) half of the key
* @param status normalized lifecycle status
* @param reason free-form discriminator (turn_start / turn_complete / …)
* @param at ISO timestamp; defaults to now
*/
export function publishAgentStatus(
publishFrame: PublishFrame,
sessionId: string,
chatId: string,
agent: string,
status: AgentStatus,
reason?: string,
at: string = new Date().toISOString(),
): void {
try {
const frame: WsFrame = {
type: 'agent_status_updated',
chat_id: chatId,
agent,
status,
...(reason ? { reason } : {}),
at,
};
publishFrame(sessionId, frame);
} catch {
// never let a status publish break the turn — best-effort only.
}
}

View File

@@ -20,6 +20,8 @@ import { ClaudeSdkBackend } from './backends/claude-sdk.js';
import { shouldUseWarmBackend } from './backends/warm-acp-routing.js';
import { shouldUseClaudeSdk } from './backends/claude-sdk-routing.js';
import type { AgentBackend, AgentEvent } from './agent-backend.js';
import { publishAgentStatus } from './agent-status-publish.js';
import type { AgentStatus } from './normalize-agent-status.js';
interface InferenceRunner {
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
@@ -66,6 +68,21 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
return task.session_id ?? `task:${task.id}`;
}
// agent-status-normalize (#10): publish a normalized per-(chat,agent) status on
// the session channel. Every external-agent path (warm-acp / opencode / claude-sdk /
// pty one-shot) reports `working` at turn start, `idle` on clean completion, and
// `error` on the failure path through this single helper so the four paths stay
// DRY and consistent. Best-effort — publishAgentStatus never throws.
function emitAgentStatus(
sessionId: string,
chatId: string,
agent: string,
status: AgentStatus,
reason: string,
): void {
publishAgentStatus(broker.publishFrame, sessionId, chatId, agent, status, reason);
}
async function poll(): Promise<void> {
// `polling` serializes poll() execution itself (timer + NOTIFY can fire
// concurrently) so we never double-select a task. It does NOT serialize task
@@ -298,6 +315,11 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
// Create an abort controller for this task
const ac = new AbortController();
// #10: hoisted above the try so the catch block can report `error` status with
// the (chat, agent) key. Empty until resolved below; guarded before use.
let sessionId = '';
let chatId = '';
try {
// Mark running
await sql`
@@ -306,9 +328,6 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
WHERE id = ${taskId}
`;
let sessionId: string;
let chatId: string;
if (task.session_id) {
sessionId = task.session_id;
const chats = await sql<{ id: string }[]>`
@@ -384,6 +403,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
role: 'assistant',
} as WsFrame);
// #10: external-agent turn begins.
emitAgentStatus(sessionId, chatId, agent, 'working', 'turn_start');
const manifestCommands = getManifestCommands(agent);
if (manifestCommands.length > 0) {
setTaskCommands(taskId, manifestCommands);
@@ -558,6 +580,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
WHERE id = ${taskId}
`;
log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)');
// #10: external-agent turn completed cleanly.
emitAgentStatus(sessionId, chatId, agent, 'idle', 'turn_complete');
clearTaskCommands(taskId);
} catch (err) {
@@ -570,6 +594,11 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
WHERE id = ${taskId}
`.catch(() => {});
// #10: external-agent turn failed/crashed. chatId may be unbound if the throw
// preceded its assignment — guard so the status publish never masks the real
// error.
if (chatId) emitAgentStatus(sessionId, chatId, agent, 'error', 'failed');
// Best-effort cleanup
await cleanupWorktree(projectPath, taskId);
clearTaskCommands(taskId);
@@ -624,6 +653,10 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
const ac = new AbortController();
// #10: hoisted so the catch can report `error` with the (chat, agent) key.
let sessionId = '';
let chatId = '';
try {
// execution_path = 'acp' — the schema CHECK has no 'opencode_server' value
// (schema is frozen at Phase 0); the warm-vs-one-shot distinction lives in
@@ -640,8 +673,6 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
// it directly. Session-less creators (arena, MCP, new_task, generic
// /api/tasks) leave it null; fall back to resolving/creating a real chat so
// ensureSession never receives a degenerate (null, agent) key.
let sessionId: string;
let chatId: string;
if (task.chat_id && task.session_id) {
sessionId = task.session_id;
chatId = task.chat_id;
@@ -714,6 +745,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
role: 'assistant',
} as WsFrame);
// #10: opencode-server turn begins.
emitAgentStatus(sessionId, chatId, agent, 'working', 'turn_start');
const manifestCommands = getManifestCommands(agent);
if (manifestCommands.length > 0) {
setTaskCommands(taskId, manifestCommands);
@@ -873,6 +907,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
WHERE id = ${taskId}
`;
log.info({ taskId, agent, finalState, costTokens: extCostTokens }, 'dispatcher: task finished (opencode server)');
// #10: clean completion → idle; backend-reported failure → error.
emitAgentStatus(
sessionId,
chatId,
agent,
result.ok ? 'idle' : 'error',
result.ok ? 'turn_complete' : 'failed',
);
clearTaskCommands(taskId);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
@@ -882,6 +924,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
WHERE id = ${taskId}
`.catch(() => {});
// #10: turn crashed.
if (chatId) emitAgentStatus(sessionId, chatId, agent, 'error', 'crashed');
clearTaskCommands(taskId);
// No worktree cleanup (persistent); backend stays warm for the next turn.
}
@@ -982,6 +1026,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
role: 'assistant',
} as WsFrame);
// #10: warm-ACP turn begins.
emitAgentStatus(sessionId, chatId, agent, 'working', 'turn_start');
const manifestCommands = getManifestCommands(agent);
if (manifestCommands.length > 0) {
setTaskCommands(taskId, manifestCommands);
@@ -1123,6 +1170,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
WHERE id = ${taskId}
`;
log.info({ taskId, agent, finalState }, 'dispatcher: task finished (warm ACP)');
// #10: clean completion → idle; backend-reported failure → error.
emitAgentStatus(
sessionId,
chatId,
agent,
result.ok ? 'idle' : 'error',
result.ok ? 'turn_complete' : 'failed',
);
clearTaskCommands(taskId);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
@@ -1132,6 +1187,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
WHERE id = ${taskId}
`.catch(() => {});
// #10: turn crashed.
emitAgentStatus(sessionId, chatId, agent, 'error', 'crashed');
clearTaskCommands(taskId);
// No worktree cleanup (persistent); backend stays warm for the next turn.
}
@@ -1224,6 +1281,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
role: 'assistant',
} as WsFrame);
// #10: claude-SDK turn begins.
emitAgentStatus(sessionId, chatId, agent, 'working', 'turn_start');
const manifestCommands = getManifestCommands(agent);
if (manifestCommands.length > 0) {
setTaskCommands(taskId, manifestCommands);
@@ -1364,6 +1424,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
WHERE id = ${taskId}
`;
log.info({ taskId, agent, finalState }, 'dispatcher: task finished (claude SDK)');
// #10: clean completion → idle; backend-reported failure → error.
emitAgentStatus(
sessionId,
chatId,
agent,
result.ok ? 'idle' : 'error',
result.ok ? 'turn_complete' : 'failed',
);
clearTaskCommands(taskId);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
@@ -1373,6 +1441,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
WHERE id = ${taskId}
`.catch(() => {});
// #10: turn crashed.
emitAgentStatus(sessionId, chatId, agent, 'error', 'crashed');
clearTaskCommands(taskId);
// No worktree cleanup (persistent); backend stays warm for the next turn.
}

View File

@@ -0,0 +1,92 @@
/**
* normalize-agent-status (#10) — clean-room vendor-event → bucket mapping.
*
* Different coding agents (claude, opencode, codex/gemini, goose, qwen) emit
* lifecycle hook events under inconsistent names: PascalCase (`SessionStart`),
* snake_case (`session_start`), camelCase (`sessionStart`), and a handful of
* provider-specific approval events (`exec_approval_request`). This module
* collapses every known event name into one of three coarse signals:
*
* working — the agent is actively progressing a turn
* blocked — the agent is waiting on a human (permission / approval / question)
* done — the turn / session ended cleanly
*
* `null` is returned for anything unrecognized so callers can ignore noise.
*
* Built now for the scoped status-publish, but specifically shaped for reuse by
* the documented config-injection follow-on: a future notify-hook injected into
* each agent's native config will POST the RAW vendor event name to a BooCoder
* endpoint, which runs this helper to derive the normalized status. The names
* below are facts about each agent's hook surface — not copied vendor code.
*/
export type AgentStatus = 'working' | 'blocked' | 'idle' | 'error';
/** The coarse signal a raw vendor event collapses to. */
export type AgentEventBucket = 'working' | 'blocked' | 'done';
// Each bucket lists the canonical vendor event names. Lookup is
// case-insensitive AND separator-insensitive (snake_case / camelCase /
// PascalCase all fold to the same key), so we normalize the raw input the same
// way before matching rather than enumerating every spelling here.
const WORKING_EVENTS = [
'SessionStart',
'UserPromptSubmit',
'UserPromptSubmitted',
'PostToolUse',
'PostToolUseFailure',
'BeforeAgent',
'AfterTool',
'task_started',
] as const;
const BLOCKED_EVENTS = [
'PreToolUse',
'Notification',
'PermissionRequest',
'exec_approval_request',
'apply_patch_approval_request',
'request_user_input',
] as const;
const DONE_EVENTS = [
'Stop',
'AfterAgent',
'SessionEnd',
'task_complete',
'agent-turn-complete',
] as const;
/**
* Fold a raw event name to a separator/case-insensitive key:
* strip every non-alphanumeric character and lowercase. So `post_tool_use`,
* `postToolUse`, `PostToolUse`, and `POST-TOOL-USE` all map to `posttooluse`.
*/
function foldKey(raw: string): string {
return raw.replace(/[^a-z0-9]/gi, '').toLowerCase();
}
function buildLookup(
groups: ReadonlyArray<readonly [AgentEventBucket, readonly string[]]>,
): Map<string, AgentEventBucket> {
const map = new Map<string, AgentEventBucket>();
for (const [bucket, names] of groups) {
for (const name of names) map.set(foldKey(name), bucket);
}
return map;
}
const EVENT_LOOKUP = buildLookup([
['working', WORKING_EVENTS],
['blocked', BLOCKED_EVENTS],
['done', DONE_EVENTS],
]);
/**
* Map a raw vendor hook-event name to its normalized bucket, or `null` when the
* name is unknown / undefined. Case- and separator-insensitive.
*/
export function normalizeAgentEvent(raw: string | undefined): AgentEventBucket | null {
if (!raw) return null;
return EVENT_LOOKUP.get(foldKey(raw)) ?? null;
}

View File

@@ -39,6 +39,12 @@ const ChatStatusValue = z.enum([
'error',
]);
// agent-status-normalize (#10): normalized per-(chat,agent) lifecycle status for
// external coding agents (warm-acp / opencode / claude-sdk / pty). Distinct from
// ChatStatusValue (native-inference chat lifecycle) — published by BooCoder's
// dispatcher + permission flow on the per-session channel.
const AgentStatusValue = z.enum(['working', 'blocked', 'idle', 'error']);
const ErrorReasonValue = z.enum([
'llm_provider_error',
'doom_loop',
@@ -301,6 +307,21 @@ export const AgentCommandsFrame = z.object({
commands: z.array(AgentCommandShape),
});
// agent-status-normalize (#10): published by BooCoder on the per-session channel
// when an external agent's normalized status changes (turn start/end, permission
// block/unblock). Keyed per (chat_id, agent); the frontend tracks the latest per
// pair and resets on chat switch. `reason` is a free-form discriminator
// (turn_start / turn_complete / failed / crashed / permission_request /
// permission_resolved).
export const AgentStatusUpdatedFrame = z.object({
type: z.literal('agent_status_updated'),
chat_id: Uuid,
agent: z.string().min(1),
status: AgentStatusValue,
reason: z.string().optional(),
at: IsoTimestamp,
});
// ---- discriminated union ---------------------------------------------------
export const WsFrameSchema = z.discriminatedUnion('type', [
@@ -320,6 +341,7 @@ export const WsFrameSchema = z.discriminatedUnion('type', [
PermissionRequestedFrame,
PermissionResolvedFrame,
AgentCommandsFrame,
AgentStatusUpdatedFrame,
// per-user
ChatStatusFrame,
SessionUpdatedFrame,
@@ -361,6 +383,7 @@ export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
'permission_requested',
'permission_resolved',
'agent_commands',
'agent_status_updated',
'chat_status',
'session_updated',
'session_renamed',

View File

@@ -596,4 +596,16 @@ export type WsFrame =
| { type: 'compacted'; session_id: string; chat_id: string; summary_message_id: string }
// v1.8.2: `reason` discriminates structured failures (the UI prefers it
// over `error` text when present).
| { type: 'error'; message_id?: string; chat_id?: string; error: string; reason?: ErrorReason };
| { type: 'error'; message_id?: string; chat_id?: string; error: string; reason?: ErrorReason }
// agent-status-normalize (#10): BooCoder publishes a normalized per-(chat,agent)
// lifecycle status for external coding agents on the per-session channel. The
// CoderPane tracks the latest status per (chat_id, agent) and resets on chat
// switch; AgentComposerBar renders the dot (distinct from the WS-liveness dot).
| {
type: 'agent_status_updated';
chat_id: string;
agent: string;
status: 'working' | 'blocked' | 'idle' | 'error';
reason?: string;
at: string;
};

View File

@@ -39,6 +39,12 @@ const ChatStatusValue = z.enum([
'error',
]);
// agent-status-normalize (#10): normalized per-(chat,agent) lifecycle status for
// external coding agents (warm-acp / opencode / claude-sdk / pty). Distinct from
// ChatStatusValue (native-inference chat lifecycle) — published by BooCoder's
// dispatcher + permission flow on the per-session channel.
const AgentStatusValue = z.enum(['working', 'blocked', 'idle', 'error']);
const ErrorReasonValue = z.enum([
'llm_provider_error',
'doom_loop',
@@ -301,6 +307,21 @@ export const AgentCommandsFrame = z.object({
commands: z.array(AgentCommandShape),
});
// agent-status-normalize (#10): published by BooCoder on the per-session channel
// when an external agent's normalized status changes (turn start/end, permission
// block/unblock). Keyed per (chat_id, agent); the frontend tracks the latest per
// pair and resets on chat switch. `reason` is a free-form discriminator
// (turn_start / turn_complete / failed / crashed / permission_request /
// permission_resolved).
export const AgentStatusUpdatedFrame = z.object({
type: z.literal('agent_status_updated'),
chat_id: Uuid,
agent: z.string().min(1),
status: AgentStatusValue,
reason: z.string().optional(),
at: IsoTimestamp,
});
// ---- discriminated union ---------------------------------------------------
export const WsFrameSchema = z.discriminatedUnion('type', [
@@ -320,6 +341,7 @@ export const WsFrameSchema = z.discriminatedUnion('type', [
PermissionRequestedFrame,
PermissionResolvedFrame,
AgentCommandsFrame,
AgentStatusUpdatedFrame,
// per-user
ChatStatusFrame,
SessionUpdatedFrame,
@@ -361,6 +383,7 @@ export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
'permission_requested',
'permission_resolved',
'agent_commands',
'agent_status_updated',
'chat_status',
'session_updated',
'session_renamed',

View File

@@ -3,6 +3,7 @@ import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bot } from 'luci
import { api } from '@/api/client';
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
import type { AgentStatusEntry } from '@/hooks/useAgentStatus';
import { providerIcon } from '@/components/coder/providerIcons';
import { useAgentSessions } from '@/hooks/useAgentSessions';
import {
@@ -183,6 +184,11 @@ interface Props {
// True once the chat has at least one prior turn — gates the chip so it stays
// hidden on a brand-new chat. Defaults to false (no chip).
hasPriorTurn?: boolean;
// #10: normalized status (working|blocked|idle|error) for the active external
// agent in this chat, or null for native boocode / before any frame. Renders
// a status dot DISTINCT from the WS-liveness `connected` dot. Undefined for
// non-coder callers — no dot.
agentStatus?: AgentStatusEntry | null;
}
// Condensed token count: 950 → "950", 12_400 → "12.4K", 3_200_000 → "3.2M".
@@ -210,7 +216,42 @@ function relativeTime(iso: string | null): string {
return `${day}d ago`;
}
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected, sessionId, hasPriorTurn }: Props) {
// #10: normalized external-agent status dot. Mirrors StatusDot's visual
// language but on the four normalized buckets (working|blocked|idle|error),
// and is DISTINCT from the WS-liveness `connected` dot beside it:
// working — emerald spinning ring (subtle motion, like chat streaming)
// blocked — amber dot (matches the permission/blocked state colour)
// idle — gray dot
// error — red dot
function AgentStatusDot({ entry, agent }: { entry: AgentStatusEntry; agent: string }) {
const title =
`${agent}: ${entry.status}` + (entry.reason ? `${entry.reason}` : '');
if (entry.status === 'working') {
return (
<span
aria-label={`Agent status: working${entry.reason ? `${entry.reason}` : ''}`}
title={title}
className="inline-block w-3 h-3 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin shrink-0"
/>
);
}
const bg =
entry.status === 'blocked' ? 'bg-amber-500'
: entry.status === 'error' ? 'bg-destructive'
: 'bg-muted-foreground/40';
return (
<span
aria-label={`Agent status: ${entry.status}${entry.reason ? `${entry.reason}` : ''}`}
title={title}
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', bg)}
/>
);
}
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected, sessionId, hasPriorTurn, agentStatus }: Props) {
const allEntries = useProviderSnapshot(projectPath);
// 5.5 — the composer picker only offers ENABLED providers that are ready (or
// still loading). Disabled (enabled:false) and unavailable/error providers are
@@ -434,6 +475,11 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
{/* Status dot + refresh as one right-aligned unit so the refresh button
stays on the top line instead of wrapping past the edge-pinned dot. */}
<div className="ml-auto flex items-center gap-1 shrink-0">
{/* #10: normalized agent status — only for an external agent with a
live status frame. Distinct from the WS-liveness dot that follows. */}
{agentStatus && value.provider !== 'boocode' && (
<AgentStatusDot entry={agentStatus} agent={value.provider} />
)}
{connected !== undefined && (
<span
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', connected ? 'bg-green-500' : 'bg-red-500')}

View File

@@ -1,7 +1,8 @@
import { useState } from 'react';
import { Code, Columns2, History, MessageSquare, Plus, RotateCcw, Terminal, X } from 'lucide-react';
import { History, MessageSquare, X } from 'lucide-react';
import type { Chat, WorkspacePane } from '@/api/types';
import { StatusDot } from '@/components/StatusDot';
import { PaneHeaderActions } from '@/components/PaneHeaderActions';
import {
ContextMenu,
ContextMenuContent,
@@ -9,12 +10,6 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useLongPress } from '@/hooks/useLongPress';
import { sessionEvents } from '@/hooks/sessionEvents';
import { cn } from '@/lib/utils';
@@ -191,90 +186,15 @@ export function ChatTabBar({
</div>
)}
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="New chat, terminal, or coder"
title="New chat / terminal / coder"
>
<Plus size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit">
{/* New BooChat opens a tab in THIS pane; terminal/coder can't be
tabs, so they split into a new pane (matches the Split menu). */}
<DropdownMenuItem onSelect={onNewTab}>
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitPane('terminal')}>
<Terminal size={14} /> New BooTerm
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitPane('coder')}>
<Code size={14} /> New BooCode
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Split pane"
title="Split pane"
>
<Columns2 size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onSelect={() => onSplitPane('chat')}>
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitPane('terminal')}>
<Terminal size={14} /> New BooTerm
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitPane('coder')}>
<Code size={14} /> New BooCode
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{onReopenPane && (
<button
type="button"
onClick={onReopenPane}
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Reopen closed pane"
title="Reopen closed pane"
>
<RotateCcw size={12} />
</button>
)}
<button
type="button"
onClick={onShowHistory}
className={cn(
'inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]',
pane.kind === 'empty' && 'text-foreground bg-muted/50'
)}
aria-label="Session history"
title="Session history"
>
<History size={12} />
</button>
{onRemovePane && (
<button
type="button"
onClick={onRemovePane}
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Close pane"
title="Close pane"
>
<X size={12} />
</button>
)}
</div>
<PaneHeaderActions
className="ml-auto px-1"
onNewTab={onNewTab}
onSplitPane={onSplitPane}
onReopenPane={onReopenPane}
onShowHistory={onShowHistory}
onRemovePane={onRemovePane}
historyActive={pane.kind === 'empty'}
/>
</div>
);
}

View File

@@ -0,0 +1,139 @@
import { Code, Columns2, History, MessageSquare, Plus, RotateCcw, Terminal, X } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
// Shared pane-header action cluster: + (new) / Split / Reopen-closed-pane /
// Session history / Close. Rendered in the chat tab bar (ChatTabBar) and the
// desktop coder + terminal pane headers (Workspace) so all pane kinds share one
// control set. Extracted to avoid a divergent copy per header.
interface Props {
// When provided (chat panes), the "+" menu's New BooChat opens an in-pane
// tab. When omitted (coder/terminal panes, which can't host tabs), New BooChat
// splits into a new pane instead.
onNewTab?: () => void;
onSplitPane: (kind: 'chat' | 'terminal' | 'coder') => void;
onReopenPane?: () => void;
onShowHistory: () => void;
onRemovePane?: () => void;
// Highlights the History button when the pane is showing the landing page.
historyActive?: boolean;
// Positioning/spacing supplied by the parent (e.g. "ml-auto px-1").
className?: string;
}
const BTN =
'inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]';
export function PaneHeaderActions({
onNewTab,
onSplitPane,
onReopenPane,
onShowHistory,
onRemovePane,
historyActive,
className,
}: Props) {
return (
<div className={cn('flex items-center gap-0.5 shrink-0', className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className={BTN}
aria-label="New chat, terminal, or coder"
title="New chat / terminal / coder"
>
<Plus size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit">
{/* Chat panes: New BooChat opens a tab in THIS pane. Coder/terminal
panes can't host tabs, so it splits into a new pane. */}
<DropdownMenuItem onSelect={onNewTab ?? (() => onSplitPane('chat'))}>
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitPane('terminal')}>
<Terminal size={14} /> New BooTerm
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitPane('coder')}>
<Code size={14} /> New BooCode
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className={cn(BTN, 'max-md:hidden')}
aria-label="Split pane"
title="Split pane"
>
<Columns2 size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onSelect={() => onSplitPane('chat')}>
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitPane('terminal')}>
<Terminal size={14} /> New BooTerm
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitPane('coder')}>
<Code size={14} /> New BooCode
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{onReopenPane && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onReopenPane();
}}
className={cn(BTN, 'max-md:hidden')}
aria-label="Reopen closed pane"
title="Reopen closed pane"
>
<RotateCcw size={12} />
</button>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onShowHistory();
}}
className={cn(BTN, 'max-md:hidden', historyActive && 'text-foreground bg-muted/50')}
aria-label="Session history"
title="Session history"
>
<History size={12} />
</button>
{onRemovePane && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemovePane();
}}
className={BTN}
aria-label="Close pane"
title="Close pane"
>
<X size={12} />
</button>
)}
</div>
);
}

View File

@@ -1,7 +1,15 @@
import { useCallback, useEffect, useState } from 'react';
import { Archive, MessageSquare, RotateCcw } from 'lucide-react';
import { Archive, Code, MessageSquare, RotateCcw, Terminal, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { ChatInput } from '@/components/ChatInput';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { api } from '@/api/client';
import type { Chat } from '@/api/types';
@@ -22,6 +30,8 @@ interface Props {
chats: Chat[];
onOpenChat: (chatId: string) => void;
onUnarchiveChat: (chatId: string) => Promise<void>;
onArchiveChat: (chatId: string) => Promise<void>;
onDeleteChat: (chatId: string) => Promise<void>;
}
function formatRelative(iso: string): string {
@@ -42,6 +52,16 @@ function byRecent(a: Chat, b: Chat): number {
return (b.updated_at ?? '').localeCompare(a.updated_at ?? '');
}
// Pick the row icon by the chat's seed name: coder and terminal panes create
// placeholder chats named 'BooCoder' / 'Terminal' (see useWorkspacePanes
// chatNameForPaneKind + the coder chat-resolve). A name heuristic keeps this
// frontend-only — matches ProjectSidebar's isCoderSessionName approach.
function iconForChat(name: string | null) {
if (name === 'BooCoder') return Code;
if (name === 'Terminal') return Terminal;
return MessageSquare;
}
export function SessionLandingPage({
projectId,
sessionId,
@@ -53,9 +73,13 @@ export function SessionLandingPage({
chats,
onOpenChat,
onUnarchiveChat,
onArchiveChat,
onDeleteChat,
}: Props) {
const [chatId, setChatId] = useState<string | null>(null);
const [archived, setArchived] = useState<Chat[]>([]);
// Plain Cancel/Confirm delete (no type-to-confirm), mirroring ProjectSidebar.
const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string | null } | null>(null);
// Archived chats aren't in the default (open-only) list, so fetch them. One
// shot on session change — the history view is transient (pick a chat and
@@ -130,14 +154,19 @@ export function SessionLandingPage({
Conversations
</h3>
<div className="space-y-0.5 mb-4">
{openChats.map((c) => (
<button
{openChats.map((c) => {
const Icon = iconForChat(c.name);
return (
<div
key={c.id}
className="group/row flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted text-sm max-md:min-h-[44px]"
>
<button
type="button"
onClick={() => onOpenChat(c.id)}
className="w-full flex items-center gap-2 text-left px-2 py-1.5 rounded hover:bg-muted text-sm max-md:min-h-[44px]"
className="flex items-center gap-2 flex-1 min-w-0 text-left"
>
<MessageSquare size={14} className="shrink-0 text-muted-foreground" />
<Icon size={14} className="shrink-0 text-muted-foreground" />
<span className="truncate shrink-0 max-w-[45%]">{c.name ?? 'New chat'}</span>
{c.last_message_preview && (
<span className="truncate flex-1 text-xs text-muted-foreground hidden sm:block">
@@ -148,7 +177,29 @@ export function SessionLandingPage({
{formatRelative(c.updated_at)}
</span>
</button>
))}
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover/row:opacity-100 focus-within:opacity-100 transition-opacity">
<button
type="button"
onClick={(e) => { e.stopPropagation(); void onArchiveChat(c.id); }}
className="inline-flex items-center justify-center size-7 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-9"
aria-label="Archive chat"
title="Archive"
>
<Archive size={14} />
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setDeleteConfirm({ id: c.id, name: c.name }); }}
className="inline-flex items-center justify-center size-7 rounded text-muted-foreground hover:bg-destructive/20 hover:text-destructive max-md:size-9"
aria-label="Delete chat"
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
</div>
);
})}
</div>
</>
)}
@@ -159,12 +210,15 @@ export function SessionLandingPage({
</h3>
<div className="space-y-0.5">
{archivedChats.map((c) => (
<button
<div
key={c.id}
className="group/arch flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted text-sm text-muted-foreground max-md:min-h-[44px]"
>
<button
type="button"
onClick={() => void restoreAndOpen(c.id)}
title="Restore and open"
className="group/arch w-full flex items-center gap-2 text-left px-2 py-1.5 rounded hover:bg-muted text-sm text-muted-foreground max-md:min-h-[44px]"
className="flex items-center gap-2 flex-1 min-w-0 text-left"
>
<Archive size={14} className="shrink-0" />
<span className="truncate flex-1">{c.name ?? 'New chat'}</span>
@@ -174,6 +228,16 @@ export function SessionLandingPage({
className="shrink-0 opacity-0 group-hover/arch:opacity-100"
/>
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setDeleteConfirm({ id: c.id, name: c.name }); }}
className="shrink-0 inline-flex items-center justify-center size-7 rounded hover:bg-destructive/20 hover:text-destructive max-md:size-9 opacity-0 group-hover/arch:opacity-100 focus-within:opacity-100 transition-opacity"
aria-label="Delete chat"
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</>
@@ -195,6 +259,31 @@ export function SessionLandingPage({
messages={[]}
modelContextLimit={null}
/>
<Dialog
open={deleteConfirm !== null}
onOpenChange={(open) => { if (!open) setDeleteConfirm(null); }}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete chat?</DialogTitle>
<DialogDescription>
Permanently deletes "{deleteConfirm?.name ?? 'New chat'}" and all its messages. This cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>Cancel</Button>
<Button
variant="destructive"
onClick={() => {
if (deleteConfirm) void onDeleteChat(deleteConfirm.id);
setDeleteConfirm(null);
}}
>
Delete
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { MessageSquare, Terminal, Code, Clipboard, Plus, X } from 'lucide-react';
import { Terminal, Code, Clipboard } from 'lucide-react';
import { api } from '@/api/client';
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
import { MAX_PANES, activePaneChatId, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
@@ -13,13 +13,8 @@ import { CoderPane } from '@/components/panes/CoderPane';
import { MarkdownArtifactPane } from '@/components/MarkdownArtifactPane';
import { HtmlArtifactPane } from '@/components/HtmlArtifactPane';
import { ChatTabBar } from '@/components/ChatTabBar';
import { PaneHeaderActions } from '@/components/PaneHeaderActions';
import { SessionLandingPage } from '@/components/SessionLandingPage';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
interface Props {
@@ -223,41 +218,13 @@ export function Workspace({
<div className="flex items-center gap-1 border-b border-border px-2 py-1 shrink-0">
<Code size={12} className="text-muted-foreground" />
<span className="text-xs text-muted-foreground">BooCode</span>
<div className="ml-auto flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="New pane"
>
<Plus size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
<Terminal size={14} /> New BooTerm
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('coder')}>
<Code size={14} /> New BooCode
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{panes.length > 1 && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); removePane(idx); }}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Close pane"
>
<X size={12} />
</button>
)}
</div>
<PaneHeaderActions
className="ml-auto"
onSplitPane={onAddPane}
onReopenPane={hasClosedPanes ? reopenPane : undefined}
onShowHistory={() => showLandingPage(idx)}
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
/>
</div>
)}
{isTerminal && (
@@ -266,30 +233,7 @@ export function Workspace({
<span className="text-xs text-muted-foreground">
{terminalLabels.get(pane.id) ?? 'Terminal'}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="ml-auto inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
aria-label="New pane"
title="New pane"
>
<Plus size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
<Terminal size={14} /> New BooTerm
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('coder')}>
<Code size={14} /> New BooCode
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="ml-auto flex items-center gap-0.5">
{/* v1.10.4: iOS Safari restricts navigator.clipboard.readText
outside direct user gestures. A real button click IS a
gesture, so this works where keystroke-driven paste may
@@ -301,26 +245,19 @@ export function Workspace({
e.stopPropagation();
terminalsRegistry.get(pane.id)?.paste();
}}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Paste from clipboard"
title="Paste from clipboard"
>
<Clipboard size={12} />
</button>
{panes.length > 1 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removePane(idx);
}}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
aria-label="Close terminal pane"
title="Close terminal pane"
>
<X size={12} />
</button>
)}
<PaneHeaderActions
onSplitPane={onAddPane}
onReopenPane={hasClosedPanes ? reopenPane : undefined}
onShowHistory={() => showLandingPage(idx)}
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
/>
</div>
</div>
)}
</div>
@@ -395,6 +332,8 @@ export function Workspace({
chats={chats}
onOpenChat={(chatId) => openChatInPane(idx, chatId)}
onUnarchiveChat={unarchiveChat}
onArchiveChat={archiveChat}
onDeleteChat={deleteChat}
/>
)}
</div>

View File

@@ -18,6 +18,7 @@ import { mergeWireToolCall } from '@/lib/coder-tools';
import { CoderMessageList, type CoderTimelineWire } from '@/components/panes/CoderMessageList';
import { providerIcon, providerLabel } from '@/components/coder/providerIcons';
import { refreshAgentSessions } from '@/hooks/useAgentSessions';
import { useAgentStatus, type AgentStatus, type AgentStatusEntry } from '@/hooks/useAgentStatus';
import { cn } from '@/lib/utils';
// ---------------------------------------------------------------------------
@@ -80,6 +81,14 @@ interface WsHandlers {
onAssistantComplete?: () => void;
onAgentCommands?: (taskId: string, commands: AgentCommand[]) => void;
onConnectedChange?: (connected: boolean) => void;
// #10: normalized external-agent status (working|blocked|idle|error) for the
// (chat,agent) carried on the frame. CoderPane records it in a live map and
// feeds the active agent's status to AgentComposerBar's status dot.
onAgentStatus?: (
chatId: string,
agent: string,
entry: AgentStatusEntry,
) => void;
}
type RawCoderMessage = {
@@ -326,6 +335,19 @@ function useCoderMessages(sessionId: string, chatId: string | undefined, handler
description: c.description,
})),
);
} else if (frame.type === 'agent_status_updated') {
// #10: { chat_id, agent, status, reason?, at }. The chat_id guard
// above already dropped cross-chat frames; record per (chat,agent).
const chatId = (frame.chat_id ?? scopedChatId) as string | undefined;
const agent = frame.agent as string | undefined;
const status = frame.status as AgentStatus | undefined;
if (chatId && agent && status) {
handlersRef.current.onAgentStatus?.(chatId, agent, {
status,
...(frame.reason ? { reason: frame.reason as string } : {}),
at: (frame.at as string) ?? new Date().toISOString(),
});
}
}
} catch {
// ignore unparseable frames
@@ -642,6 +664,8 @@ export function CoderPane({
return groups;
}, [agentCommands, skillItems, agentConfig.provider]);
// #10: live normalized status per (chat,agent), reset on chat switch below.
const agentStatus = useAgentStatus();
const { messages, setMessages, connected, loadMessages } = useCoderMessages(sessionId, chatId, {
onConnectedChange,
onPermissionRequested: (prompt) => {
@@ -661,7 +685,21 @@ export function CoderPane({
onAgentCommands: (_taskId, commands) => {
setLiveTaskCommands(commands);
},
onAgentStatus: agentStatus.record,
});
// Clear any stale status for the previous chat when the pane switches chats so
// a lingering working/blocked dot never carries into the next conversation.
useEffect(() => {
return () => agentStatus.reset(chatId);
}, [chatId, agentStatus]);
// The active agent's normalized status for this chat. null for native boocode
// (no external status published) or before any frame arrives — gates the dot.
const currentAgentStatus: AgentStatusEntry | null =
agentConfig.provider && agentConfig.provider !== 'boocode'
? agentStatus.get(chatId, agentConfig.provider)
: null;
const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId);
const { checkpointMessageIds, refreshCheckpoints } = useCheckpoints(sessionId, chatId);
const [input, setInput] = useState('');
@@ -968,6 +1006,7 @@ export function CoderPane({
connected={connected}
sessionId={sessionId}
hasPriorTurn={hasPriorTurn}
agentStatus={currentAgentStatus}
/>
{/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
<div className="flex-1 min-h-0 flex flex-col">

View File

@@ -0,0 +1,62 @@
import { useCallback, useMemo, useState } from 'react';
// Normalized external-agent status (#10). Consumed from the
// `agent_status_updated` WS frame the coder backend publishes:
// { type: 'agent_status_updated'; chat_id; agent; status; reason?; at }
// BooCoder collapses ~30 vendor lifecycle events into these four buckets:
// working — turn in flight
// blocked — waiting on a permission / approval
// idle — clean completion
// error — crash / failure
export type AgentStatus = 'working' | 'blocked' | 'idle' | 'error';
export interface AgentStatusEntry {
status: AgentStatus;
reason?: string;
at: string;
}
const key = (chatId: string, agent: string): string => `${chatId}:${agent}`;
// Per-(chat,agent) live status map. The dot reflects the latest frame for the
// active agent in the current chat; entries are reset when the chat switches so
// a stale "working"/"blocked" from a previous chat never leaks into the next.
export function useAgentStatus() {
const [map, setMap] = useState<Record<string, AgentStatusEntry>>({});
const record = useCallback(
(chatId: string, agent: string, entry: AgentStatusEntry) => {
setMap((prev) => ({ ...prev, [key(chatId, agent)]: entry }));
},
[],
);
// Drop every entry for a chat (called on chat switch). No-op when nothing
// matches so it's safe to call unconditionally from an effect.
const reset = useCallback((chatId: string | undefined) => {
setMap((prev) => {
if (!chatId) return prev;
const prefix = `${chatId}:`;
let changed = false;
const next: Record<string, AgentStatusEntry> = {};
for (const [k, v] of Object.entries(prev)) {
if (k.startsWith(prefix)) {
changed = true;
continue;
}
next[k] = v;
}
return changed ? next : prev;
});
}, []);
const get = useCallback(
(chatId: string | undefined, agent: string | undefined): AgentStatusEntry | null => {
if (!chatId || !agent) return null;
return map[key(chatId, agent)] ?? null;
},
[map],
);
return useMemo(() => ({ record, reset, get }), [record, reset, get]);
}

View File

@@ -189,6 +189,12 @@ function applyFrame(state: State, frame: WsFrame): State {
// duplicating async work inside a synchronous reducer.
return state;
}
case 'agent_status_updated': {
// agent-status-normalize (#10): coder-only frame consumed by CoderPane's
// own WS handler, not BooChat's native message reducer. No-op here to keep
// TS exhaustiveness satisfied (native sessions never emit it).
return state;
}
}
}

View File

@@ -640,13 +640,23 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
const showLandingPage = useCallback((paneIdx: number) => {
setPanes((prev) => {
const pane = prev[paneIdx];
// Coder/terminal panes are not chat hosts — history button is chat-only.
if (!pane || pane.kind === 'coder' || pane.kind === 'terminal') return prev;
if (!pane) return prev;
const next = [...prev];
if (pane.kind === 'coder' || pane.kind === 'terminal') {
// Scoped panes don't host chat tabs. Leaving one for the session
// history closes it: drop the pane→chat binding, and for terminals
// kill the tmux session (terminals are ephemeral — closing = killing,
// mirroring removePane).
if (pane.kind === 'terminal') {
api.terminals.kill(sessionId, pane.id).catch(() => { /* non-fatal */ });
}
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
} else {
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined };
}
return next;
});
}, []);
}, [sessionId]);
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'coder'): string | null => {
// Generate the id outside the updater so we can return it deterministically.

View File

@@ -0,0 +1,61 @@
# Normalized external-agent status (#10, scoped)
**Status:** in progress (started 2026-06-01)
**Source:** `boocode_code_review_v2.md` §1 #10, §5j (superset, Elastic License 2.0 — PATTERN-ONLY,
clean-room; `/opt/forks/superset/.../map-event-type.ts`, `notify-hook.template.sh`, `agent-setup/*`).
**Decision (Sam, 2026-06-01):** scoped status-publish now; config-injection notify-hook as a follow-on.
## Why (corrected premise)
BooCoder already *observes* agent lifecycle (warm-acp/opencode/SDK backends know active/idle/crashed;
the permission-waiter knows blocked) but never **publishes a normalized per-`(chat,agent)` status** to the
UI — so blocked-on-permission is invisible and crash/idle aren't pushed proactively. The `AgentComposerBar`
dot only shows WS liveness. This batch publishes the status BooCoder already knows; the heavier
config-injection notify-hook (for out-of-band signals) is the documented follow-on.
## State model (clean-room from superset's `mapEventType`)
Superset collapses ~30 vendor event names → 3 signals: **Start** (working), **PermissionRequest**
(blocked), **Stop** (done). BooCoder adds idle (after done) + error (crash/fail). Normalized status:
`working | blocked | idle | error`.
## Pinned frame contract (server + web, byte-identical, parity-tested)
```ts
{ type: 'agent_status_updated', chat_id: Uuid, agent: string,
status: 'working' | 'blocked' | 'idle' | 'error', reason?: string, at: IsoTimestamp }
```
Added to `apps/server/src/types/ws-frames.ts` AND `apps/web/src/api/ws-frames.ts` (the `ws-frames` parity
test), plus the web `WsFrame` union in `apps/web/src/api/types.ts`. Published via the coder's
`broker.publishFrame` (validated against the server `WsFrameSchema`).
## Clean-room normalize helper (built now, reused by the injection follow-on)
`apps/coder/src/services/normalize-agent-status.ts`:
`normalizeAgentEvent(raw: string): 'working' | 'blocked' | 'done' | null` — a clean-room reimplementation
of the vendor-event-name → bucket mapping (the event names are facts about each agent's hooks:
`SessionStart`/`UserPromptSubmit`/`PostToolUse`→working; `PreToolUse`/`Notification`/`PermissionRequest`/
`exec_approval_request`→blocked; `Stop`/`session_end`/`task_complete`→done). The scoped publish points use
BooCoder's own already-normalized turn boundaries; this helper exists so the config-injection follow-on
(which receives raw vendor event names POSTed from agent hooks) reuses it. Unit-tested.
## Publish points (BooCoder's existing observation — no per-backend change)
- Dispatcher (`dispatcher.ts`) turn boundaries, for every external-agent path (warm-acp/opencode/sdk/pty):
`working` at turn start, `idle` on clean completion, `error` on failure.
- Permission-waiter (`permission-waiter.ts` / the `setPermissionHooks` publish in `index.ts`): `blocked`
when a permission is requested, back to `working` when resolved.
A small `publishAgentStatus(broker, chatId, agent, status, reason?)` helper centralizes the frame.
## Frontend
- `CoderPane.tsx` tracks the latest `agent_status_updated` per `(chat, agent)` (a small live map; reset on
chat switch).
- `AgentComposerBar.tsx` renders a normalized status dot beside the existing session chip (reuse the
`StatusDot` visual language: working=spinner/green, blocked=amber, idle=gray, error=red), distinct from
the WS-liveness `connected` dot.
## Follow-on (documented, not built): config-injection notify-hook
Clean-room re-derive superset's `agent-setup`: inject a notify hook into each agent's native config
(claude `~/.claude/settings.json`, opencode plugin, codex/gemini templates) that POSTs
`{agent, chat_id, eventType}` to a new `POST /api/coder/agent-status` endpoint, which runs
`normalizeAgentEvent` → publishes the SAME `agent_status_updated` frame. Reuses everything this batch
builds. Catches out-of-band signals BooCoder's dispatch can't see.
## Verify
- `pnpm -C apps/coder test` (+ normalize-agent-status tests) + `pnpm -C apps/server test` (ws-frames parity)
- `pnpm -C apps/server build && pnpm -C apps/coder build`; `npx tsc -p apps/web/tsconfig.app.json --noEmit`