Compare commits

...

5 Commits

Author SHA1 Message Date
27f3a6c463 Merge boocode-ui-ember-coder-model: v2.7.8 Ember theme + brand banner + coder tabs + model-attribution chips 2026-06-01 22:30:58 +00:00
3a646fd6df feat: BooCode 2.0 UI — Ember theme, brand banner, coder tabs, model-attribution chips
- Ember theme (Obsidian charcoal + #ff7a18 orange), now DEFAULT_THEME_ID; server theme_id whitelist gains 'ember'
- Brand banner: transparent Westie mascot + >_BooCode wordmark, big/edge-to-edge (flood-filled to transparency + cropped)
- Coder panes are multi-tab: + opens a BooCode tab, split opens a pane (shared ChatTabBar via tabKind + createCoderTab; closeOtherTabs/tab-numbering extended to coder)
- Model-attribution: new messages.model column stamped at finalizeCompletion (BooChat/native coder) + dispatcher assistant-row creation (external coder); surfaced via view + wire types + live frame; rendered as a subtle shortened-name chip (shortenModelName)
- Composer Web toggle moved into a boxed focus-ringed input; glowing accent dot on tool rows
- Claude SDK follow-ups (1M context, follow-up-message fix, collapsed thinking/tool chips) + CLAUDE_SDK_BACKEND=1

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:30:47 +00:00
7098014261 Merge pane-header-shared: v2.7.7 shared pane-header cluster + chat-resolve WorkspaceState fix 2026-06-01 14:29:00 +00:00
c56d169ef9 feat: shared PaneHeaderActions + chat-resolve WorkspaceState fix (v2.7.7)
In-flight workspace UX work.

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:28:49 +00:00
b7fb254e5d Merge agent-status-dot: v2.7.6 normalized external-agent status (scoped #10) 2026-06-01 14:04:26 +00:00
38 changed files with 934 additions and 369 deletions

View File

@@ -2,6 +2,14 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch. 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 ## 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`. 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`.

View File

@@ -35,7 +35,7 @@ npx tsc -p apps/web/tsconfig.app.json --noEmit # web app specifically
docker compose build --no-cache boocode && docker compose up -d 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 ~1520s after a boocoder restart while the startup agent-probe scan runs — retry; an early connection-refused is not a failed deploy.
- Agent dispatch spawns binaries directly using `install_path` from `available_agents` — no `spawn('sh', ['-c', ...])` (fails under systemd). Follows Paseo's pattern: `spawn(fullBinaryPath, argsArray, { cwd })`. - 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 36 sentence paragraph summarizing what shipped, drawn from the commit body. Cross-reference other tags by name when the batch builds on, fixes, or pairs with prior work (e.g. "pairs with `v1.13.12-ws-schemas`", "fixed in `v1.13.5-stability-bundle`"). No nested bullets — one paragraph. - `CHANGELOG.md` is the per-tag release log, most-recent on top. When a new tag is created, add a `## <tag> — <YYYY-MM-DD>` section with a 36 sentence paragraph summarizing what shipped, drawn from the commit body. Cross-reference other tags by name when the batch builds on, fixes, or pairs with prior work (e.g. "pairs with `v1.13.12-ws-schemas`", "fixed in `v1.13.5-stability-bundle`"). No nested bullets — one paragraph.
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue). - 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`.

View File

@@ -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

View File

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

View File

@@ -8,6 +8,36 @@ interface WorkspacePaneRow {
activeChatIdx?: number; 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}
`; `;

View File

@@ -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

View File

@@ -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;
} }
/** /**

View File

@@ -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';

View File

@@ -213,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;
@@ -380,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;
@@ -723,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;
@@ -1004,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;
@@ -1260,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;
@@ -1373,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, {

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 910 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -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,7 +604,10 @@ 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
bordered, focus-ringed message box (Refreshed direction). */}
<div className="px-4 py-3">
<div className="rounded-xl border bg-card transition-colors focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/15">
<Textarea <Textarea
ref={textareaRef} ref={textareaRef}
value={value} value={value}
@@ -654,8 +621,35 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
} }
disabled={disabled || busy} disabled={disabled || busy}
rows={3} rows={3}
className="resize-none min-h-[68px] max-h-[240px]" 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"
/> />
{/* bottom controls row: Web toggle on the left, Send/Stop on the right */}
<div className="flex items-center gap-1.5 px-2 pb-2 pt-0.5">
{sessionId && (
<button
type="button"
onClick={async () => {
// v1.9 tri-state collapses to two on toggle; null (inherit) → on.
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'
}`}
>
<Globe className="size-3.5" />
Web
</button>
)}
<div className="flex-1" />
{(() => { {(() => {
const hasContent = value.trim().length > 0 || attachments.length > 0; const hasContent = value.trim().length > 0 || attachments.length > 0;
// While generating with an empty draft, the button stops generation. // While generating with an empty draft, the button stops generation.
@@ -663,7 +657,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
return ( return (
<Button <Button
onClick={() => void onStop()} onClick={() => void onStop()}
size="icon-lg" size="icon"
variant="outline" variant="outline"
aria-label="Stop generating" aria-label="Stop generating"
title="Stop generating" title="Stop generating"
@@ -679,7 +673,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
<Button <Button
onClick={() => void submit()} onClick={() => void submit()}
disabled={disabled || busy || !hasContent} disabled={disabled || busy || !hasContent}
size="icon-lg" size="icon"
variant={queueing ? 'secondary' : 'default'} variant={queueing ? 'secondary' : 'default'}
aria-label={queueing ? 'Queue message' : 'Send'} aria-label={queueing ? 'Queue message' : 'Send'}
title={queueing ? 'Queue message' : 'Send'} title={queueing ? 'Queue message' : 'Send'}
@@ -690,6 +684,8 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
})()} })()}
</div> </div>
</div> </div>
</div>
</div>
<AttachmentPreviewModal <AttachmentPreviewModal
attachment={previewAttachment} attachment={previewAttachment}
onClose={() => setPreviewAttachment(null)} onClose={() => setPreviewAttachment(null)}

View File

@@ -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>
); );
} }

View File

@@ -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 {

View File

@@ -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

View 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>
);
}

View File

@@ -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">

View File

@@ -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,14 +154,19 @@ 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);
return (
<div
key={c.id} key={c.id}
className="group/row flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted text-sm max-md:min-h-[44px]"
>
<button
type="button" type="button"
onClick={() => onOpenChat(c.id)} onClick={() => onOpenChat(c.id)}
className="w-full flex items-center gap-2 text-left px-2 py-1.5 rounded hover:bg-muted text-sm max-md:min-h-[44px]" className="flex items-center gap-2 flex-1 min-w-0 text-left"
> >
<MessageSquare size={14} className="shrink-0 text-muted-foreground" /> <Icon size={14} className="shrink-0 text-muted-foreground" />
<span className="truncate shrink-0 max-w-[45%]">{c.name ?? 'New chat'}</span> <span className="truncate shrink-0 max-w-[45%]">{c.name ?? 'New chat'}</span>
{c.last_message_preview && ( {c.last_message_preview && (
<span className="truncate flex-1 text-xs text-muted-foreground hidden sm:block"> <span className="truncate flex-1 text-xs text-muted-foreground hidden sm:block">
@@ -148,7 +177,29 @@ export function SessionLandingPage({
{formatRelative(c.updated_at)} {formatRelative(c.updated_at)}
</span> </span>
</button> </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,12 +210,15 @@ 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}
className="group/arch flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted text-sm text-muted-foreground max-md:min-h-[44px]"
>
<button
type="button" type="button"
onClick={() => void restoreAndOpen(c.id)} onClick={() => void restoreAndOpen(c.id)}
title="Restore and open" title="Restore and open"
className="group/arch w-full flex items-center gap-2 text-left px-2 py-1.5 rounded hover:bg-muted text-sm text-muted-foreground max-md:min-h-[44px]" className="flex items-center gap-2 flex-1 min-w-0 text-left"
> >
<Archive size={14} className="shrink-0" /> <Archive size={14} className="shrink-0" />
<span className="truncate flex-1">{c.name ?? 'New chat'}</span> <span className="truncate flex-1">{c.name ?? 'New chat'}</span>
@@ -174,6 +228,16 @@ export function SessionLandingPage({
className="shrink-0 opacity-0 group-hover/arch:opacity-100" className="shrink-0 opacity-0 group-hover/arch:opacity-100"
/> />
</button> </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>
); );
} }

View File

@@ -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' : ''}`}

View File

@@ -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,30 +243,7 @@ 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>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="ml-auto inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
aria-label="New pane"
title="New pane"
>
<Plus size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
<Terminal size={14} /> New BooTerm
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('coder')}>
<Code size={14} /> New BooCode
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* v1.10.4: iOS Safari restricts navigator.clipboard.readText {/* v1.10.4: iOS Safari restricts navigator.clipboard.readText
outside direct user gestures. A real button click IS a outside direct user gestures. A real button click IS a
gesture, so this works where keystroke-driven paste may gesture, so this works where keystroke-driven paste may
@@ -301,26 +255,19 @@ export function Workspace({
e.stopPropagation(); e.stopPropagation();
terminalsRegistry.get(pane.id)?.paste(); terminalsRegistry.get(pane.id)?.paste();
}} }}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7" className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Paste from clipboard" aria-label="Paste from clipboard"
title="Paste from clipboard" title="Paste from clipboard"
> >
<Clipboard size={12} /> <Clipboard size={12} />
</button> </button>
{panes.length > 1 && ( <PaneHeaderActions
<button onSplitPane={onAddPane}
type="button" onReopenPane={hasClosedPanes ? reopenPane : undefined}
onClick={(e) => { onShowHistory={() => showLandingPage(idx)}
e.stopPropagation(); onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
removePane(idx); />
}} </div>
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
aria-label="Close terminal pane"
title="Close terminal pane"
>
<X size={12} />
</button>
)}
</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>

View File

@@ -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.

View File

@@ -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];
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 }; 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,

View 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;
}

View File

@@ -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';

View File

@@ -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 *));

View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />