Compare commits
7 Commits
v2.7.5-cla
...
v2.7.8-emb
| Author | SHA1 | Date | |
|---|---|---|---|
| 27f3a6c463 | |||
| 3a646fd6df | |||
| 7098014261 | |||
| c56d169ef9 | |||
| b7fb254e5d | |||
| 59cf082e06 | |||
| 6fc3175730 |
12
CHANGELOG.md
12
CHANGELOG.md
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
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.
|
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.8-ember-coder-tabs-model-chips — 2026-06-01
|
||||||
|
|
||||||
|
The BooCode 2.0 visual identity plus two workflow features. **Ember theme** (`styles/themes/ember.css`, now `DEFAULT_THEME_ID`) is the signature orange-on-near-black look — rebuilt on Obsidian's flat charcoal structure (`#0c0c0e`/`#15151a`/`#1f1f23`) with `#ff7a18` swapped in for the purple, after a Reinvented-direction detour (neon borders + a scanline/glow texture overlay) was dialed back to taste; the server `theme_id` whitelist gains `ember` so it can actually be selected. The **brand banner** (`ProjectSidebar`) shows the eye-patch Westie mascot + the `>_BooCode` wordmark big and edge-to-edge on transparent backgrounds — the source PNGs shipped with baked-white canvases, so they were flood-filled to transparency from the corners (preserving the white dog, which a naive white-key would have destroyed) and cropped to bounds. **Coder panes are now multi-tab**: `+` opens a new BooCode tab (a fresh chat = a new agent context sharing the session worktree) while the split button still opens a pane — coder panes reuse the shared `ChatTabBar` via a kind-aware `tabKind`, backed by a new `createCoderTab` action with `closeOtherTabs`/tab-numbering extended to coder kind. **Model-attribution chips**: a new `messages.model` column (both apps share the table) stamped at `finalizeCompletion` (BooChat + native coder) and at the dispatcher's assistant-row creation (external coder), surfaced through the `messages_with_parts` view + wire types + the live `message_complete` frame (the Zod already allowed `model`; nothing consumed it), and rendered as a subtle accent chip with a shortened label (`shortenModelName` → `Sonnet 4.6`, `Qwen3.6 35B`) beside the message stats — so swapping models mid-coder-session stays legible. Also the composer moved its Web toggle into a boxed, focus-ringed input, tool rows lead with a glowing accent dot, and the Claude-SDK-backend follow-ups validated live this session (1M context window, follow-up-message fix, collapsed thinking/tool chips) land with `CLAUDE_SDK_BACKEND=1` flipped on. One snag fixed mid-deploy: the view's new `m.model` was first inserted mid-list and `CREATE OR REPLACE VIEW` can't reorder columns (42P16) — appended at the end. Web tsc + server + coder builds green; deployed (docker + boocoder, tools:34). Builds on `v2.7.7-pane-header-actions`.
|
||||||
|
|
||||||
|
## 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
|
## 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`.
|
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`.
|
||||||
|
|||||||
@@ -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
|
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
|
## 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.
|
- **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`.
|
- 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.
|
- 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 ~15–20s 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 })`.
|
- 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).
|
- 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.
|
- `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
|
## Workflow
|
||||||
|
|
||||||
- Sam reviews all diffs and commits manually. Do not commit unless explicitly asked.
|
- 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.
|
- 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).
|
- 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 3–6 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.
|
- `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 3–6 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).
|
- 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`.
|
- 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.
|
- 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.
|
- 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`.
|
- 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`.
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ GITEA_SSH_HOST=100.114.205.53:2222
|
|||||||
MCP_CONFIG_PATH=/data/mcp.json
|
MCP_CONFIG_PATH=/data/mcp.json
|
||||||
SKILLS_ROOT=/opt/boocode/data/skills
|
SKILLS_ROOT=/opt/boocode/data/skills
|
||||||
CODER_PROVIDERS_PATH=/opt/boocode/data/coder-providers.json
|
CODER_PROVIDERS_PATH=/opt/boocode/data/coder-providers.json
|
||||||
|
CLAUDE_SDK_BACKEND=1
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { createOrphanWorktreeReaper } from './services/orphan-worktree-reaper.js
|
|||||||
import { probeAgents } from './services/agent-probe.js';
|
import { probeAgents } from './services/agent-probe.js';
|
||||||
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
|
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
|
||||||
import { setPermissionHooks } from './services/permission-waiter.js';
|
import { setPermissionHooks } from './services/permission-waiter.js';
|
||||||
|
import { publishAgentStatus } from './services/agent-status-publish.js';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -82,6 +83,21 @@ async function main() {
|
|||||||
// Broker: in-memory pub/sub for session + user channel streaming.
|
// Broker: in-memory pub/sub for session + user channel streaming.
|
||||||
const broker = createBroker(app.log);
|
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({
|
setPermissionHooks({
|
||||||
onPrompt: async (prompt) => {
|
onPrompt: async (prompt) => {
|
||||||
await sql`
|
await sql`
|
||||||
@@ -96,6 +112,18 @@ async function main() {
|
|||||||
...(prompt.input ? { input: prompt.input } : {}),
|
...(prompt.input ? { input: prompt.input } : {}),
|
||||||
options: prompt.options.map((o) => ({ option_id: o.optionId, label: o.label })),
|
options: prompt.options.map((o) => ({ option_id: o.optionId, label: o.label })),
|
||||||
} as WsFrame);
|
} 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) => {
|
onResolved: async (taskId, sessionId) => {
|
||||||
await sql`
|
await sql`
|
||||||
@@ -106,6 +134,18 @@ async function main() {
|
|||||||
task_id: taskId,
|
task_id: taskId,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
} as WsFrame);
|
} 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',
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
110
apps/coder/src/routes/__tests__/chat-resolve.test.ts
Normal file
110
apps/coder/src/routes/__tests__/chat-resolve.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,36 @@ interface WorkspacePaneRow {
|
|||||||
activeChatIdx?: number;
|
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 {
|
function chatNameForKind(kind: string): string {
|
||||||
if (kind === 'coder' || kind === 'agent') return 'BooCoder';
|
if (kind === 'coder' || kind === 'agent') return 'BooCoder';
|
||||||
if (kind === 'terminal') return 'Terminal';
|
if (kind === 'terminal') return 'Terminal';
|
||||||
@@ -28,12 +58,13 @@ export async function resolveChatId(
|
|||||||
paneId: string,
|
paneId: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
return sql.begin(async (tx) => {
|
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
|
SELECT workspace_panes FROM sessions WHERE id = ${sessionId} FOR UPDATE
|
||||||
`;
|
`;
|
||||||
if (sessionRows.length === 0) return null;
|
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);
|
const paneIdx = panes.findIndex((p) => p.id === paneId);
|
||||||
if (paneIdx < 0) return null;
|
if (paneIdx < 0) return null;
|
||||||
|
|
||||||
@@ -69,9 +100,10 @@ export async function resolveChatId(
|
|||||||
: p,
|
: p,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const nextState: WorkspaceStateRow = { ...state, panes: nextPanes };
|
||||||
await tx`
|
await tx`
|
||||||
UPDATE sessions
|
UPDATE sessions
|
||||||
SET workspace_panes = ${tx.json(nextPanes as never)},
|
SET workspace_panes = ${tx.json(nextState as never)},
|
||||||
updated_at = clock_timestamp()
|
updated_at = clock_timestamp()
|
||||||
WHERE id = ${sessionId}
|
WHERE id = ${sessionId}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ interface MessageRow {
|
|||||||
role: string;
|
role: string;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
|
model: string | null;
|
||||||
|
ctx_used: number | null;
|
||||||
|
ctx_max: number | null;
|
||||||
tool_calls: Array<{ id: string; name: string; args?: Record<string, unknown> }> | null;
|
tool_calls: Array<{ id: string; name: string; args?: Record<string, unknown> }> | null;
|
||||||
tool_results: {
|
tool_results: {
|
||||||
tool_call_id: string;
|
tool_call_id: string;
|
||||||
@@ -88,6 +91,9 @@ function mapCoderMessageRow(row: MessageRow) {
|
|||||||
role: row.role as 'user' | 'assistant' | 'system',
|
role: row.role as 'user' | 'assistant' | 'system',
|
||||||
content: row.content ?? '',
|
content: row.content ?? '',
|
||||||
status: (row.status ?? 'complete') as 'streaming' | 'complete' | 'failed',
|
status: (row.status ?? 'complete') as 'streaming' | 'complete' | 'failed',
|
||||||
|
...(row.model ? { model: row.model } : {}),
|
||||||
|
...(row.ctx_used != null ? { ctx_used: row.ctx_used } : {}),
|
||||||
|
...(row.ctx_max != null ? { ctx_max: row.ctx_max } : {}),
|
||||||
...(reasoningText ? { reasoning_text: reasoningText } : {}),
|
...(reasoningText ? { reasoning_text: reasoningText } : {}),
|
||||||
...(tool_calls?.length ? { tool_calls } : {}),
|
...(tool_calls?.length ? { tool_calls } : {}),
|
||||||
};
|
};
|
||||||
@@ -126,13 +132,13 @@ export function registerMessageRoutes(
|
|||||||
|
|
||||||
const rows = chatId
|
const rows = chatId
|
||||||
? await sql<MessageRow[]>`
|
? await sql<MessageRow[]>`
|
||||||
SELECT id, role, content, status, tool_calls, tool_results, reasoning_parts
|
SELECT id, role, content, status, model, ctx_used, ctx_max, tool_calls, tool_results, reasoning_parts
|
||||||
FROM messages_with_parts
|
FROM messages_with_parts
|
||||||
WHERE session_id = ${sessionId} AND chat_id = ${chatId}
|
WHERE session_id = ${sessionId} AND chat_id = ${chatId}
|
||||||
ORDER BY created_at ASC, id ASC
|
ORDER BY created_at ASC, id ASC
|
||||||
`
|
`
|
||||||
: await sql<MessageRow[]>`
|
: await sql<MessageRow[]>`
|
||||||
SELECT id, role, content, status, tool_calls, tool_results, reasoning_parts
|
SELECT id, role, content, status, model, ctx_used, ctx_max, tool_calls, tool_results, reasoning_parts
|
||||||
FROM messages_with_parts
|
FROM messages_with_parts
|
||||||
WHERE session_id = ${sessionId}
|
WHERE session_id = ${sessionId}
|
||||||
ORDER BY created_at ASC, id ASC
|
ORDER BY created_at ASC, id ASC
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -82,6 +82,12 @@ export interface PromptCtx {
|
|||||||
export interface TurnResult {
|
export interface TurnResult {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
// Optional context-window telemetry (claude SDK): the model's reported window
|
||||||
|
// (ctxMax, 1M-aware) and the peak request input ≈ current fill (ctxUsed). The
|
||||||
|
// dispatcher writes these onto the assistant message so the ContextBar renders a
|
||||||
|
// real fill for the turn. Omitted by backends that don't report a window.
|
||||||
|
ctxUsed?: number;
|
||||||
|
ctxMax?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
55
apps/coder/src/services/agent-status-publish.ts
Normal file
55
apps/coder/src/services/agent-status-publish.ts
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -165,6 +165,12 @@ export class ClaudeSdkBackend implements AgentBackend {
|
|||||||
// Stream partial assistant messages so text/thinking/tool deltas arrive live
|
// Stream partial assistant messages so text/thinking/tool deltas arrive live
|
||||||
// (the mapper reads them; without this only terminal messages land).
|
// (the mapper reads them; without this only terminal messages land).
|
||||||
includePartialMessages: true,
|
includePartialMessages: true,
|
||||||
|
// BooCode default: enable the documented 1M-context-window beta. Active on
|
||||||
|
// models that support it (the SDK lists Sonnet 4/4.5); a non-supporting model
|
||||||
|
// simply doesn't get the larger window. The TRUE window is read back from
|
||||||
|
// `result.modelUsage[*].contextWindow` and shown in the ContextBar, so whatever
|
||||||
|
// window a model actually gets is surfaced truthfully (no guessing).
|
||||||
|
betas: ['context-1m-2025-08-07'],
|
||||||
...(model ? { model } : {}),
|
...(model ? { model } : {}),
|
||||||
...(resumeId ? { resume: resumeId } : {}),
|
...(resumeId ? { resume: resumeId } : {}),
|
||||||
...(this.installPath ? { pathToClaudeCodeExecutable: this.installPath } : {}),
|
...(this.installPath ? { pathToClaudeCodeExecutable: this.installPath } : {}),
|
||||||
@@ -192,6 +198,11 @@ export class ClaudeSdkBackend implements AgentBackend {
|
|||||||
|
|
||||||
this.busy = true;
|
this.busy = true;
|
||||||
const state: ClaudeSdkMapState = createClaudeSdkMapState();
|
const state: ClaudeSdkMapState = createClaudeSdkMapState();
|
||||||
|
// Peak per-request input (incl. cache) across the turn ≈ the conversation context
|
||||||
|
// held in the window. result.usage SUMS input over the turn's internal requests
|
||||||
|
// (overcounts for multi-tool turns), so the per-request peak is the accurate
|
||||||
|
// "context used" for the ContextBar (paseo's approach).
|
||||||
|
let maxInputTokens = 0;
|
||||||
// Per-turn abort: interrupt the in-flight query on the SAME generator (never
|
// Per-turn abort: interrupt the in-flight query on the SAME generator (never
|
||||||
// tear down the warm query — that's the pool's lifetime). The generator then
|
// tear down the warm query — that's the pool's lifetime). The generator then
|
||||||
// emits its terminal result and the drain loop exits.
|
// emits its terminal result and the drain loop exits.
|
||||||
@@ -214,7 +225,32 @@ export class ClaudeSdkBackend implements AgentBackend {
|
|||||||
queue.push(userMsg);
|
queue.push(userMsg);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const msg of gen) {
|
// Manual iteration — NOT `for await (… of gen)`. Returning out of a for-await
|
||||||
|
// loop calls gen.return(), which CLOSES the async generator; that killed the
|
||||||
|
// warm streaming-input query after a single turn, so every FOLLOW-UP message
|
||||||
|
// hit a dead generator and failed. gen.next() leaves the generator suspended
|
||||||
|
// (alive) for the next pushed user message — the warm query is only closed
|
||||||
|
// deliberately in teardownQuery()/dispose().
|
||||||
|
while (true) {
|
||||||
|
const next = await gen.next();
|
||||||
|
if (next.done) {
|
||||||
|
// Generator ended (e.g. disposed) without a result — non-fatal incomplete.
|
||||||
|
if (aborted) return { ok: false, error: 'aborted' };
|
||||||
|
return { ok: false, error: 'claude-sdk: query ended before result' };
|
||||||
|
}
|
||||||
|
const msg = next.value;
|
||||||
|
// Track the peak per-request input from message_start usage (delivered by
|
||||||
|
// includePartialMessages) — the largest single request's input is the real
|
||||||
|
// context fill, unlike the summed result.usage.
|
||||||
|
if (msg.type === 'stream_event') {
|
||||||
|
const sev = msg.event as { type?: string; message?: { usage?: Record<string, unknown> } };
|
||||||
|
if (sev?.type === 'message_start' && sev.message?.usage) {
|
||||||
|
const ru = sev.message.usage;
|
||||||
|
const reqInput =
|
||||||
|
num(ru.input_tokens) + num(ru.cache_read_input_tokens) + num(ru.cache_creation_input_tokens);
|
||||||
|
if (reqInput > maxInputTokens) maxInputTokens = reqInput;
|
||||||
|
}
|
||||||
|
}
|
||||||
// Capture the provider session id from the init message (authoritative).
|
// Capture the provider session id from the init message (authoritative).
|
||||||
if (msg.type === 'system' && msg.subtype === 'init' && msg.session_id) {
|
if (msg.type === 'system' && msg.subtype === 'init' && msg.session_id) {
|
||||||
if (this.agentSessionId !== msg.session_id) {
|
if (this.agentSessionId !== msg.session_id) {
|
||||||
@@ -234,19 +270,28 @@ export class ClaudeSdkBackend implements AgentBackend {
|
|||||||
await this.markIdle();
|
await this.markIdle();
|
||||||
}
|
}
|
||||||
if (aborted) return { ok: false, error: 'aborted' };
|
if (aborted) return { ok: false, error: 'aborted' };
|
||||||
return ok
|
if (!ok) return { ok: false, error: resultErrorMessage(msg) };
|
||||||
? { ok: true }
|
// Context-window telemetry for the ContextBar (paseo's method):
|
||||||
: { ok: false, error: resultErrorMessage(msg) };
|
// ctxMax = the model's OWN reported window (1M-aware — reflects the active
|
||||||
|
// window, so the bar shows the truth per model);
|
||||||
|
// ctxUsed = peak request input (history in the window) + this turn's output.
|
||||||
|
const ctxMax = extractMaxContextWindow((msg as { modelUsage?: unknown }).modelUsage);
|
||||||
|
const fallbackInput =
|
||||||
|
num(msg.usage?.input_tokens) +
|
||||||
|
num(msg.usage?.cache_read_input_tokens) +
|
||||||
|
num(msg.usage?.cache_creation_input_tokens);
|
||||||
|
const ctxUsed = (maxInputTokens || fallbackInput) + num(msg.usage?.output_tokens);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
...(ctxMax > 0 ? { ctxMax } : {}),
|
||||||
|
...(ctxUsed > 0 ? { ctxUsed } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
// Map renderable content → AgentEvents for the dispatcher's onEvent.
|
// Map renderable content → AgentEvents for the dispatcher's onEvent.
|
||||||
for (const ev of mapSdkMessage(msg, state)) {
|
for (const ev of mapSdkMessage(msg, state)) {
|
||||||
ctx.onEvent(ev);
|
ctx.onEvent(ev);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Generator ended without a result message (e.g. it was disposed) — treat as
|
|
||||||
// a non-fatal incomplete turn so the dispatcher still finalizes the row.
|
|
||||||
if (aborted) return { ok: false, error: 'aborted' };
|
|
||||||
return { ok: false, error: 'claude-sdk: query ended before result' };
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (aborted) return { ok: false, error: 'aborted' };
|
if (aborted) return { ok: false, error: 'aborted' };
|
||||||
await this.markCrashed();
|
await this.markCrashed();
|
||||||
@@ -351,6 +396,22 @@ function numF(v: unknown): number {
|
|||||||
return Number.isFinite(x) && x > 0 ? x : 0;
|
return Number.isFinite(x) && x > 0 ? x : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Largest context-window the SDK reports across `result.modelUsage` (a
|
||||||
|
* `Record<model, ModelUsage>`, each with a `contextWindow`). This is the model's
|
||||||
|
* OWN window — 1M when the 1M model/beta is active, 200K otherwise — so the
|
||||||
|
* ContextBar shows the true window without us mapping model→size ourselves. */
|
||||||
|
function extractMaxContextWindow(modelUsage: unknown): number {
|
||||||
|
if (!modelUsage || typeof modelUsage !== 'object') return 0;
|
||||||
|
let max = 0;
|
||||||
|
for (const v of Object.values(modelUsage as Record<string, unknown>)) {
|
||||||
|
if (v && typeof v === 'object') {
|
||||||
|
const cw = (v as { contextWindow?: unknown }).contextWindow;
|
||||||
|
if (typeof cw === 'number' && Number.isFinite(cw) && cw > max) max = cw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
|
||||||
/** Build a human-readable error from an SDK error-result message. */
|
/** Build a human-readable error from an SDK error-result message. */
|
||||||
function resultErrorMessage(result: Extract<SDKMessage, { type: 'result' }>): string {
|
function resultErrorMessage(result: Extract<SDKMessage, { type: 'result' }>): string {
|
||||||
if (result.subtype === 'success') return 'ok';
|
if (result.subtype === 'success') return 'ok';
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { ClaudeSdkBackend } from './backends/claude-sdk.js';
|
|||||||
import { shouldUseWarmBackend } from './backends/warm-acp-routing.js';
|
import { shouldUseWarmBackend } from './backends/warm-acp-routing.js';
|
||||||
import { shouldUseClaudeSdk } from './backends/claude-sdk-routing.js';
|
import { shouldUseClaudeSdk } from './backends/claude-sdk-routing.js';
|
||||||
import type { AgentBackend, AgentEvent } from './agent-backend.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 {
|
interface InferenceRunner {
|
||||||
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
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}`;
|
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> {
|
async function poll(): Promise<void> {
|
||||||
// `polling` serializes poll() execution itself (timer + NOTIFY can fire
|
// `polling` serializes poll() execution itself (timer + NOTIFY can fire
|
||||||
// concurrently) so we never double-select a task. It does NOT serialize task
|
// concurrently) so we never double-select a task. It does NOT serialize task
|
||||||
@@ -196,8 +213,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
const [assistantMsg] = await sql<{ id: string }[]>`
|
const [assistantMsg] = await sql<{ id: string }[]>`
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
INSERT INTO messages (session_id, chat_id, role, content, status, model, created_at)
|
||||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', ${task.model}, clock_timestamp())
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
const assistantId = assistantMsg!.id;
|
const assistantId = assistantMsg!.id;
|
||||||
@@ -298,6 +315,11 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
// Create an abort controller for this task
|
// Create an abort controller for this task
|
||||||
const ac = new AbortController();
|
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 {
|
try {
|
||||||
// Mark running
|
// Mark running
|
||||||
await sql`
|
await sql`
|
||||||
@@ -306,9 +328,6 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
let sessionId: string;
|
|
||||||
let chatId: string;
|
|
||||||
|
|
||||||
if (task.session_id) {
|
if (task.session_id) {
|
||||||
sessionId = task.session_id;
|
sessionId = task.session_id;
|
||||||
const chats = await sql<{ id: string }[]>`
|
const chats = await sql<{ id: string }[]>`
|
||||||
@@ -361,8 +380,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
let acpReasoning = '';
|
let acpReasoning = '';
|
||||||
|
|
||||||
const [assistantMsg] = await sql<{ id: string }[]>`
|
const [assistantMsg] = await sql<{ id: string }[]>`
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
INSERT INTO messages (session_id, chat_id, role, content, status, model, created_at)
|
||||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', ${task.model}, clock_timestamp())
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
const assistantId = assistantMsg!.id;
|
const assistantId = assistantMsg!.id;
|
||||||
@@ -384,6 +403,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
} as WsFrame);
|
} as WsFrame);
|
||||||
|
|
||||||
|
// #10: external-agent turn begins.
|
||||||
|
emitAgentStatus(sessionId, chatId, agent, 'working', 'turn_start');
|
||||||
|
|
||||||
const manifestCommands = getManifestCommands(agent);
|
const manifestCommands = getManifestCommands(agent);
|
||||||
if (manifestCommands.length > 0) {
|
if (manifestCommands.length > 0) {
|
||||||
setTaskCommands(taskId, manifestCommands);
|
setTaskCommands(taskId, manifestCommands);
|
||||||
@@ -558,6 +580,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`;
|
`;
|
||||||
log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)');
|
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);
|
clearTaskCommands(taskId);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -570,6 +594,11 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`.catch(() => {});
|
`.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
|
// Best-effort cleanup
|
||||||
await cleanupWorktree(projectPath, taskId);
|
await cleanupWorktree(projectPath, taskId);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
@@ -624,6 +653,10 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
|
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
|
|
||||||
|
// #10: hoisted so the catch can report `error` with the (chat, agent) key.
|
||||||
|
let sessionId = '';
|
||||||
|
let chatId = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// execution_path = 'acp' — the schema CHECK has no 'opencode_server' value
|
// 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
|
// (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
|
// it directly. Session-less creators (arena, MCP, new_task, generic
|
||||||
// /api/tasks) leave it null; fall back to resolving/creating a real chat so
|
// /api/tasks) leave it null; fall back to resolving/creating a real chat so
|
||||||
// ensureSession never receives a degenerate (null, agent) key.
|
// ensureSession never receives a degenerate (null, agent) key.
|
||||||
let sessionId: string;
|
|
||||||
let chatId: string;
|
|
||||||
if (task.chat_id && task.session_id) {
|
if (task.chat_id && task.session_id) {
|
||||||
sessionId = task.session_id;
|
sessionId = task.session_id;
|
||||||
chatId = task.chat_id;
|
chatId = task.chat_id;
|
||||||
@@ -692,8 +723,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready');
|
log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready');
|
||||||
|
|
||||||
const [assistantMsg] = await sql<{ id: string }[]>`
|
const [assistantMsg] = await sql<{ id: string }[]>`
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
INSERT INTO messages (session_id, chat_id, role, content, status, model, created_at)
|
||||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', ${task.model}, clock_timestamp())
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
const assistantId = assistantMsg!.id;
|
const assistantId = assistantMsg!.id;
|
||||||
@@ -714,6 +745,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
} as WsFrame);
|
} as WsFrame);
|
||||||
|
|
||||||
|
// #10: opencode-server turn begins.
|
||||||
|
emitAgentStatus(sessionId, chatId, agent, 'working', 'turn_start');
|
||||||
|
|
||||||
const manifestCommands = getManifestCommands(agent);
|
const manifestCommands = getManifestCommands(agent);
|
||||||
if (manifestCommands.length > 0) {
|
if (manifestCommands.length > 0) {
|
||||||
setTaskCommands(taskId, manifestCommands);
|
setTaskCommands(taskId, manifestCommands);
|
||||||
@@ -873,6 +907,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`;
|
`;
|
||||||
log.info({ taskId, agent, finalState, costTokens: extCostTokens }, 'dispatcher: task finished (opencode server)');
|
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);
|
clearTaskCommands(taskId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = err instanceof Error ? err.message : String(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)}
|
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`.catch(() => {});
|
`.catch(() => {});
|
||||||
|
// #10: turn crashed.
|
||||||
|
if (chatId) emitAgentStatus(sessionId, chatId, agent, 'error', 'crashed');
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||||
}
|
}
|
||||||
@@ -960,8 +1004,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready (warm ACP)');
|
log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready (warm ACP)');
|
||||||
|
|
||||||
const [assistantMsg] = await sql<{ id: string }[]>`
|
const [assistantMsg] = await sql<{ id: string }[]>`
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
INSERT INTO messages (session_id, chat_id, role, content, status, model, created_at)
|
||||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', ${task.model}, clock_timestamp())
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
const assistantId = assistantMsg!.id;
|
const assistantId = assistantMsg!.id;
|
||||||
@@ -982,6 +1026,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
} as WsFrame);
|
} as WsFrame);
|
||||||
|
|
||||||
|
// #10: warm-ACP turn begins.
|
||||||
|
emitAgentStatus(sessionId, chatId, agent, 'working', 'turn_start');
|
||||||
|
|
||||||
const manifestCommands = getManifestCommands(agent);
|
const manifestCommands = getManifestCommands(agent);
|
||||||
if (manifestCommands.length > 0) {
|
if (manifestCommands.length > 0) {
|
||||||
setTaskCommands(taskId, manifestCommands);
|
setTaskCommands(taskId, manifestCommands);
|
||||||
@@ -1123,6 +1170,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`;
|
`;
|
||||||
log.info({ taskId, agent, finalState }, 'dispatcher: task finished (warm ACP)');
|
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);
|
clearTaskCommands(taskId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = err instanceof Error ? err.message : String(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)}
|
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`.catch(() => {});
|
`.catch(() => {});
|
||||||
|
// #10: turn crashed.
|
||||||
|
emitAgentStatus(sessionId, chatId, agent, 'error', 'crashed');
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||||
}
|
}
|
||||||
@@ -1203,8 +1260,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready (claude SDK)');
|
log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready (claude SDK)');
|
||||||
|
|
||||||
const [assistantMsg] = await sql<{ id: string }[]>`
|
const [assistantMsg] = await sql<{ id: string }[]>`
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
INSERT INTO messages (session_id, chat_id, role, content, status, model, created_at)
|
||||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', ${task.model}, clock_timestamp())
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
const assistantId = assistantMsg!.id;
|
const assistantId = assistantMsg!.id;
|
||||||
@@ -1224,6 +1281,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
} as WsFrame);
|
} as WsFrame);
|
||||||
|
|
||||||
|
// #10: claude-SDK turn begins.
|
||||||
|
emitAgentStatus(sessionId, chatId, agent, 'working', 'turn_start');
|
||||||
|
|
||||||
const manifestCommands = getManifestCommands(agent);
|
const manifestCommands = getManifestCommands(agent);
|
||||||
if (manifestCommands.length > 0) {
|
if (manifestCommands.length > 0) {
|
||||||
setTaskCommands(taskId, manifestCommands);
|
setTaskCommands(taskId, manifestCommands);
|
||||||
@@ -1313,9 +1373,12 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
|
|
||||||
await persistExternalAgentTurn(sql, assistantId, [...toolSnaps.values()], reasoningText);
|
await persistExternalAgentTurn(sql, assistantId, [...toolSnaps.values()], reasoningText);
|
||||||
|
|
||||||
|
// ctx_used/ctx_max from the SDK result (1M-aware) → the assistant message, so
|
||||||
|
// the ContextBar renders a real context-window fill for claude.
|
||||||
await sql`
|
await sql`
|
||||||
UPDATE messages
|
UPDATE messages
|
||||||
SET content = ${assistantContent}, status = 'complete', finished_at = clock_timestamp()
|
SET content = ${assistantContent}, status = 'complete', finished_at = clock_timestamp(),
|
||||||
|
ctx_used = ${result.ctxUsed ?? null}, ctx_max = ${result.ctxMax ?? null}
|
||||||
WHERE id = ${assistantId}
|
WHERE id = ${assistantId}
|
||||||
`;
|
`;
|
||||||
broker.publishFrame(sessionId, {
|
broker.publishFrame(sessionId, {
|
||||||
@@ -1364,6 +1427,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`;
|
`;
|
||||||
log.info({ taskId, agent, finalState }, 'dispatcher: task finished (claude SDK)');
|
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);
|
clearTaskCommands(taskId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
@@ -1373,6 +1444,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`.catch(() => {});
|
`.catch(() => {});
|
||||||
|
// #10: turn crashed.
|
||||||
|
emitAgentStatus(sessionId, chatId, agent, 'error', 'crashed');
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||||
}
|
}
|
||||||
|
|||||||
92
apps/coder/src/services/normalize-agent-status.ts
Normal file
92
apps/coder/src/services/normalize-agent-status.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -441,7 +441,7 @@ export function registerChatRoutes(
|
|||||||
const rows = await sql<Message[]>`
|
const rows = await sql<Message[]>`
|
||||||
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
||||||
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
|
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
|
||||||
summary, tail_start_id, compacted_at
|
summary, tail_start_id, compacted_at, model
|
||||||
FROM messages_with_parts
|
FROM messages_with_parts
|
||||||
WHERE chat_id = ${req.params.id}
|
WHERE chat_id = ${req.params.id}
|
||||||
ORDER BY created_at ASC, id ASC
|
ORDER BY created_at ASC, id ASC
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export function registerMessageRoutes(
|
|||||||
const rows = await sql<Message[]>`
|
const rows = await sql<Message[]>`
|
||||||
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
||||||
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
|
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
|
||||||
summary, tail_start_id, compacted_at
|
summary, tail_start_id, compacted_at, model
|
||||||
FROM messages_with_parts
|
FROM messages_with_parts
|
||||||
WHERE session_id = ${req.params.id}
|
WHERE session_id = ${req.params.id}
|
||||||
ORDER BY created_at ASC, id ASC
|
ORDER BY created_at ASC, id ASC
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ export async function setSetting(
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// themes-v1: whitelist of the 18 preset theme ids. Kept in sync with
|
// themes-v1: whitelist of the preset theme ids. Kept in sync with
|
||||||
// docs/themes_v1.md §1 and apps/web/src/lib/theme.ts THEMES.
|
// docs/themes_v1.md §1 and apps/web/src/lib/theme.ts THEMES.
|
||||||
|
// (+ 'ember' — the BooCode 2.0 signature, now the default.)
|
||||||
const THEME_IDS = [
|
const THEME_IDS = [
|
||||||
'obsidian',
|
'obsidian',
|
||||||
'gunmetal',
|
'gunmetal',
|
||||||
@@ -43,6 +44,7 @@ const THEME_IDS = [
|
|||||||
'chalk',
|
'chalk',
|
||||||
'cobalt',
|
'cobalt',
|
||||||
'midnight-sapphire',
|
'midnight-sapphire',
|
||||||
|
'ember',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const THEME_MODES = ['dark', 'light', 'system'] as const;
|
const THEME_MODES = ['dark', 'light', 'system'] as const;
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function registerWebSocket(
|
|||||||
const messages = await sql<Message[]>`
|
const messages = await sql<Message[]>`
|
||||||
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
||||||
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
|
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
|
||||||
summary, tail_start_id, compacted_at
|
summary, tail_start_id, compacted_at, model
|
||||||
FROM messages_with_parts
|
FROM messages_with_parts
|
||||||
WHERE session_id = ${sessionId}
|
WHERE session_id = ${sessionId}
|
||||||
ORDER BY created_at ASC, id ASC
|
ORDER BY created_at ASC, id ASC
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ END $$;
|
|||||||
-- a single jsonb object {tool_call_id, output, truncated, error?}.
|
-- a single jsonb object {tool_call_id, output, truncated, error?}.
|
||||||
-- reasoning_parts is consumed by the inference history fetch (payload.ts)
|
-- reasoning_parts is consumed by the inference history fetch (payload.ts)
|
||||||
-- for v1.13.1-C reasoning round-tripping. Not surfaced in external APIs.
|
-- for v1.13.1-C reasoning round-tripping. Not surfaced in external APIs.
|
||||||
|
-- model-attribution: which model produced an assistant message (NULL for
|
||||||
|
-- user/system rows and pre-existing messages). Stamped at finalize (BooChat /
|
||||||
|
-- native coder) and at assistant-row creation (external coder dispatcher).
|
||||||
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS model TEXT;
|
||||||
|
|
||||||
CREATE OR REPLACE VIEW messages_with_parts AS
|
CREATE OR REPLACE VIEW messages_with_parts AS
|
||||||
SELECT
|
SELECT
|
||||||
m.id, m.session_id, m.chat_id, m.role, m.content, m.kind, m.status,
|
m.id, m.session_id, m.chat_id, m.role, m.content, m.kind, m.status,
|
||||||
@@ -122,7 +127,10 @@ SELECT
|
|||||||
ORDER BY p.sequence LIMIT 1) AS tool_results,
|
ORDER BY p.sequence LIMIT 1) AS tool_results,
|
||||||
(SELECT jsonb_agg(p.payload ORDER BY p.sequence)
|
(SELECT jsonb_agg(p.payload ORDER BY p.sequence)
|
||||||
FROM message_parts p
|
FROM message_parts p
|
||||||
WHERE p.message_id = m.id AND p.kind = 'reasoning' AND p.hidden_at IS NULL) AS reasoning_parts
|
WHERE p.message_id = m.id AND p.kind = 'reasoning' AND p.hidden_at IS NULL) AS reasoning_parts,
|
||||||
|
-- NEW columns MUST be appended at the end: CREATE OR REPLACE VIEW can't
|
||||||
|
-- reorder/rename existing columns (42P16). m.model added last.
|
||||||
|
m.model
|
||||||
FROM messages m;
|
FROM messages m;
|
||||||
|
|
||||||
-- v1.13.20: drop legacy tool_calls/tool_results columns. Reads have routed
|
-- v1.13.20: drop legacy tool_calls/tool_results columns. Reads have routed
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export async function finalizeCompletion(
|
|||||||
tokens_used = ${completionTokens},
|
tokens_used = ${completionTokens},
|
||||||
ctx_used = ${promptTokens},
|
ctx_used = ${promptTokens},
|
||||||
ctx_max = ${nCtx},
|
ctx_max = ${nCtx},
|
||||||
|
model = ${session.model},
|
||||||
finished_at = clock_timestamp()
|
finished_at = clock_timestamp()
|
||||||
WHERE id = ${assistantMessageId}
|
WHERE id = ${assistantMessageId}
|
||||||
RETURNING tokens_used, ctx_used, ctx_max, finished_at
|
RETURNING tokens_used, ctx_used, ctx_max, finished_at
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ const ChatStatusValue = z.enum([
|
|||||||
'error',
|
'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([
|
const ErrorReasonValue = z.enum([
|
||||||
'llm_provider_error',
|
'llm_provider_error',
|
||||||
'doom_loop',
|
'doom_loop',
|
||||||
@@ -301,6 +307,21 @@ export const AgentCommandsFrame = z.object({
|
|||||||
commands: z.array(AgentCommandShape),
|
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 ---------------------------------------------------
|
// ---- discriminated union ---------------------------------------------------
|
||||||
|
|
||||||
export const WsFrameSchema = z.discriminatedUnion('type', [
|
export const WsFrameSchema = z.discriminatedUnion('type', [
|
||||||
@@ -320,6 +341,7 @@ export const WsFrameSchema = z.discriminatedUnion('type', [
|
|||||||
PermissionRequestedFrame,
|
PermissionRequestedFrame,
|
||||||
PermissionResolvedFrame,
|
PermissionResolvedFrame,
|
||||||
AgentCommandsFrame,
|
AgentCommandsFrame,
|
||||||
|
AgentStatusUpdatedFrame,
|
||||||
// per-user
|
// per-user
|
||||||
ChatStatusFrame,
|
ChatStatusFrame,
|
||||||
SessionUpdatedFrame,
|
SessionUpdatedFrame,
|
||||||
@@ -361,6 +383,7 @@ export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
|
|||||||
'permission_requested',
|
'permission_requested',
|
||||||
'permission_resolved',
|
'permission_resolved',
|
||||||
'agent_commands',
|
'agent_commands',
|
||||||
|
'agent_status_updated',
|
||||||
'chat_status',
|
'chat_status',
|
||||||
'session_updated',
|
'session_updated',
|
||||||
'session_renamed',
|
'session_renamed',
|
||||||
|
|||||||
@@ -201,6 +201,9 @@ export interface Message {
|
|||||||
tokens_used: number | null;
|
tokens_used: number | null;
|
||||||
ctx_used: number | null;
|
ctx_used: number | null;
|
||||||
ctx_max: number | null;
|
ctx_max: number | null;
|
||||||
|
// model-attribution: which model produced this assistant message (null for
|
||||||
|
// user/system rows + pre-attribution messages). Rendered as a chip.
|
||||||
|
model: string | null;
|
||||||
started_at: string | null;
|
started_at: string | null;
|
||||||
finished_at: string | null;
|
finished_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -351,7 +354,13 @@ export interface CoderMessageWire {
|
|||||||
role: 'user' | 'assistant' | 'system';
|
role: 'user' | 'assistant' | 'system';
|
||||||
content: string;
|
content: string;
|
||||||
status?: 'streaming' | 'complete' | 'failed';
|
status?: 'streaming' | 'complete' | 'failed';
|
||||||
|
// model-attribution: which model produced this coder assistant message.
|
||||||
|
model?: string | null;
|
||||||
reasoning_text?: string;
|
reasoning_text?: string;
|
||||||
|
// Context-window fill for the ContextBar (claude SDK turns set these from the
|
||||||
|
// SDK's reported window; other agents omit them). Read via the Message cast.
|
||||||
|
ctx_used?: number | null;
|
||||||
|
ctx_max?: number | null;
|
||||||
tool_calls?: Array<{
|
tool_calls?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
function: { name: string; arguments: string };
|
function: { name: string; arguments: string };
|
||||||
@@ -571,6 +580,8 @@ export type WsFrame =
|
|||||||
ctx_max?: number | null;
|
ctx_max?: number | null;
|
||||||
started_at?: string | null;
|
started_at?: string | null;
|
||||||
finished_at?: string | null;
|
finished_at?: string | null;
|
||||||
|
// model-attribution: the model that produced this assistant message.
|
||||||
|
model?: string | null;
|
||||||
// v1.8.2: piggybacks the persisted metadata onto the terminal frame so
|
// v1.8.2: piggybacks the persisted metadata onto the terminal frame so
|
||||||
// cap-hit sentinels (and any future stamped-on-complete metadata) flow
|
// cap-hit sentinels (and any future stamped-on-complete metadata) flow
|
||||||
// to the client without a refetch.
|
// to the client without a refetch.
|
||||||
@@ -596,4 +607,16 @@ export type WsFrame =
|
|||||||
| { type: 'compacted'; session_id: string; chat_id: string; summary_message_id: string }
|
| { type: 'compacted'; session_id: string; chat_id: string; summary_message_id: string }
|
||||||
// v1.8.2: `reason` discriminates structured failures (the UI prefers it
|
// v1.8.2: `reason` discriminates structured failures (the UI prefers it
|
||||||
// over `error` text when present).
|
// 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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ const ChatStatusValue = z.enum([
|
|||||||
'error',
|
'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([
|
const ErrorReasonValue = z.enum([
|
||||||
'llm_provider_error',
|
'llm_provider_error',
|
||||||
'doom_loop',
|
'doom_loop',
|
||||||
@@ -301,6 +307,21 @@ export const AgentCommandsFrame = z.object({
|
|||||||
commands: z.array(AgentCommandShape),
|
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 ---------------------------------------------------
|
// ---- discriminated union ---------------------------------------------------
|
||||||
|
|
||||||
export const WsFrameSchema = z.discriminatedUnion('type', [
|
export const WsFrameSchema = z.discriminatedUnion('type', [
|
||||||
@@ -320,6 +341,7 @@ export const WsFrameSchema = z.discriminatedUnion('type', [
|
|||||||
PermissionRequestedFrame,
|
PermissionRequestedFrame,
|
||||||
PermissionResolvedFrame,
|
PermissionResolvedFrame,
|
||||||
AgentCommandsFrame,
|
AgentCommandsFrame,
|
||||||
|
AgentStatusUpdatedFrame,
|
||||||
// per-user
|
// per-user
|
||||||
ChatStatusFrame,
|
ChatStatusFrame,
|
||||||
SessionUpdatedFrame,
|
SessionUpdatedFrame,
|
||||||
@@ -361,6 +383,7 @@ export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
|
|||||||
'permission_requested',
|
'permission_requested',
|
||||||
'permission_resolved',
|
'permission_resolved',
|
||||||
'agent_commands',
|
'agent_commands',
|
||||||
|
'agent_status_updated',
|
||||||
'chat_status',
|
'chat_status',
|
||||||
'session_updated',
|
'session_updated',
|
||||||
'session_renamed',
|
'session_renamed',
|
||||||
|
|||||||
BIN
apps/web/src/assets/brand/banner-mascot.png
Normal file
BIN
apps/web/src/assets/brand/banner-mascot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 910 KiB |
BIN
apps/web/src/assets/brand/banner-wordmark.png
Normal file
BIN
apps/web/src/assets/brand/banner-wordmark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 685 KiB |
BIN
apps/web/src/assets/brand/boo-badge.png
Normal file
BIN
apps/web/src/assets/brand/boo-badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
apps/web/src/assets/brand/boocode-icon.png
Normal file
BIN
apps/web/src/assets/brand/boocode-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
BIN
apps/web/src/assets/brand/boocode-wordmark-tight.png
Normal file
BIN
apps/web/src/assets/brand/boocode-wordmark-tight.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
BIN
apps/web/src/assets/brand/boocode-wordmark.png
Normal file
BIN
apps/web/src/assets/brand/boocode-wordmark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -3,6 +3,7 @@ import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bot } from 'luci
|
|||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
|
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
|
||||||
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
|
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
|
||||||
|
import type { AgentStatusEntry } from '@/hooks/useAgentStatus';
|
||||||
import { providerIcon } from '@/components/coder/providerIcons';
|
import { providerIcon } from '@/components/coder/providerIcons';
|
||||||
import { useAgentSessions } from '@/hooks/useAgentSessions';
|
import { useAgentSessions } from '@/hooks/useAgentSessions';
|
||||||
import {
|
import {
|
||||||
@@ -183,6 +184,11 @@ interface Props {
|
|||||||
// True once the chat has at least one prior turn — gates the chip so it stays
|
// 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).
|
// hidden on a brand-new chat. Defaults to false (no chip).
|
||||||
hasPriorTurn?: boolean;
|
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".
|
// 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`;
|
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);
|
const allEntries = useProviderSnapshot(projectPath);
|
||||||
// 5.5 — the composer picker only offers ENABLED providers that are ready (or
|
// 5.5 — the composer picker only offers ENABLED providers that are ready (or
|
||||||
// still loading). Disabled (enabled:false) and unavailable/error providers are
|
// 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
|
{/* 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. */}
|
stays on the top line instead of wrapping past the edge-pinned dot. */}
|
||||||
<div className="ml-auto flex items-center gap-1 shrink-0">
|
<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 && (
|
{connected !== undefined && (
|
||||||
<span
|
<span
|
||||||
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', connected ? 'bg-green-500' : 'bg-red-500')}
|
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', connected ? 'bg-green-500' : 'bg-red-500')}
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
|
||||||
import { Check, ListPlus, Plus, Send, Square } from 'lucide-react';
|
import { Globe, ListPlus, Send, Square } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import {
|
import {
|
||||||
flattenToMessage,
|
flattenToMessage,
|
||||||
inferLanguage,
|
inferLanguage,
|
||||||
@@ -598,39 +592,9 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
onChange={onAgentChange}
|
onChange={onAgentChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{sessionId && (
|
{/* BooCode 2.0: the web-search toggle moved out of this top toolbar
|
||||||
<DropdownMenu>
|
into the composer box's bottom controls row (the Web pill below),
|
||||||
<DropdownMenuTrigger asChild>
|
leaving the top row as just the agent picker + context bar. */}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Quick toggles"
|
|
||||||
title="Quick toggles"
|
|
||||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Plus className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={async () => {
|
|
||||||
// v1.9: tri-state collapses to two on the wire when toggled
|
|
||||||
// here. null (inherit) treated as off; click flips to true.
|
|
||||||
// To restore "inherit" the user opens SettingsPane.
|
|
||||||
const next = webSearchEnabled === true ? false : true;
|
|
||||||
try {
|
|
||||||
await api.sessions.update(sessionId, { web_search_enabled: next });
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : 'failed to toggle web search');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<Check className={`size-3 ${webSearchEnabled === true ? 'opacity-100' : 'opacity-0'}`} />
|
|
||||||
Enable web search and fetch
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
{/* v1.11.5.1: ContextBar fills the remaining horizontal space.
|
{/* v1.11.5.1: ContextBar fills the remaining horizontal space.
|
||||||
`flex-1 min-w-0` is set inside the component. Mounts only when
|
`flex-1 min-w-0` is set inside the component. Mounts only when
|
||||||
the caller passes `messages` so older call sites (without the
|
the caller passes `messages` so older call sites (without the
|
||||||
@@ -640,54 +604,86 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="px-4 py-3 flex items-end gap-2">
|
{/* BooCode 2.0 composer: textarea + a bottom controls row live INSIDE one
|
||||||
<Textarea
|
bordered, focus-ringed message box (Refreshed direction). */}
|
||||||
ref={textareaRef}
|
<div className="px-4 py-3">
|
||||||
value={value}
|
<div className="rounded-xl border bg-card transition-colors focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/15">
|
||||||
onChange={handleChange}
|
<Textarea
|
||||||
onKeyDown={onKeyDown}
|
ref={textareaRef}
|
||||||
onPaste={onPaste}
|
value={value}
|
||||||
placeholder={
|
onChange={handleChange}
|
||||||
isMobile
|
onKeyDown={onKeyDown}
|
||||||
? 'Ask about this project. Tap send to submit.'
|
onPaste={onPaste}
|
||||||
: 'Ask about this project. Enter to send · Shift+Enter for newline.'
|
placeholder={
|
||||||
}
|
isMobile
|
||||||
disabled={disabled || busy}
|
? 'Ask about this project. Tap send to submit.'
|
||||||
rows={3}
|
: 'Ask about this project. Enter to send · Shift+Enter for newline.'
|
||||||
className="resize-none min-h-[68px] max-h-[240px]"
|
}
|
||||||
/>
|
disabled={disabled || busy}
|
||||||
{(() => {
|
rows={3}
|
||||||
const hasContent = value.trim().length > 0 || attachments.length > 0;
|
className="resize-none min-h-[56px] max-h-[240px] border-0 bg-transparent px-3 pt-2.5 shadow-none focus-visible:ring-0 dark:bg-transparent"
|
||||||
// While generating with an empty draft, the button stops generation.
|
/>
|
||||||
if (generating && onStop && !hasContent) {
|
{/* bottom controls row: Web toggle on the left, Send/Stop on the right */}
|
||||||
return (
|
<div className="flex items-center gap-1.5 px-2 pb-2 pt-0.5">
|
||||||
<Button
|
{sessionId && (
|
||||||
onClick={() => void onStop()}
|
<button
|
||||||
size="icon-lg"
|
type="button"
|
||||||
variant="outline"
|
onClick={async () => {
|
||||||
aria-label="Stop generating"
|
// v1.9 tri-state collapses to two on toggle; null (inherit) → on.
|
||||||
title="Stop generating"
|
const next = webSearchEnabled === true ? false : true;
|
||||||
|
try {
|
||||||
|
await api.sessions.update(sessionId, { web_search_enabled: next });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'failed to toggle web search');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-pressed={webSearchEnabled === true}
|
||||||
|
title="Web search & fetch"
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors max-md:min-h-[36px] ${
|
||||||
|
webSearchEnabled === true
|
||||||
|
? 'border-primary/40 bg-primary/10 text-primary'
|
||||||
|
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Square className="fill-current size-3.5" />
|
<Globe className="size-3.5" />
|
||||||
</Button>
|
Web
|
||||||
);
|
</button>
|
||||||
}
|
)}
|
||||||
// With a draft, submit. While generating the caller queues it, so the
|
<div className="flex-1" />
|
||||||
// button reads as Queue; otherwise it's a normal Send.
|
{(() => {
|
||||||
const queueing = !!generating && hasContent;
|
const hasContent = value.trim().length > 0 || attachments.length > 0;
|
||||||
return (
|
// While generating with an empty draft, the button stops generation.
|
||||||
<Button
|
if (generating && onStop && !hasContent) {
|
||||||
onClick={() => void submit()}
|
return (
|
||||||
disabled={disabled || busy || !hasContent}
|
<Button
|
||||||
size="icon-lg"
|
onClick={() => void onStop()}
|
||||||
variant={queueing ? 'secondary' : 'default'}
|
size="icon"
|
||||||
aria-label={queueing ? 'Queue message' : 'Send'}
|
variant="outline"
|
||||||
title={queueing ? 'Queue message' : 'Send'}
|
aria-label="Stop generating"
|
||||||
>
|
title="Stop generating"
|
||||||
{queueing ? <ListPlus /> : <Send />}
|
>
|
||||||
</Button>
|
<Square className="fill-current size-3.5" />
|
||||||
);
|
</Button>
|
||||||
})()}
|
);
|
||||||
|
}
|
||||||
|
// With a draft, submit. While generating the caller queues it, so the
|
||||||
|
// button reads as Queue; otherwise it's a normal Send.
|
||||||
|
const queueing = !!generating && hasContent;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={() => void submit()}
|
||||||
|
disabled={disabled || busy || !hasContent}
|
||||||
|
size="icon"
|
||||||
|
variant={queueing ? 'secondary' : 'default'}
|
||||||
|
aria-label={queueing ? 'Queue message' : 'Send'}
|
||||||
|
title={queueing ? 'Queue message' : 'Send'}
|
||||||
|
>
|
||||||
|
{queueing ? <ListPlus /> : <Send />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AttachmentPreviewModal
|
<AttachmentPreviewModal
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Code, Columns2, History, MessageSquare, Plus, RotateCcw, Terminal, X } from 'lucide-react';
|
import { Code, History, MessageSquare, X } from 'lucide-react';
|
||||||
import type { Chat, WorkspacePane } from '@/api/types';
|
import type { Chat, WorkspacePane } from '@/api/types';
|
||||||
import { StatusDot } from '@/components/StatusDot';
|
import { StatusDot } from '@/components/StatusDot';
|
||||||
|
import { PaneHeaderActions } from '@/components/PaneHeaderActions';
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
@@ -9,12 +10,6 @@ import {
|
|||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from '@/components/ui/context-menu';
|
} from '@/components/ui/context-menu';
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import { useLongPress } from '@/hooks/useLongPress';
|
import { useLongPress } from '@/hooks/useLongPress';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -22,6 +17,9 @@ import { cn } from '@/lib/utils';
|
|||||||
interface Props {
|
interface Props {
|
||||||
pane: WorkspacePane;
|
pane: WorkspacePane;
|
||||||
tabs: Chat[];
|
tabs: Chat[];
|
||||||
|
// Host pane kind — 'coder' shows the Code glyph + routes the "+" to a new
|
||||||
|
// BooCode tab. Defaults to 'chat' (the BooChat tab bar).
|
||||||
|
tabKind?: 'chat' | 'coder';
|
||||||
// v2.6.x (Batch 3a): stable session-scoped tab number per chat id. Keyed by
|
// v2.6.x (Batch 3a): stable session-scoped tab number per chat id. Keyed by
|
||||||
// chat.id, NEVER by tab position.
|
// chat.id, NEVER by tab position.
|
||||||
tabNumbers: Record<string, number>;
|
tabNumbers: Record<string, number>;
|
||||||
@@ -41,6 +39,7 @@ interface Props {
|
|||||||
export function ChatTabBar({
|
export function ChatTabBar({
|
||||||
pane,
|
pane,
|
||||||
tabs,
|
tabs,
|
||||||
|
tabKind = 'chat',
|
||||||
tabNumbers,
|
tabNumbers,
|
||||||
onSwitchTab,
|
onSwitchTab,
|
||||||
onRemoveTab,
|
onRemoveTab,
|
||||||
@@ -56,6 +55,8 @@ export function ChatTabBar({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||||
const [renameValue, setRenameValue] = useState('');
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
const TabIcon = tabKind === 'coder' ? Code : MessageSquare;
|
||||||
|
const newLabel = tabKind === 'coder' ? 'New BooCode' : 'New chat';
|
||||||
|
|
||||||
// Long-press: dispatch a synthetic contextmenu event on the tab so the
|
// Long-press: dispatch a synthetic contextmenu event on the tab so the
|
||||||
// existing Radix ContextMenuTrigger opens at the touch coordinates. Works
|
// existing Radix ContextMenuTrigger opens at the touch coordinates. Works
|
||||||
@@ -109,7 +110,7 @@ export function ChatTabBar({
|
|||||||
: 'bg-muted/30 text-muted-foreground hover:bg-muted/60'
|
: 'bg-muted/30 text-muted-foreground hover:bg-muted/60'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MessageSquare size={12} className="shrink-0" />
|
<TabIcon size={12} className="shrink-0" />
|
||||||
<StatusDot chatId={chat.id} />
|
<StatusDot chatId={chat.id} />
|
||||||
{renamingId === chat.id ? (
|
{renamingId === chat.id ? (
|
||||||
<input
|
<input
|
||||||
@@ -147,7 +148,7 @@ export function ChatTabBar({
|
|||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
<ContextMenuItem onSelect={onNewTab}>
|
<ContextMenuItem onSelect={onNewTab}>
|
||||||
New chat
|
{newLabel}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
@@ -191,90 +192,16 @@ export function ChatTabBar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
|
<PaneHeaderActions
|
||||||
<DropdownMenu>
|
className="ml-auto px-1"
|
||||||
<DropdownMenuTrigger asChild>
|
onNewTab={onNewTab}
|
||||||
<button
|
tabKind={tabKind}
|
||||||
type="button"
|
onSplitPane={onSplitPane}
|
||||||
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]"
|
onReopenPane={onReopenPane}
|
||||||
aria-label="New chat, terminal, or coder"
|
onShowHistory={onShowHistory}
|
||||||
title="New chat / terminal / coder"
|
onRemovePane={onRemovePane}
|
||||||
>
|
historyActive={pane.kind === 'empty'}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,18 +19,24 @@ interface Props {
|
|||||||
// the same boundaries the server's auto-compaction triggers.
|
// the same boundaries the server's auto-compaction triggers.
|
||||||
const COMPACTION_BUFFER = 20_000;
|
const COMPACTION_BUFFER = 20_000;
|
||||||
|
|
||||||
// Walk newest-first; first message with both ctx_used and ctx_max non-null
|
// Take the latest ctx_used and the latest ctx_max INDEPENDENTLY (newest-first).
|
||||||
// AND ctx_max > 0 wins. Older messages may have ctx_used but missing ctx_max
|
// They needn't be on the same message: ctx_max is the model's context window — a
|
||||||
// (early v1 before llama-swap's n_ctx capture worked) — skip them and keep
|
// constant per model — while some agents report it only intermittently (the claude
|
||||||
// walking. Returns null when no usable pair exists in the chat.
|
// SDK populates modelUsage.contextWindow on some turns, not all) yet report
|
||||||
|
// ctx_used every turn. Pairing the latest of each gives a correct used/max even
|
||||||
|
// when the most recent turn omitted the window. Native BooChat sets both on the
|
||||||
|
// same assistant message, so this is identical there. Returns null until BOTH a
|
||||||
|
// used and a positive max have been seen at least once.
|
||||||
function latestPair(messages: Message[]): { used: number; max: number } | null {
|
function latestPair(messages: Message[]): { used: number; max: number } | null {
|
||||||
|
let used: number | null = null;
|
||||||
|
let max: number | null = null;
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
const m = messages[i]!;
|
const m = messages[i]!;
|
||||||
if (m.ctx_used == null || m.ctx_max == null) continue;
|
if (used === null && m.ctx_used != null) used = m.ctx_used;
|
||||||
if (m.ctx_max <= 0) continue;
|
if (max === null && m.ctx_max != null && m.ctx_max > 0) max = m.ctx_max;
|
||||||
return { used: m.ctx_used, max: m.ctx_max };
|
if (used !== null && max !== null) break;
|
||||||
}
|
}
|
||||||
return null;
|
return used !== null && max !== null ? { used, max } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ColorTier {
|
interface ColorTier {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { Chat, ErrorReason, Message } from '@/api/types';
|
|||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
|
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
|
||||||
|
import { shortenModelName } from '@/lib/modelName';
|
||||||
import { CapHitSentinel } from './CapHitSentinel';
|
import { CapHitSentinel } from './CapHitSentinel';
|
||||||
import { DoomLoopSentinel } from './DoomLoopSentinel';
|
import { DoomLoopSentinel } from './DoomLoopSentinel';
|
||||||
import { MarkdownRenderer } from './MarkdownRenderer';
|
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||||
@@ -608,12 +609,12 @@ function SummaryCard({ message }: { message: Message }) {
|
|||||||
|
|
||||||
// Collapsible "Thinking" block for assistant reasoning. Fed by either
|
// Collapsible "Thinking" block for assistant reasoning. Fed by either
|
||||||
// reasoning_text (coder wire / live reasoning_delta stream) or reasoning_parts
|
// reasoning_text (coder wire / live reasoning_delta stream) or reasoning_parts
|
||||||
// (native inference, persisted from message_parts). Auto-expands while the turn
|
// (native inference, persisted from message_parts). Starts COLLAPSED to start
|
||||||
// is still streaming so the user watches it think (Paseo-style), then stays
|
// (a quiet chip) — for native BooChat/BooCode and the external agents (opencode,
|
||||||
// where the user left it once the turn completes — initial state is captured
|
// claude SDK) alike — so the transcript stays tidy; click to expand. The
|
||||||
// once at mount, so we never fight a manual collapse on later re-renders.
|
// `streaming` pulse still animates while the turn runs.
|
||||||
function ReasoningBlock({ text, streaming }: { text: string; streaming: boolean }) {
|
function ReasoningBlock({ text, streaming }: { text: string; streaming: boolean }) {
|
||||||
const [expanded, setExpanded] = useState(() => streaming);
|
const [expanded, setExpanded] = useState(false);
|
||||||
return (
|
return (
|
||||||
<div className="max-w-[90%] rounded-lg border bg-muted/30 text-sm">
|
<div className="max-w-[90%] rounded-lg border bg-muted/30 text-sm">
|
||||||
<button
|
<button
|
||||||
@@ -768,7 +769,7 @@ export function MessageBubble({
|
|||||||
return (
|
return (
|
||||||
<div className="group flex flex-col items-end gap-1">
|
<div className="group flex flex-col items-end gap-1">
|
||||||
<SendToTerminalMenu>
|
<SendToTerminalMenu>
|
||||||
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
|
<div className="boo-user-bubble max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
|
||||||
{message.content}
|
{message.content}
|
||||||
</div>
|
</div>
|
||||||
</SendToTerminalMenu>
|
</SendToTerminalMenu>
|
||||||
@@ -782,6 +783,8 @@ export function MessageBubble({
|
|||||||
// v1.13.7: match the MessageList.flatten trim guard so a whitespace-only
|
// v1.13.7: match the MessageList.flatten trim guard so a whitespace-only
|
||||||
// assistant turn doesn't render an empty bubble + dangling ActionRow.
|
// assistant turn doesn't render an empty bubble + dangling ActionRow.
|
||||||
const hasContent = message.content.trim().length > 0;
|
const hasContent = message.content.trim().length > 0;
|
||||||
|
// model-attribution chip: short label for the model that produced this turn.
|
||||||
|
const modelLabel = shortenModelName(message.model);
|
||||||
// Reasoning arrives as a pre-joined string (coder wire) or as parts (native
|
// Reasoning arrives as a pre-joined string (coder wire) or as parts (native
|
||||||
// inference). Read whichever is present; loose ?? chain tolerates the coder
|
// inference). Read whichever is present; loose ?? chain tolerates the coder
|
||||||
// shape where reasoning_parts is undefined (see CLAUDE.md null-guard note).
|
// shape where reasoning_parts is undefined (see CLAUDE.md null-guard note).
|
||||||
@@ -823,6 +826,14 @@ export function MessageBubble({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!isStreaming && (modelLabel || null) && (
|
||||||
|
<span
|
||||||
|
className="inline-flex w-fit items-center rounded-full border border-primary/25 bg-primary/10 px-2 py-0.5 text-[10px] font-mono text-primary/90"
|
||||||
|
title={message.model ?? undefined}
|
||||||
|
>
|
||||||
|
{modelLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{!isStreaming && <StatsLine message={message} />}
|
{!isStreaming && <StatsLine message={message} />}
|
||||||
{!isStreaming && hasContent && (
|
{!isStreaming && hasContent && (
|
||||||
<ActionRow
|
<ActionRow
|
||||||
|
|||||||
148
apps/web/src/components/PaneHeaderActions.tsx
Normal file
148
apps/web/src/components/PaneHeaderActions.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
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, the "+" menu item matching `tabKind` opens an in-pane tab
|
||||||
|
// (e.g. chat panes: New BooChat → tab; coder panes: New BooCode → tab). Every
|
||||||
|
// OTHER kind splits into a new pane. When onNewTab is omitted (terminal
|
||||||
|
// panes, which can't host tabs) all three items split.
|
||||||
|
onNewTab?: () => void;
|
||||||
|
// The host pane's own kind — the "+" item of this kind becomes "new tab".
|
||||||
|
// Defaults to 'chat' for back-compat with the chat tab bar.
|
||||||
|
tabKind?: 'chat' | 'terminal' | 'coder';
|
||||||
|
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,
|
||||||
|
tabKind = 'chat',
|
||||||
|
onSplitPane,
|
||||||
|
onReopenPane,
|
||||||
|
onShowHistory,
|
||||||
|
onRemovePane,
|
||||||
|
historyActive,
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
|
// The "+" item of the host pane's own kind adds a tab; every other kind
|
||||||
|
// splits into a new pane. Falls back to split when onNewTab is absent.
|
||||||
|
const newOrSplit = (kind: 'chat' | 'terminal' | 'coder') =>
|
||||||
|
onNewTab && tabKind === kind ? onNewTab : () => onSplitPane(kind);
|
||||||
|
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">
|
||||||
|
{/* The item matching the host pane's kind opens an in-pane tab; the
|
||||||
|
others split into a new pane. (tabKind defaults to 'chat'.) */}
|
||||||
|
<DropdownMenuItem onSelect={newOrSplit('chat')}>
|
||||||
|
<MessageSquare size={14} /> New BooChat
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={newOrSplit('terminal')}>
|
||||||
|
<Terminal size={14} /> New BooTerm
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={newOrSplit('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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
|||||||
import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus, Settings as SettingsIcon, X, Code } from 'lucide-react';
|
import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus, Settings as SettingsIcon, X, Code } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import mascot from '@/assets/brand/banner-mascot.png';
|
||||||
|
import wordmark from '@/assets/brand/banner-wordmark.png';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
@@ -307,9 +309,22 @@ export function ProjectSidebar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={asideCls}>
|
<aside className={asideCls}>
|
||||||
<div className="px-4 py-3 border-b flex items-center justify-between">
|
<div className="px-2 py-1 border-b flex items-center justify-between gap-1">
|
||||||
<NavLink to="/" className="font-semibold tracking-tight text-base">
|
{/* BooCode brand banner: mascot badge + >_BooCode wordmark, big and
|
||||||
BooCode
|
visible, on transparent backgrounds (no chip, no blend). */}
|
||||||
|
<NavLink to="/" aria-label="BooCode home" className="flex items-center gap-0.5 min-w-0 flex-1">
|
||||||
|
<img
|
||||||
|
src={mascot}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
className="h-12 w-auto select-none shrink-0"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src={wordmark}
|
||||||
|
alt="BooCode"
|
||||||
|
draggable={false}
|
||||||
|
className="h-12 w-auto select-none min-w-0 flex-1 object-contain object-left"
|
||||||
|
/>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button size="icon-sm" variant="ghost" onClick={() => setAddOpen(true)} aria-label="Add project">
|
<Button size="icon-sm" variant="ghost" onClick={() => setAddOpen(true)} aria-label="Add project">
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
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 { toast } from 'sonner';
|
||||||
import { ChatInput } from '@/components/ChatInput';
|
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 { api } from '@/api/client';
|
||||||
import type { Chat } from '@/api/types';
|
import type { Chat } from '@/api/types';
|
||||||
|
|
||||||
@@ -22,6 +30,8 @@ interface Props {
|
|||||||
chats: Chat[];
|
chats: Chat[];
|
||||||
onOpenChat: (chatId: string) => void;
|
onOpenChat: (chatId: string) => void;
|
||||||
onUnarchiveChat: (chatId: string) => Promise<void>;
|
onUnarchiveChat: (chatId: string) => Promise<void>;
|
||||||
|
onArchiveChat: (chatId: string) => Promise<void>;
|
||||||
|
onDeleteChat: (chatId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelative(iso: string): string {
|
function formatRelative(iso: string): string {
|
||||||
@@ -42,6 +52,16 @@ function byRecent(a: Chat, b: Chat): number {
|
|||||||
return (b.updated_at ?? '').localeCompare(a.updated_at ?? '');
|
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({
|
export function SessionLandingPage({
|
||||||
projectId,
|
projectId,
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -53,9 +73,13 @@ export function SessionLandingPage({
|
|||||||
chats,
|
chats,
|
||||||
onOpenChat,
|
onOpenChat,
|
||||||
onUnarchiveChat,
|
onUnarchiveChat,
|
||||||
|
onArchiveChat,
|
||||||
|
onDeleteChat,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [chatId, setChatId] = useState<string | null>(null);
|
const [chatId, setChatId] = useState<string | null>(null);
|
||||||
const [archived, setArchived] = useState<Chat[]>([]);
|
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
|
// 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
|
// shot on session change — the history view is transient (pick a chat and
|
||||||
@@ -130,25 +154,52 @@ export function SessionLandingPage({
|
|||||||
Conversations
|
Conversations
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-0.5 mb-4">
|
<div className="space-y-0.5 mb-4">
|
||||||
{openChats.map((c) => (
|
{openChats.map((c) => {
|
||||||
<button
|
const Icon = iconForChat(c.name);
|
||||||
key={c.id}
|
return (
|
||||||
type="button"
|
<div
|
||||||
onClick={() => onOpenChat(c.id)}
|
key={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="group/row flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted text-sm max-md:min-h-[44px]"
|
||||||
>
|
>
|
||||||
<MessageSquare size={14} className="shrink-0 text-muted-foreground" />
|
<button
|
||||||
<span className="truncate shrink-0 max-w-[45%]">{c.name ?? 'New chat'}</span>
|
type="button"
|
||||||
{c.last_message_preview && (
|
onClick={() => onOpenChat(c.id)}
|
||||||
<span className="truncate flex-1 text-xs text-muted-foreground hidden sm:block">
|
className="flex items-center gap-2 flex-1 min-w-0 text-left"
|
||||||
{c.last_message_preview}
|
>
|
||||||
</span>
|
<Icon size={14} className="shrink-0 text-muted-foreground" />
|
||||||
)}
|
<span className="truncate shrink-0 max-w-[45%]">{c.name ?? 'New chat'}</span>
|
||||||
<span className="shrink-0 ml-auto text-xs text-muted-foreground">
|
{c.last_message_preview && (
|
||||||
{formatRelative(c.updated_at)}
|
<span className="truncate flex-1 text-xs text-muted-foreground hidden sm:block">
|
||||||
</span>
|
{c.last_message_preview}
|
||||||
</button>
|
</span>
|
||||||
))}
|
)}
|
||||||
|
<span className="shrink-0 ml-auto text-xs text-muted-foreground">
|
||||||
|
{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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -159,21 +210,34 @@ export function SessionLandingPage({
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{archivedChats.map((c) => (
|
{archivedChats.map((c) => (
|
||||||
<button
|
<div
|
||||||
key={c.id}
|
key={c.id}
|
||||||
type="button"
|
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]"
|
||||||
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]"
|
|
||||||
>
|
>
|
||||||
<Archive size={14} className="shrink-0" />
|
<button
|
||||||
<span className="truncate flex-1">{c.name ?? 'New chat'}</span>
|
type="button"
|
||||||
<span className="shrink-0 text-xs">{formatRelative(c.updated_at)}</span>
|
onClick={() => void restoreAndOpen(c.id)}
|
||||||
<RotateCcw
|
title="Restore and open"
|
||||||
size={13}
|
className="flex items-center gap-2 flex-1 min-w-0 text-left"
|
||||||
className="shrink-0 opacity-0 group-hover/arch:opacity-100"
|
>
|
||||||
/>
|
<Archive size={14} className="shrink-0" />
|
||||||
</button>
|
<span className="truncate flex-1">{c.name ?? 'New chat'}</span>
|
||||||
|
<span className="shrink-0 text-xs">{formatRelative(c.updated_at)}</span>
|
||||||
|
<RotateCcw
|
||||||
|
size={13}
|
||||||
|
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -195,6 +259,31 @@ export function SessionLandingPage({
|
|||||||
messages={[]}
|
messages={[]}
|
||||||
modelContextLimit={null}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,8 +149,13 @@ export function ToolCallLine({ run, insideGroup }: Props) {
|
|||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className="flex items-center gap-1.5 w-full text-left hover:bg-muted/40 rounded px-1 py-0.5 -mx-1"
|
className="flex items-center gap-1.5 w-full text-left hover:bg-muted/40 rounded px-1 py-0.5 -mx-1"
|
||||||
>
|
>
|
||||||
|
{/* BooCode 2.0: glowing activity indicator (was ↳ / >_) */}
|
||||||
{!insideGroup && (
|
{!insideGroup && (
|
||||||
<span className="text-muted-foreground/60 select-none shrink-0">↳</span>
|
<span
|
||||||
|
className="size-1.5 rounded-full bg-primary shrink-0"
|
||||||
|
style={{ boxShadow: '0 0 6px var(--primary)' }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className={`size-3 text-muted-foreground/60 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`}
|
className={`size-3 text-muted-foreground/60 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { MessageSquare, Terminal, Code, Clipboard, Plus, X } from 'lucide-react';
|
import { Terminal, Clipboard } from 'lucide-react';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
|
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
|
||||||
import { MAX_PANES, activePaneChatId, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
|
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 { MarkdownArtifactPane } from '@/components/MarkdownArtifactPane';
|
||||||
import { HtmlArtifactPane } from '@/components/HtmlArtifactPane';
|
import { HtmlArtifactPane } from '@/components/HtmlArtifactPane';
|
||||||
import { ChatTabBar } from '@/components/ChatTabBar';
|
import { ChatTabBar } from '@/components/ChatTabBar';
|
||||||
|
import { PaneHeaderActions } from '@/components/PaneHeaderActions';
|
||||||
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -65,6 +60,7 @@ export function Workspace({
|
|||||||
closeAllTabs,
|
closeAllTabs,
|
||||||
showLandingPage,
|
showLandingPage,
|
||||||
addSplitPane,
|
addSplitPane,
|
||||||
|
createCoderTab,
|
||||||
removePane,
|
removePane,
|
||||||
reopenPane,
|
reopenPane,
|
||||||
hasClosedPanes,
|
hasClosedPanes,
|
||||||
@@ -219,46 +215,27 @@ export function Workspace({
|
|||||||
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Coder panes host BooCode tabs (one chat = one agent context,
|
||||||
|
all sharing the session worktree). "+" adds a tab; the split
|
||||||
|
button adds a pane. Same tab strip as chat panes (tabKind). */}
|
||||||
{isCoder && !isMobile && (
|
{isCoder && !isMobile && (
|
||||||
<div className="flex items-center gap-1 border-b border-border px-2 py-1 shrink-0">
|
<ChatTabBar
|
||||||
<Code size={12} className="text-muted-foreground" />
|
pane={pane}
|
||||||
<span className="text-xs text-muted-foreground">BooCode</span>
|
tabs={chatsForPane(pane)}
|
||||||
<div className="ml-auto flex items-center gap-1">
|
tabKind="coder"
|
||||||
<DropdownMenu>
|
tabNumbers={tabNumbers}
|
||||||
<DropdownMenuTrigger asChild>
|
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
|
||||||
<button
|
onRemoveTab={(chatId) => removeTab(idx, chatId)}
|
||||||
type="button"
|
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
|
||||||
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
onCloseAll={() => closeAllTabs(idx)}
|
||||||
aria-label="New pane"
|
onNewTab={() => void createCoderTab(idx)}
|
||||||
>
|
onSplitPane={(kind) => onAddPane(kind)}
|
||||||
<Plus size={12} />
|
onReopenPane={hasClosedPanes ? reopenPane : undefined}
|
||||||
</button>
|
onShowHistory={() => showLandingPage(idx)}
|
||||||
</DropdownMenuTrigger>
|
onRename={renameChat}
|
||||||
<DropdownMenuContent align="end" className="w-fit">
|
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{isTerminal && (
|
{isTerminal && (
|
||||||
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
|
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
|
||||||
@@ -266,61 +243,31 @@ export function Workspace({
|
|||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{terminalLabels.get(pane.id) ?? 'Terminal'}
|
{terminalLabels.get(pane.id) ?? 'Terminal'}
|
||||||
</span>
|
</span>
|
||||||
<DropdownMenu>
|
<div className="ml-auto flex items-center gap-0.5">
|
||||||
<DropdownMenuTrigger asChild>
|
{/* v1.10.4: iOS Safari restricts navigator.clipboard.readText
|
||||||
<button
|
outside direct user gestures. A real button click IS a
|
||||||
type="button"
|
gesture, so this works where keystroke-driven paste may
|
||||||
onClick={(e) => e.stopPropagation()}
|
not on iOS. The action lives in TerminalPane behind the
|
||||||
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"
|
registry's paste() callback. */}
|
||||||
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>
|
|
||||||
{/* 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
|
|
||||||
not on iOS. The action lives in TerminalPane behind the
|
|
||||||
registry's paste() callback. */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
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"
|
|
||||||
aria-label="Paste from clipboard"
|
|
||||||
title="Paste from clipboard"
|
|
||||||
>
|
|
||||||
<Clipboard size={12} />
|
|
||||||
</button>
|
|
||||||
{panes.length > 1 && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
removePane(idx);
|
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="Close terminal pane"
|
aria-label="Paste from clipboard"
|
||||||
title="Close terminal pane"
|
title="Paste from clipboard"
|
||||||
>
|
>
|
||||||
<X size={12} />
|
<Clipboard size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
<PaneHeaderActions
|
||||||
|
onSplitPane={onAddPane}
|
||||||
|
onReopenPane={hasClosedPanes ? reopenPane : undefined}
|
||||||
|
onShowHistory={() => showLandingPage(idx)}
|
||||||
|
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -395,6 +342,8 @@ export function Workspace({
|
|||||||
chats={chats}
|
chats={chats}
|
||||||
onOpenChat={(chatId) => openChatInPane(idx, chatId)}
|
onOpenChat={(chatId) => openChatInPane(idx, chatId)}
|
||||||
onUnarchiveChat={unarchiveChat}
|
onUnarchiveChat={unarchiveChat}
|
||||||
|
onArchiveChat={archiveChat}
|
||||||
|
onDeleteChat={deleteChat}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { mergeWireToolCall } from '@/lib/coder-tools';
|
|||||||
import { CoderMessageList, type CoderTimelineWire } from '@/components/panes/CoderMessageList';
|
import { CoderMessageList, type CoderTimelineWire } from '@/components/panes/CoderMessageList';
|
||||||
import { providerIcon, providerLabel } from '@/components/coder/providerIcons';
|
import { providerIcon, providerLabel } from '@/components/coder/providerIcons';
|
||||||
import { refreshAgentSessions } from '@/hooks/useAgentSessions';
|
import { refreshAgentSessions } from '@/hooks/useAgentSessions';
|
||||||
|
import { useAgentStatus, type AgentStatus, type AgentStatusEntry } from '@/hooks/useAgentStatus';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -80,6 +81,14 @@ interface WsHandlers {
|
|||||||
onAssistantComplete?: () => void;
|
onAssistantComplete?: () => void;
|
||||||
onAgentCommands?: (taskId: string, commands: AgentCommand[]) => void;
|
onAgentCommands?: (taskId: string, commands: AgentCommand[]) => void;
|
||||||
onConnectedChange?: (connected: boolean) => 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 = {
|
type RawCoderMessage = {
|
||||||
@@ -326,6 +335,19 @@ function useCoderMessages(sessionId: string, chatId: string | undefined, handler
|
|||||||
description: c.description,
|
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 {
|
} catch {
|
||||||
// ignore unparseable frames
|
// ignore unparseable frames
|
||||||
@@ -642,6 +664,8 @@ export function CoderPane({
|
|||||||
return groups;
|
return groups;
|
||||||
}, [agentCommands, skillItems, agentConfig.provider]);
|
}, [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, {
|
const { messages, setMessages, connected, loadMessages } = useCoderMessages(sessionId, chatId, {
|
||||||
onConnectedChange,
|
onConnectedChange,
|
||||||
onPermissionRequested: (prompt) => {
|
onPermissionRequested: (prompt) => {
|
||||||
@@ -661,7 +685,21 @@ export function CoderPane({
|
|||||||
onAgentCommands: (_taskId, commands) => {
|
onAgentCommands: (_taskId, commands) => {
|
||||||
setLiveTaskCommands(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 { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId);
|
||||||
const { checkpointMessageIds, refreshCheckpoints } = useCheckpoints(sessionId, chatId);
|
const { checkpointMessageIds, refreshCheckpoints } = useCheckpoints(sessionId, chatId);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
@@ -968,6 +1006,7 @@ export function CoderPane({
|
|||||||
connected={connected}
|
connected={connected}
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
hasPriorTurn={hasPriorTurn}
|
hasPriorTurn={hasPriorTurn}
|
||||||
|
agentStatus={currentAgentStatus}
|
||||||
/>
|
/>
|
||||||
{/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
|
{/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
|
||||||
<div className="flex-1 min-h-0 flex flex-col">
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
|
|||||||
62
apps/web/src/hooks/useAgentStatus.ts
Normal file
62
apps/web/src/hooks/useAgentStatus.ts
Normal 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]);
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ function applyFrame(state: State, frame: WsFrame): State {
|
|||||||
tokens_used: null,
|
tokens_used: null,
|
||||||
ctx_used: null,
|
ctx_used: null,
|
||||||
ctx_max: null,
|
ctx_max: null,
|
||||||
|
model: null,
|
||||||
started_at: null,
|
started_at: null,
|
||||||
finished_at: null,
|
finished_at: null,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
@@ -105,6 +106,7 @@ function applyFrame(state: State, frame: WsFrame): State {
|
|||||||
tokens_used: null,
|
tokens_used: null,
|
||||||
ctx_used: null,
|
ctx_used: null,
|
||||||
ctx_max: null,
|
ctx_max: null,
|
||||||
|
model: null,
|
||||||
started_at: null,
|
started_at: null,
|
||||||
finished_at: null,
|
finished_at: null,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
@@ -123,6 +125,7 @@ function applyFrame(state: State, frame: WsFrame): State {
|
|||||||
...(frame.ctx_max !== undefined ? { ctx_max: frame.ctx_max } : {}),
|
...(frame.ctx_max !== undefined ? { ctx_max: frame.ctx_max } : {}),
|
||||||
...(frame.started_at !== undefined ? { started_at: frame.started_at } : {}),
|
...(frame.started_at !== undefined ? { started_at: frame.started_at } : {}),
|
||||||
...(frame.finished_at !== undefined ? { finished_at: frame.finished_at } : {}),
|
...(frame.finished_at !== undefined ? { finished_at: frame.finished_at } : {}),
|
||||||
|
...(frame.model !== undefined ? { model: frame.model } : {}),
|
||||||
// v1.8.2: cap-hit sentinels (and future stamped metadata) ride
|
// v1.8.2: cap-hit sentinels (and future stamped metadata) ride
|
||||||
// in on this terminal frame so the reducer can attach it
|
// in on this terminal frame so the reducer can attach it
|
||||||
// without waiting for a refetch.
|
// without waiting for a refetch.
|
||||||
@@ -189,6 +192,12 @@ function applyFrame(state: State, frame: WsFrame): State {
|
|||||||
// duplicating async work inside a synchronous reducer.
|
// duplicating async work inside a synchronous reducer.
|
||||||
return state;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,8 @@ export interface UseWorkspacePanesResult {
|
|||||||
// id to update mobile URL state so the URL-sync effect doesn't fight the
|
// id to update mobile URL state so the URL-sync effect doesn't fight the
|
||||||
// freshly-set activePaneIdx.
|
// freshly-set activePaneIdx.
|
||||||
addSplitPane: (kind: 'chat' | 'terminal' | 'coder') => string | null;
|
addSplitPane: (kind: 'chat' | 'terminal' | 'coder') => string | null;
|
||||||
|
/** Append a new BooCode tab to an existing coder pane (the coder "+"). */
|
||||||
|
createCoderTab: (paneIdx: number) => Promise<void>;
|
||||||
// Open-on-first-click, close-on-second-click. Singleton — settings panes
|
// Open-on-first-click, close-on-second-click. Singleton — settings panes
|
||||||
// don't count toward MAX_PANES. Closing the only remaining pane (edge case)
|
// don't count toward MAX_PANES. Closing the only remaining pane (edge case)
|
||||||
// falls back to an empty pane to preserve the "always one pane" invariant.
|
// falls back to an empty pane to preserve the "always one pane" invariant.
|
||||||
@@ -265,6 +267,42 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
[sessionId, attachChatToPane, markPaneChatPending],
|
[sessionId, attachChatToPane, markPaneChatPending],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Add a new BooCode tab to an existing coder pane (the "+" in the coder pane
|
||||||
|
// header). Creates a fresh chat row (= a new agent context that shares the
|
||||||
|
// session worktree) and APPENDS it to the pane's chatIds, keeping the pane
|
||||||
|
// kind 'coder' and focusing the new tab. Mirrors createChat for chat panes;
|
||||||
|
// the per-pane "split into a new pane" action stays addSplitPane.
|
||||||
|
const createCoderTab = useCallback(
|
||||||
|
async (paneIdx: number) => {
|
||||||
|
const paneId = panes[paneIdx]?.id;
|
||||||
|
if (!paneId) return;
|
||||||
|
markPaneChatPending(paneId, true);
|
||||||
|
try {
|
||||||
|
const chat = await api.chats.create(sessionId, { name: chatNameForPaneKind('coder') });
|
||||||
|
setPanes((prev) => {
|
||||||
|
const idx = prev.findIndex((p) => p.id === paneId);
|
||||||
|
if (idx < 0) return prev;
|
||||||
|
const pane = prev[idx]!;
|
||||||
|
const newIds = [...pane.chatIds, chat.id];
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = {
|
||||||
|
...pane,
|
||||||
|
kind: 'coder',
|
||||||
|
chatId: chat.id,
|
||||||
|
chatIds: newIds,
|
||||||
|
activeChatIdx: newIds.length - 1,
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to create coder tab');
|
||||||
|
} finally {
|
||||||
|
markPaneChatPending(paneId, false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sessionId, panes, markPaneChatPending],
|
||||||
|
);
|
||||||
|
|
||||||
const seedEmptyScopedPanes = useCallback(
|
const seedEmptyScopedPanes = useCallback(
|
||||||
(paneList: WorkspacePane[]) => {
|
(paneList: WorkspacePane[]) => {
|
||||||
for (const pane of paneList) {
|
for (const pane of paneList) {
|
||||||
@@ -426,16 +464,16 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
}, [sessionId, panes, tabNumbers, nextTabNumber, closedPaneStack]);
|
}, [sessionId, panes, tabNumbers, nextTabNumber, closedPaneStack]);
|
||||||
|
|
||||||
// v2.6.x (Batch 3a): maintain stable, session-scoped tab numbers. Collect the
|
// v2.6.x (Batch 3a): maintain stable, session-scoped tab numbers. Collect the
|
||||||
// chat ids that appear in CHAT-kind panes in deterministic order (pane index,
|
// chat ids that appear in CHAT- or CODER-kind panes in deterministic order
|
||||||
// then tab index). Assign numbers to any without one (global per session,
|
// (pane index, then tab index). Assign numbers to any without one (global per
|
||||||
// only ever increasing, never reused) and prune entries whose chat is no
|
// session, only ever increasing, never reused) and prune entries whose chat
|
||||||
// longer in any chat-kind pane. Guarded against render loops: only setState
|
// is no longer in any tab-hosting pane. Guarded against render loops: only
|
||||||
// when something actually changed.
|
// setState when something actually changed.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const liveChatIds: string[] = [];
|
const liveChatIds: string[] = [];
|
||||||
const liveSet = new Set<string>();
|
const liveSet = new Set<string>();
|
||||||
for (const pane of panes) {
|
for (const pane of panes) {
|
||||||
if (pane.kind !== 'chat') continue;
|
if (pane.kind !== 'chat' && pane.kind !== 'coder') continue;
|
||||||
for (const id of pane.chatIds) {
|
for (const id of pane.chatIds) {
|
||||||
if (!liveSet.has(id)) {
|
if (!liveSet.has(id)) {
|
||||||
liveSet.add(id);
|
liveSet.add(id);
|
||||||
@@ -597,9 +635,9 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
const pane = next[paneIdx]!;
|
const pane = next[paneIdx]!;
|
||||||
const keepIdx = pane.chatIds.indexOf(keepChatId);
|
const keepIdx = pane.chatIds.indexOf(keepChatId);
|
||||||
if (keepIdx < 0) return prev;
|
if (keepIdx < 0) return prev;
|
||||||
|
// Preserve pane.kind (...pane) — a coder pane stays a coder pane.
|
||||||
next[paneIdx] = {
|
next[paneIdx] = {
|
||||||
...pane,
|
...pane,
|
||||||
kind: 'chat',
|
|
||||||
chatId: keepChatId,
|
chatId: keepChatId,
|
||||||
chatIds: [keepChatId],
|
chatIds: [keepChatId],
|
||||||
activeChatIdx: 0,
|
activeChatIdx: 0,
|
||||||
@@ -640,13 +678,23 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
const showLandingPage = useCallback((paneIdx: number) => {
|
const showLandingPage = useCallback((paneIdx: number) => {
|
||||||
setPanes((prev) => {
|
setPanes((prev) => {
|
||||||
const pane = prev[paneIdx];
|
const pane = prev[paneIdx];
|
||||||
// Coder/terminal panes are not chat hosts — history button is chat-only.
|
if (!pane) return prev;
|
||||||
if (!pane || pane.kind === 'coder' || pane.kind === 'terminal') return prev;
|
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined };
|
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;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, [sessionId]);
|
||||||
|
|
||||||
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'coder'): string | null => {
|
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'coder'): string | null => {
|
||||||
// Generate the id outside the updater so we can return it deterministically.
|
// Generate the id outside the updater so we can return it deterministically.
|
||||||
@@ -944,6 +992,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
closeAllTabs,
|
closeAllTabs,
|
||||||
showLandingPage,
|
showLandingPage,
|
||||||
addSplitPane,
|
addSplitPane,
|
||||||
|
createCoderTab,
|
||||||
toggleSettingsPane,
|
toggleSettingsPane,
|
||||||
removePane,
|
removePane,
|
||||||
reopenPane,
|
reopenPane,
|
||||||
|
|||||||
32
apps/web/src/lib/modelName.ts
Normal file
32
apps/web/src/lib/modelName.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// model-attribution: turn a raw model id into a short, friendly label for the
|
||||||
|
// per-message model chip (e.g. "claude-sonnet-4-6" → "Sonnet 4.6",
|
||||||
|
// "qwen3.6-35b-a3b-mxfp4" → "Qwen3.6 35B"). Strips provider prefixes and maps
|
||||||
|
// the common families; falls back to the cleaned id so unknown models still
|
||||||
|
// read. Returns null for empty/absent input so the caller can skip the chip.
|
||||||
|
export function shortenModelName(model: string | null | undefined): string | null {
|
||||||
|
if (!model) return null;
|
||||||
|
let m = model.trim();
|
||||||
|
if (!m) return null;
|
||||||
|
|
||||||
|
// opencode / provider-prefixed ids: "llama-swap/qwen…", "anthropic/claude…".
|
||||||
|
const slash = m.lastIndexOf('/');
|
||||||
|
if (slash >= 0) m = m.slice(slash + 1);
|
||||||
|
|
||||||
|
// claude-{opus,sonnet,haiku}-X-Y[-date] → "Opus X.Y".
|
||||||
|
const claude = /^claude-(opus|sonnet|haiku)-(\d+)-(\d+)/i.exec(m);
|
||||||
|
if (claude) {
|
||||||
|
const tier = claude[1]!.charAt(0).toUpperCase() + claude[1]!.slice(1).toLowerCase();
|
||||||
|
return `${tier} ${claude[2]}.${claude[3]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// qwen3.6-35b-a3b-… → "Qwen3.6 35B".
|
||||||
|
const qwen = /^qwen([\d.]+)-(\d+)b/i.exec(m);
|
||||||
|
if (qwen) return `Qwen${qwen[1]} ${qwen[2]}B`;
|
||||||
|
|
||||||
|
// gpt-4o, gpt-5-… → "GPT-4o" / "GPT-5".
|
||||||
|
const gpt = /^gpt-([\w.-]+)/i.exec(m);
|
||||||
|
if (gpt) return `GPT-${gpt[1]}`;
|
||||||
|
|
||||||
|
// Fallback: keep the id readable, cap the length for the chip.
|
||||||
|
return m.length > 26 ? `${m.slice(0, 25)}…` : m;
|
||||||
|
}
|
||||||
@@ -24,7 +24,8 @@ export type ThemeId =
|
|||||||
| 'ivory'
|
| 'ivory'
|
||||||
| 'chalk'
|
| 'chalk'
|
||||||
| 'cobalt'
|
| 'cobalt'
|
||||||
| 'midnight-sapphire';
|
| 'midnight-sapphire'
|
||||||
|
| 'ember';
|
||||||
|
|
||||||
export type ThemeMode = 'dark' | 'light' | 'system';
|
export type ThemeMode = 'dark' | 'light' | 'system';
|
||||||
|
|
||||||
@@ -74,9 +75,13 @@ export const THEMES: readonly ThemeMeta[] = [
|
|||||||
anchors: ['#020817', '#061434', '#0c2244', '#3060a0', '#0047ab'] },
|
anchors: ['#020817', '#061434', '#0c2244', '#3060a0', '#0047ab'] },
|
||||||
{ id: 'midnight-sapphire', name: 'Midnight Sapphire', family: 'Blue', supportsDark: true, supportsLight: true,
|
{ id: 'midnight-sapphire', name: 'Midnight Sapphire', family: 'Blue', supportsDark: true, supportsLight: true,
|
||||||
anchors: ['#02050e', '#060c1f', '#0e1a36', '#4a6088', '#1e3a8a'] },
|
anchors: ['#02050e', '#060c1f', '#0e1a36', '#4a6088', '#1e3a8a'] },
|
||||||
|
{ id: 'ember', name: 'BooCode Ember', family: 'Amber', supportsDark: true, supportsLight: true,
|
||||||
|
anchors: ['#0c0c0e', '#15151a', '#1f1f23', '#6b6b75', '#ff7a18'] },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const DEFAULT_THEME_ID: ThemeId = 'obsidian';
|
// BooCode 2.0: orange-on-black "BooCode Ember" is the out-of-the-box signature
|
||||||
|
// (was 'obsidian' / purple). Also the dark fallback for the light-only themes.
|
||||||
|
export const DEFAULT_THEME_ID: ThemeId = 'ember';
|
||||||
export const DEFAULT_THEME_MODE: ThemeMode = 'dark';
|
export const DEFAULT_THEME_MODE: ThemeMode = 'dark';
|
||||||
export const STORAGE_KEY = 'boocode.theme';
|
export const STORAGE_KEY = 'boocode.theme';
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
@import "./themes/chalk.css";
|
@import "./themes/chalk.css";
|
||||||
@import "./themes/cobalt.css";
|
@import "./themes/cobalt.css";
|
||||||
@import "./themes/midnight-sapphire.css";
|
@import "./themes/midnight-sapphire.css";
|
||||||
|
@import "./themes/ember.css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
|||||||
76
apps/web/src/styles/themes/ember.css
Normal file
76
apps/web/src/styles/themes/ember.css
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/* BooCode Ember (family: Amber) — the signature brand theme. Mirrors the
|
||||||
|
Obsidian theme's flat charcoal structure (same neutrals, flat hairline
|
||||||
|
borders), with ember orange (--accent #ff7a18) swapped in for Obsidian's
|
||||||
|
purple. Dark anchors: #0c0c0e #15151a #1f1f23 #6b6b75 #ff7a18. */
|
||||||
|
.theme-ember {
|
||||||
|
--background: #fafafa;
|
||||||
|
--foreground: #18181b;
|
||||||
|
--card: #f4f4f5;
|
||||||
|
--card-foreground: #18181b;
|
||||||
|
--popover: #f4f4f5;
|
||||||
|
--popover-foreground: #18181b;
|
||||||
|
--primary: #e25f00;
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--secondary: #e4e4e7;
|
||||||
|
--secondary-foreground: #18181b;
|
||||||
|
--muted: #e4e4e7;
|
||||||
|
--muted-foreground: #71717a;
|
||||||
|
--accent: #e25f00;
|
||||||
|
--accent-foreground: #ffffff;
|
||||||
|
--destructive: #b91c1c;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
--border: #e4e4e7;
|
||||||
|
--input: #e4e4e7;
|
||||||
|
--ring: #e25f00;
|
||||||
|
--sidebar: #f4f4f5;
|
||||||
|
--sidebar-foreground: #18181b;
|
||||||
|
--sidebar-primary: #e25f00;
|
||||||
|
--sidebar-primary-foreground: #ffffff;
|
||||||
|
--sidebar-accent: #e4e4e7;
|
||||||
|
--sidebar-accent-foreground: #18181b;
|
||||||
|
--sidebar-border: #e4e4e7;
|
||||||
|
--sidebar-ring: #e25f00;
|
||||||
|
}
|
||||||
|
.theme-ember.dark {
|
||||||
|
--background: #0c0c0e;
|
||||||
|
--foreground: #ece9f0;
|
||||||
|
--card: #15151a;
|
||||||
|
--card-foreground: #ece9f0;
|
||||||
|
--popover: #15151a;
|
||||||
|
--popover-foreground: #ece9f0;
|
||||||
|
--primary: #ff7a18;
|
||||||
|
--primary-foreground: #120a04;
|
||||||
|
--secondary: #1f1f23;
|
||||||
|
--secondary-foreground: #ece9f0;
|
||||||
|
--muted: #1f1f23;
|
||||||
|
--muted-foreground: #6b6b75;
|
||||||
|
--accent: #ff7a18;
|
||||||
|
--accent-foreground: #120a04;
|
||||||
|
--destructive: #dc2626;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
--border: #1f1f23;
|
||||||
|
--input: #1f1f23;
|
||||||
|
--ring: #ff7a18;
|
||||||
|
--sidebar: #15151a;
|
||||||
|
--sidebar-foreground: #ece9f0;
|
||||||
|
--sidebar-primary: #ff7a18;
|
||||||
|
--sidebar-primary-foreground: #120a04;
|
||||||
|
/* Softened selected/hover surface — a faint accent tint, NOT the solid bright
|
||||||
|
accent Obsidian uses (per your earlier "selected button shouldn't be solid
|
||||||
|
orange"). Set --sidebar-accent: #ff7a18 + foreground #120a04 for parity. */
|
||||||
|
--sidebar-accent: color-mix(in oklab, #ff7a18 16%, transparent);
|
||||||
|
--sidebar-accent-foreground: #ece9f0;
|
||||||
|
--sidebar-border: #1f1f23;
|
||||||
|
--sidebar-ring: #ff7a18;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User message bubble: a dark surface card with a 2px accent right-edge — not
|
||||||
|
the solid-orange fill (per your earlier preference). Remove this block to get
|
||||||
|
the Obsidian-style solid-accent bubble. */
|
||||||
|
.theme-ember.dark .boo-user-bubble {
|
||||||
|
background: var(--popover);
|
||||||
|
color: var(--foreground);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-right: 2px solid var(--primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
1
apps/web/src/vite-env.d.ts
vendored
Normal file
1
apps/web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
61
openspec/changes/agent-status-normalize/proposal.md
Normal file
61
openspec/changes/agent-status-normalize/proposal.md
Normal 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`
|
||||||
Reference in New Issue
Block a user