Compare commits

..

3 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
34 changed files with 482 additions and 163 deletions

View File

@@ -2,6 +2,10 @@
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 ## 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`. 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`.

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

@@ -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,54 +604,86 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
)} )}
</div> </div>
)} )}
<div className="px-4 py-3 flex items-end gap-2"> {/* BooCode 2.0 composer: textarea + a bottom controls row live INSIDE one
<Textarea bordered, focus-ringed message box (Refreshed direction). */}
ref={textareaRef} <div className="px-4 py-3">
value={value} <div className="rounded-xl border bg-card transition-colors focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/15">
onChange={handleChange} <Textarea
onKeyDown={onKeyDown} ref={textareaRef}
onPaste={onPaste} value={value}
placeholder={ onChange={handleChange}
isMobile onKeyDown={onKeyDown}
? 'Ask about this project. Tap send to submit.' onPaste={onPaste}
: 'Ask about this project. Enter to send · Shift+Enter for newline.' placeholder={
} isMobile
disabled={disabled || busy} ? 'Ask about this project. Tap send to submit.'
rows={3} : 'Ask about this project. Enter to send · Shift+Enter for newline.'
className="resize-none min-h-[68px] max-h-[240px]" }
/> disabled={disabled || busy}
{(() => { rows={3}
const hasContent = value.trim().length > 0 || attachments.length > 0; className="resize-none min-h-[56px] max-h-[240px] border-0 bg-transparent px-3 pt-2.5 shadow-none focus-visible:ring-0 dark:bg-transparent"
// While generating with an empty draft, the button stops generation. />
if (generating && onStop && !hasContent) { {/* bottom controls row: Web toggle on the left, Send/Stop on the right */}
return ( <div className="flex items-center gap-1.5 px-2 pb-2 pt-0.5">
<Button {sessionId && (
onClick={() => void onStop()} <button
size="icon-lg" type="button"
variant="outline" onClick={async () => {
aria-label="Stop generating" // v1.9 tri-state collapses to two on toggle; null (inherit) → on.
title="Stop generating" const next = webSearchEnabled === true ? false : true;
try {
await api.sessions.update(sessionId, { web_search_enabled: next });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to toggle web search');
}
}}
aria-pressed={webSearchEnabled === true}
title="Web search & fetch"
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors max-md:min-h-[36px] ${
webSearchEnabled === true
? 'border-primary/40 bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
> >
<Square className="fill-current size-3.5" /> <Globe className="size-3.5" />
</Button> Web
); </button>
} )}
// With a draft, submit. While generating the caller queues it, so the <div className="flex-1" />
// button reads as Queue; otherwise it's a normal Send. {(() => {
const queueing = !!generating && hasContent; const hasContent = value.trim().length > 0 || attachments.length > 0;
return ( // While generating with an empty draft, the button stops generation.
<Button if (generating && onStop && !hasContent) {
onClick={() => void submit()} return (
disabled={disabled || busy || !hasContent} <Button
size="icon-lg" onClick={() => void onStop()}
variant={queueing ? 'secondary' : 'default'} size="icon"
aria-label={queueing ? 'Queue message' : 'Send'} variant="outline"
title={queueing ? 'Queue message' : 'Send'} aria-label="Stop generating"
> title="Stop generating"
{queueing ? <ListPlus /> : <Send />} >
</Button> <Square className="fill-current size-3.5" />
); </Button>
})()} );
}
// With a draft, submit. While generating the caller queues it, so the
// button reads as Queue; otherwise it's a normal Send.
const queueing = !!generating && hasContent;
return (
<Button
onClick={() => void submit()}
disabled={disabled || busy || !hasContent}
size="icon"
variant={queueing ? 'secondary' : 'default'}
aria-label={queueing ? 'Queue message' : 'Send'}
title={queueing ? 'Queue message' : 'Send'}
>
{queueing ? <ListPlus /> : <Send />}
</Button>
);
})()}
</div>
</div>
</div> </div>
</div> </div>
<AttachmentPreviewModal <AttachmentPreviewModal

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { History, MessageSquare, 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 { PaneHeaderActions } from '@/components/PaneHeaderActions';
@@ -17,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>;
@@ -36,6 +39,7 @@ interface Props {
export function ChatTabBar({ export function ChatTabBar({
pane, pane,
tabs, tabs,
tabKind = 'chat',
tabNumbers, tabNumbers,
onSwitchTab, onSwitchTab,
onRemoveTab, onRemoveTab,
@@ -51,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
@@ -104,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
@@ -142,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={() =>
@@ -189,6 +195,7 @@ export function ChatTabBar({
<PaneHeaderActions <PaneHeaderActions
className="ml-auto px-1" className="ml-auto px-1"
onNewTab={onNewTab} onNewTab={onNewTab}
tabKind={tabKind}
onSplitPane={onSplitPane} onSplitPane={onSplitPane}
onReopenPane={onReopenPane} onReopenPane={onReopenPane}
onShowHistory={onShowHistory} onShowHistory={onShowHistory}

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

@@ -12,10 +12,14 @@ import { cn } from '@/lib/utils';
// desktop coder + terminal pane headers (Workspace) so all pane kinds share one // desktop coder + terminal pane headers (Workspace) so all pane kinds share one
// control set. Extracted to avoid a divergent copy per header. // control set. Extracted to avoid a divergent copy per header.
interface Props { interface Props {
// When provided (chat panes), the "+" menu's New BooChat opens an in-pane // When provided, the "+" menu item matching `tabKind` opens an in-pane tab
// tab. When omitted (coder/terminal panes, which can't host tabs), New BooChat // (e.g. chat panes: New BooChat → tab; coder panes: New BooCode → tab). Every
// splits into a new pane instead. // OTHER kind splits into a new pane. When onNewTab is omitted (terminal
// panes, which can't host tabs) all three items split.
onNewTab?: () => void; 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; onSplitPane: (kind: 'chat' | 'terminal' | 'coder') => void;
onReopenPane?: () => void; onReopenPane?: () => void;
onShowHistory: () => void; onShowHistory: () => void;
@@ -31,6 +35,7 @@ const BTN =
export function PaneHeaderActions({ export function PaneHeaderActions({
onNewTab, onNewTab,
tabKind = 'chat',
onSplitPane, onSplitPane,
onReopenPane, onReopenPane,
onShowHistory, onShowHistory,
@@ -38,6 +43,10 @@ export function PaneHeaderActions({
historyActive, historyActive,
className, className,
}: Props) { }: 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 ( return (
<div className={cn('flex items-center gap-0.5 shrink-0', className)}> <div className={cn('flex items-center gap-0.5 shrink-0', className)}>
<DropdownMenu> <DropdownMenu>
@@ -53,15 +62,15 @@ export function PaneHeaderActions({
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit"> <DropdownMenuContent align="end" className="w-fit">
{/* Chat panes: New BooChat opens a tab in THIS pane. Coder/terminal {/* The item matching the host pane's kind opens an in-pane tab; the
panes can't host tabs, so it splits into a new pane. */} others split into a new pane. (tabKind defaults to 'chat'.) */}
<DropdownMenuItem onSelect={onNewTab ?? (() => onSplitPane('chat'))}> <DropdownMenuItem onSelect={newOrSplit('chat')}>
<MessageSquare size={14} /> New BooChat <MessageSquare size={14} /> New BooChat
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitPane('terminal')}> <DropdownMenuItem onSelect={newOrSplit('terminal')}>
<Terminal size={14} /> New BooTerm <Terminal size={14} /> New BooTerm
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitPane('coder')}> <DropdownMenuItem onSelect={newOrSplit('coder')}>
<Code size={14} /> New BooCode <Code size={14} /> New BooCode
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>

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

@@ -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 { Terminal, Code, Clipboard } 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';
@@ -60,6 +60,7 @@ export function Workspace({
closeAllTabs, closeAllTabs,
showLandingPage, showLandingPage,
addSplitPane, addSplitPane,
createCoderTab,
removePane, removePane,
reopenPane, reopenPane,
hasClosedPanes, hasClosedPanes,
@@ -214,18 +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)}
<PaneHeaderActions tabKind="coder"
className="ml-auto" tabNumbers={tabNumbers}
onSplitPane={onAddPane} onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
onReopenPane={hasClosedPanes ? reopenPane : undefined} onRemoveTab={(chatId) => removeTab(idx, chatId)}
onShowHistory={() => showLandingPage(idx)} onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined} onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
/> onCloseAll={() => closeAllTabs(idx)}
</div> onNewTab={() => void createCoderTab(idx)}
onSplitPane={(kind) => onAddPane(kind)}
onReopenPane={hasClosedPanes ? reopenPane : undefined}
onShowHistory={() => showLandingPage(idx)}
onRename={renameChat}
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
/>
)} )}
{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">

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,
@@ -954,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" />