Compare commits

..

7 Commits

Author SHA1 Message Date
09aecc4ee9 v1.9: settings pane + per-project defaults + bulk archive + themes lift
Adds a singleton, ephemeral 'settings' pane kind to the workspace.
Opened via a new bottom-pinned button in ProjectSidebar (emits an
open_settings_pane event when a session is mounted; navigates to
/settings otherwise). Pane has three sections — Session, Project,
Theme — and a maximize toggle that hides sibling pane columns via
display:none on desktop only. Settings panes don't count toward
MAX_PANES and are filtered out of the localStorage persistence layer
so reload always restores a clean workspace.

Schema (additive):
- projects.default_system_prompt TEXT NOT NULL DEFAULT ''
- projects.default_web_search_enabled BOOLEAN NOT NULL DEFAULT false
- sessions.web_search_enabled BOOLEAN  (nullable; null = inherit)

Inference resolves user_prompt = session.system_prompt.trim() ||
project.default_system_prompt.trim() — empty/whitespace at either
layer means "no override". Keeps the columns NOT NULL and matches
the existing inherit semantics.

Server routes:
- GET /api/projects/:id (new; settings pane refetches on
  project_updated)
- PATCH /api/projects/:id accepts default_system_prompt,
  default_web_search_enabled
- PATCH /api/sessions/:id accepts web_search_enabled (tri-state)
- POST /api/projects/:id/sessions/archive-all + GET
  /api/projects/:id/sessions/open-count
- POST /api/sessions/:id/chats/archive-all + GET
  /api/sessions/:id/chats/open-count
- PATCH /api/sessions/:id now broadcasts session_updated on every
  successful PATCH (was rename-only). Lets SettingsPane open in
  another tab pick up edits without a refetch.

Bulk-archive publishes one session_archived / chat_archived frame
per affected id so useSidebar's existing reducer cases handle them
incrementally — no new frame type, no payload widening.

ModelPicker refactored: shared ModelList inside a responsive shell.
Desktop = labeled trigger + DropdownMenu, mobile = icon-only Cpu
button + BottomSheet. Header in Session.tsx drops the pill wrap on
mobile since the new trigger is the visual.

ChatInput gains an icon-only '+' DropdownMenu next to AgentPicker
when sessionId + webSearchEnabled props are provided. One item for
now — Web search — with a checkmark reflecting the stored value
(true), not the effective one. Click PATCHes the override; to
restore inherit-from-project the user opens SettingsPane.

ThemePicker lifted out of pages/Settings.tsx into a reusable
component. The standalone /settings route is now a thin wrapper
that mounts <ThemePicker /> with a Back button on top
(navigate(-1) with fallback to '/'); the SettingsPane Theme tab
renders the same picker bare.

Project section delete-flow removed (button + confirm dialog +
handler). Replaced with "Archive all sessions" using the same
two-step count → confirm → fire pattern as "Archive all chats" in
the Session section. api.projects.remove() stays in the client
because useProjects.ts still uses it.

Hand-rolled Switch primitive in SettingsPane (no shadcn switch in
the project; spec said no new deps). Section nav is plain buttons
(no shadcn Tabs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:37:29 +00:00
32c1a2b5f6 Merge themes-v1 2026-05-17 16:25:19 +00:00
9b174cdb5e themes-v1: 18 preset palettes + Settings picker
Adds 18 preset themes (16 dual-mode + 2 light-only) selectable from
a new /settings route. Persists per-user via the existing key-value
settings table — no schema refactor. Default on first load is
obsidian dark.

Storage: two new seeded keys (theme_id, theme_mode) inserted
idempotently from schema.sql. PATCH /api/settings tightens validation
with a discriminated branch — theme_id must be one of the 18
whitelisted ids, theme_mode ∈ {dark,light,system}, anything else
rejects 400. Other keys pass through the loose record schema.

CSS layer: 18 files in apps/web/src/styles/themes/, each declaring
.theme-<id> (light) and .theme-<id>.dark (dark) — except ivory and
chalk which are light-only. Anchor-to-token mapping per spec §3.
--destructive stays red across all themes. --radius unchanged at
0.625rem (spec parenthetical was about "not per-theme", not a
specific value swap).

Frontend: lib/theme.ts owns THEMES, applyTheme(), setTheme(), and
useTheme() — module-singleton with optimistic PATCH + revert on
failure (mirrors useChatStatus / useSidebar pattern). Settings.tsx
renders a 3-col (md) / 2-col (mobile) grid of shadcn Card swatches
with a Dark/Light/System radio group on top. App.tsx mounts
useTheme() at AppShell top and wires the /settings route.
index.html ships a pre-React FOUC script that reads localStorage
'boocode.theme' and stamps the className on <html> before any
paint. Stripped two pre-existing dark-mode lock-ins (AppShell's
hardcoded 'dark' className and body's neutral-950/100 tailwind
utilities) that would have fought theme tokens.

Light-only + dark request → falls back to obsidian dark in three
places: lib/theme.ts effectiveThemeId(), the FOUC script, and the
picker's "Light only" badge. No inline message; matches spec §8
decision 1.

shadcn primitives card and radio-group installed via shadcn CLI
(no hand-rolling). card.tsx and radio-group.tsx are the only ui/
additions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:25:15 +00:00
efbecd074a Merge v1.8.2 2026-05-17 10:33:21 +00:00
5c61cc7281 v1.8.2: tool loop cap-hit summary + tool call UI compaction
Old hardcoded MAX_TOOL_LOOP_DEPTH=15 replaced by per-agent
max_tool_calls (1-100, AGENTS.md frontmatter) with defaults: 30 for
read-only-only agents, 10 for agents that include any non-read-only
tool, 15 for raw chat. When the loop hits cap, fire one final summary
call with tools disabled, stream the wrap-up into the in-flight
assistant message, then insert a system sentinel with
metadata.kind='cap_hit'. The sentinel renders an amber bubble with a
Continue button (latest sentinel only) that POSTs to a new
/api/chats/:id/continue route to extend. Hard ceiling: 3 cap-hits per
chat (2 continues max) — third sentinel reports can_continue=false.

Error frames carry a machine-readable reason code alongside human
error text. Failed messages persist the reason via
metadata.kind='error' so the bubble renders specifics on reload (WS
error frame is one-shot).

Tool call UI rewired: ToolCallLine renders inline (↳ name args
spinner/check/✗, expand-on-tap for args+result); ToolCallGroup
collapses 3+ consecutive same-tool runs into a compact card.
MessageList owns a three-pass pre-render (flatten + fold tool
results onto matching runs by id + group same-tool runs + number
sentinels). MessageBubble drops tool rendering and adds the
sentinel / error-reason branches. ToolCallCard deleted.

Roadmap follow-up logged: add explicit max_tool_calls: 30 to the 6
agents in /data/AGENTS.md and /opt/boocode/AGENTS.md post-ship for
discoverability (defaults handle behavior identically).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 10:31:32 +00:00
5422c47928 gitignore data/ for global AGENTS.md
The /data dir is host-mounted into the container at /data:ro and holds
the global AGENTS.md seed (v1.8.1). It is part of the deployment
contract — anyone cloning needs to mkdir data/ + cp AGENTS.md into it
themselves — so the directory itself should never be tracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:50:47 +00:00
b09d0ffde0 Merge v1.8.1 2026-05-16 23:16:38 +00:00
62 changed files with 4155 additions and 240 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ dist
.vite
coverage
secrets/
data/

View File

@@ -123,6 +123,53 @@ export function registerChatRoutes(
}
);
// v1.9: bulk-archive every open chat in a session. Mirrors the single
// /chats/:id/archive shape — N chat_archived frames published, useSidebar
// reducer handles each via the existing case.
app.post<{ Params: { id: string } }>(
'/api/sessions/:id/chats/archive-all',
async (req, reply) => {
const session = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`;
if (session.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
const rows = await sql<{ id: string }[]>`
UPDATE chats
SET status = 'archived', updated_at = clock_timestamp()
WHERE session_id = ${req.params.id} AND status = 'open'
RETURNING id
`;
const ids = rows.map((r) => r.id);
for (const id of ids) {
broker.publishUser('default', {
type: 'chat_archived',
chat_id: id,
session_id: req.params.id,
});
}
return { archived: ids.length, ids };
}
);
// v1.9: count helper for the confirm dialog.
app.get<{ Params: { id: string } }>(
'/api/sessions/:id/chats/open-count',
async (req, reply) => {
const session = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`;
if (session.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
const rows = await sql<{ count: number }[]>`
SELECT COUNT(*)::int AS count
FROM chats
WHERE session_id = ${req.params.id} AND status = 'open'
`;
return { count: rows[0]?.count ?? 0 };
}
);
app.post<{ Params: { id: string } }>(
'/api/chats/:id/archive',
async (req, reply) => {
@@ -231,7 +278,7 @@ export function registerChatRoutes(
INSERT INTO messages (
session_id, chat_id, role, content, kind, tool_calls, tool_results,
status, tokens_used, ctx_used, ctx_max, started_at, finished_at,
created_at
created_at, metadata
)
SELECT
${source.session_id}, ${chat!.id}, role, content, kind,
@@ -239,7 +286,8 @@ export function registerChatRoutes(
tokens_used, ctx_used, ctx_max, started_at, finished_at,
clock_timestamp() + (
ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) * INTERVAL '1 microsecond'
)
),
metadata
FROM messages
WHERE chat_id = ${source.id}
AND created_at <= ${target.created_at}::timestamptz
@@ -268,7 +316,7 @@ export function registerChatRoutes(
}
const rows = await sql<Message[]>`
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
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata
FROM messages
WHERE chat_id = ${req.params.id}
ORDER BY created_at ASC, id ASC

View File

@@ -7,6 +7,13 @@ const SendBody = z.object({
content: z.string().min(1).max(64_000),
});
// v1.8.2: Continue extends an inference loop that hit the tool budget. Caller
// passes the sentinel message it's continuing from; server validates shape
// and the per-chat hard ceiling before resuming.
const ContinueBody = z.object({
sentinel_message_id: z.string().uuid(),
});
interface MessageHandlers {
enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
enqueueCompact: (sessionId: string, chatId: string, compactMessageId: string, user: string) => void;
@@ -36,7 +43,7 @@ export function registerMessageRoutes(
}
const rows = await sql<Message[]>`
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
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata
FROM messages
WHERE session_id = ${req.params.id}
ORDER BY created_at ASC, id ASC
@@ -253,6 +260,76 @@ export function registerMessageRoutes(
}
);
app.post<{ Params: { id: string } }>(
'/api/chats/:id/continue',
async (req, reply) => {
const parsed = ContinueBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const chat = chatRows[0]!;
const sessionId = chat.session_id;
// Cap-hit sentinels are only ever inserted after a turn completes, so
// there must not be an active inference at this moment. If there is,
// the client is racing the cap-hit summary that just emitted the
// sentinel — bail rather than enqueue a parallel run.
if (handlers.hasActiveInference(chat.id)) {
reply.code(409);
return { error: 'chat is currently streaming' };
}
const sentinel = await sql<{ metadata: { kind?: unknown; can_continue?: unknown } | null }[]>`
SELECT metadata
FROM messages
WHERE id = ${parsed.data.sentinel_message_id}
AND chat_id = ${chat.id}
AND role = 'system'
`;
if (sentinel.length === 0) {
reply.code(404);
return { error: 'sentinel not found' };
}
const meta = sentinel[0]!.metadata;
if (!meta || meta.kind !== 'cap_hit') {
reply.code(400);
return { error: 'message is not a cap-hit sentinel' };
}
// Server-side hard ceiling check. UI already disables the button when
// can_continue is false; defending against a stale tab or a direct
// API hit is the only reason this lives on the server too.
if (meta.can_continue !== true) {
reply.code(409);
return { error: 'hard limit reached for this chat' };
}
const result = await sql.begin(async (tx) => {
const [assistantMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
return { assistant_message_id: assistantMsg!.id };
});
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
reply.code(202);
return result;
}
);
app.post<{ Params: { id: string } }>(
'/api/chats/:id/force_send',
async (req, reply) => {

View File

@@ -22,8 +22,14 @@ const AddProjectBody = z.object({
name: z.string().min(1).optional(),
});
// v1.9: PATCH accepts the new per-project defaults. All fields optional so
// the existing rename-only callers keep working. Empty string on
// default_system_prompt is the "no override" sentinel — same convention as
// sessions.system_prompt.
const PatchProjectBody = z.object({
name: z.string().min(1).max(200),
name: z.string().min(1).max(200).optional(),
default_system_prompt: z.string().max(8000).optional(),
default_web_search_enabled: z.boolean().optional(),
});
const CreateProjectBody = z.object({
@@ -70,7 +76,8 @@ export function registerProjectRoutes(
app.get<{ Querystring: { status?: string } }>('/api/projects', async (req) => {
const status = req.query.status === 'archived' ? 'archived' : 'open';
const rows = await sql<Project[]>`
SELECT id, name, path, added_at, last_session_id, status, gitea_remote
SELECT id, name, path, added_at, last_session_id, status, gitea_remote,
default_system_prompt, default_web_search_enabled
FROM projects
WHERE status = ${status}
ORDER BY added_at DESC
@@ -119,7 +126,8 @@ export function registerProjectRoutes(
const [row] = await sql<Project[]>`
INSERT INTO projects (name, path, gitea_remote)
VALUES (${parsed.data.name}, ${bootstrap.folder_real_path}, ${bootstrap.gitea_remote_url})
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote,
default_system_prompt, default_web_search_enabled
`;
broker.publishUser('default', { type: 'project_created', project: row as unknown as Project });
reply.code(201);
@@ -173,7 +181,8 @@ export function registerProjectRoutes(
INSERT INTO projects (name, path)
VALUES (${name}, ${resolved.real})
ON CONFLICT (path) DO UPDATE SET status = 'open'
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote,
default_system_prompt, default_web_search_enabled
`;
if (existing.length === 0) {
@@ -187,22 +196,53 @@ export function registerProjectRoutes(
return row;
});
// v1.9: single-project fetch so the settings pane can refetch on
// project_updated without pulling the whole project list.
app.get<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => {
const rows = await sql<Project[]>`
SELECT id, name, path, added_at, last_session_id, status, gitea_remote,
default_system_prompt, default_web_search_enabled
FROM projects WHERE id = ${req.params.id}
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'not found' };
}
return rows[0];
});
app.patch<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => {
const parsed = PatchProjectBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { name, default_system_prompt, default_web_search_enabled } = parsed.data;
// v1.9: every field optional. COALESCE on the bind keeps the prior value
// when the caller omits it. Boolean has its own branch since COALESCE
// can't disambiguate "omitted" from "explicitly false" via a single
// nullable parameter.
const dwsProvided = default_web_search_enabled !== undefined;
const rows = await sql<Project[]>`
UPDATE projects SET name = ${parsed.data.name}
UPDATE projects
SET
name = COALESCE(${name ?? null}, name),
default_system_prompt = COALESCE(${default_system_prompt ?? null}, default_system_prompt),
default_web_search_enabled = CASE WHEN ${dwsProvided}
THEN ${default_web_search_enabled ?? false}
ELSE default_web_search_enabled END
WHERE id = ${req.params.id}
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote,
default_system_prompt, default_web_search_enabled
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'not found' };
}
const project = rows[0]!;
// v1.9: the project_updated frame still only carries id + name. Clients
// that need the new fields refetch via api.projects.list() — keeps the
// frame payload lean, per the locked recon decision (d).
broker.publishUser('default', {
type: 'project_updated',
project_id: project.id,
@@ -229,7 +269,8 @@ export function registerProjectRoutes(
const rows = await sql<Project[]>`
UPDATE projects SET status = 'open'
WHERE id = ${req.params.id} AND status = 'archived'
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote,
default_system_prompt, default_web_search_enabled
`;
if (rows.length === 0) {
reply.code(404);

View File

@@ -19,6 +19,8 @@ const PatchBody = z.object({
model: z.string().min(1).max(200).optional(),
system_prompt: z.string().max(8000).optional(),
agent_id: z.string().min(1).max(200).nullable().optional(),
// v1.9: null = inherit from project default; true/false = explicit override.
web_search_enabled: z.boolean().nullable().optional(),
});
async function resolveDefaultModel(sql: Sql, config: Config): Promise<string> {
@@ -50,7 +52,7 @@ export function registerSessionRoutes(
}
const status = req.query.status === 'archived' ? 'archived' : 'open';
const rows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled
FROM sessions
WHERE project_id = ${req.params.id} AND status = ${status}
ORDER BY updated_at DESC
@@ -100,7 +102,7 @@ export function registerSessionRoutes(
const [session] = await tx<Session[]>`
INSERT INTO sessions (project_id, name, model, system_prompt, agent_id)
VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt}, ${agentId})
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled
`;
await tx`
INSERT INTO chats (session_id, name, status)
@@ -120,7 +122,7 @@ export function registerSessionRoutes(
app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
const rows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled
FROM sessions WHERE id = ${req.params.id}
`;
if (rows.length === 0) {
@@ -139,10 +141,13 @@ export function registerSessionRoutes(
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { name, model, system_prompt } = parsed.data;
// agent_id is tri-state on the wire: omitted = no change, null = clear,
// string = set. CASE WHEN inside SET handles all three atomically.
// agent_id and web_search_enabled are both tri-state on the wire: omitted
// = no change, null = clear/inherit, value = set. CASE WHEN inside SET
// handles all three atomically.
const agentIdProvided = parsed.data.agent_id !== undefined;
const newAgentId = parsed.data.agent_id ?? null;
const wseProvided = parsed.data.web_search_enabled !== undefined;
const newWse = parsed.data.web_search_enabled ?? null;
// Read the prior name so the post-update publish can skip no-op renames
// (PATCH { name: "Foo" } where the session is already "Foo"). The window
// between SELECT and UPDATE is sub-millisecond in the same request handler;
@@ -159,9 +164,11 @@ export function registerSessionRoutes(
model = COALESCE(${model ?? null}, model),
system_prompt = COALESCE(${system_prompt ?? null}, system_prompt),
agent_id = CASE WHEN ${agentIdProvided} THEN ${newAgentId} ELSE agent_id END,
web_search_enabled = CASE WHEN ${wseProvided} THEN ${newWse} ELSE web_search_enabled END,
updated_at = clock_timestamp()
WHERE id = ${req.params.id}
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
agent_id, web_search_enabled
`;
if (rows.length === 0) {
reply.code(404);
@@ -175,10 +182,69 @@ export function registerSessionRoutes(
name: session.name,
});
}
// v1.9: any successful PATCH broadcasts session_updated so listeners
// (notably the SettingsPane open in another tab) can refetch and pick
// up the new fields. Frame stays lean (decision d) — payload is just
// ids + name + updated_at, the client refetches via api.sessions.get.
broker.publishUser('default', {
type: 'session_updated',
session_id: session.id,
project_id: session.project_id,
name: session.name,
updated_at: session.updated_at,
});
return session;
}
);
// v1.9: bulk-archive every open session in a project. Mirrors the
// single-archive shape (same broker frame type) so the existing useSidebar
// reducer cases handle it without changes — just N frames instead of 1.
app.post<{ Params: { id: string } }>(
'/api/projects/:id/sessions/archive-all',
async (req, reply) => {
const project = await sql`SELECT id FROM projects WHERE id = ${req.params.id}`;
if (project.length === 0) {
reply.code(404);
return { error: 'project not found' };
}
const rows = await sql<{ id: string }[]>`
UPDATE sessions
SET status = 'archived', updated_at = clock_timestamp()
WHERE project_id = ${req.params.id} AND status = 'open'
RETURNING id
`;
const ids = rows.map((r) => r.id);
for (const id of ids) {
broker.publishUser('default', {
type: 'session_archived',
session_id: id,
project_id: req.params.id,
});
}
return { archived: ids.length, ids };
}
);
// v1.9: count helper for the confirm dialog. Cheap COUNT(*) — the settings
// pane calls it on click, not on render.
app.get<{ Params: { id: string } }>(
'/api/projects/:id/sessions/open-count',
async (req, reply) => {
const project = await sql`SELECT id FROM projects WHERE id = ${req.params.id}`;
if (project.length === 0) {
reply.code(404);
return { error: 'project not found' };
}
const rows = await sql<{ count: number }[]>`
SELECT COUNT(*)::int AS count
FROM sessions
WHERE project_id = ${req.params.id} AND status = 'open'
`;
return { count: rows[0]?.count ?? 0 };
}
);
app.post<{ Params: { id: string } }>(
'/api/sessions/:id/archive',
async (req, reply) => {
@@ -207,7 +273,7 @@ export function registerSessionRoutes(
const rows = await sql<Session[]>`
UPDATE sessions SET status = 'open', updated_at = clock_timestamp()
WHERE id = ${req.params.id} AND status = 'archived'
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled
`;
if (rows.length === 0) {
reply.code(404);

View File

@@ -22,6 +22,50 @@ export async function setSetting(
`;
}
// themes-v1: whitelist of the 18 preset theme ids. Kept in sync with
// docs/themes_v1.md §1 and apps/web/src/lib/theme.ts THEMES.
const THEME_IDS = [
'obsidian',
'gunmetal',
'espresso',
'volcanic-brown',
'copper',
'gold',
'oxblood',
'crimson',
'elderflower',
'plum',
'steel-pink',
'fuchsia-noir',
'matrix',
'sage',
'ivory',
'chalk',
'cobalt',
'midnight-sapphire',
] as const;
const THEME_MODES = ['dark', 'light', 'system'] as const;
// PATCH body is still a free-form key/value bag for everything except the
// two theme keys, which carry strict per-key validation. Anything outside
// THEME_IDS / THEME_MODES on those keys is rejected with 400.
function validateThemeKeys(body: Record<string, unknown>): string | null {
if ('theme_id' in body) {
const v = body.theme_id;
if (typeof v !== 'string' || !(THEME_IDS as readonly string[]).includes(v)) {
return `theme_id must be one of: ${THEME_IDS.join(', ')}`;
}
}
if ('theme_mode' in body) {
const v = body.theme_mode;
if (typeof v !== 'string' || !(THEME_MODES as readonly string[]).includes(v)) {
return `theme_mode must be one of: ${THEME_MODES.join(', ')}`;
}
}
return null;
}
const PatchBody = z.record(z.string(), z.unknown());
export function registerSettingsRoutes(app: FastifyInstance, sql: Sql): void {
@@ -38,6 +82,11 @@ export function registerSettingsRoutes(app: FastifyInstance, sql: Sql): void {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const themeError = validateThemeKeys(parsed.data);
if (themeError) {
reply.code(400);
return { error: themeError };
}
for (const [k, v] of Object.entries(parsed.data)) {
await setSetting(sql, k, v);
}

View File

@@ -23,7 +23,7 @@ export function registerWebSocket(
const messages = await sql<Message[]>`
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
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata
FROM messages
WHERE session_id = ${sessionId}
ORDER BY created_at ASC, id ASC

View File

@@ -158,3 +158,24 @@ END $$;
-- the DB; they live in builtins (services/agents.ts) and a per-project AGENTS.md.
-- agent_id is the slugified agent name. NULL means "use BooCode defaults".
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS agent_id TEXT;
-- v1.8.2: per-message metadata for sentinels (cap-hit) and structured error
-- reasons. JSONB so future kinds can extend without further schema churn.
-- Shape for cap_hit: { kind: 'cap_hit', used: number, limit: number,
-- agent_name: string|null, can_continue: boolean }
-- Shape for errors: { error_reason: 'llm_provider_error'|..., error_text: string }
ALTER TABLE messages ADD COLUMN IF NOT EXISTS metadata JSONB;
-- themes-v1: idempotent seeds for the two theme preference keys. The settings
-- table is a key/value store (see line 43) so theme prefs live as two rows,
-- not new columns. Defaults match docs/themes_v1.md: obsidian (dark).
INSERT INTO settings (key, value) VALUES ('theme_id', '"obsidian"') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('theme_mode', '"dark"') ON CONFLICT (key) DO NOTHING;
-- v1.9: per-project defaults that new sessions inherit, plus a per-session
-- web-search override. Empty string on either prompt column means "inherit"
-- (resolved in inference.ts buildSystemPrompt). web_search_enabled is the
-- only tri-state field: null on session = inherit from project default.
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_system_prompt TEXT NOT NULL DEFAULT '';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_web_search_enabled BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS web_search_enabled BOOLEAN;

View File

@@ -21,6 +21,8 @@ function makeSession(overrides: Partial<Session> = {}): Session {
status: 'open',
created_at: new Date(0).toISOString(),
updated_at: new Date(0).toISOString(),
agent_id: null,
web_search_enabled: null,
...overrides,
};
}
@@ -34,6 +36,8 @@ function makeProject(overrides: Partial<Project> = {}): Project {
last_session_id: null,
status: 'open',
gitea_remote: null,
default_system_prompt: '',
default_web_search_enabled: false,
...overrides,
};
}
@@ -62,6 +66,7 @@ function makeMessage(
started_at: null,
finished_at: null,
created_at: new Date(counter * 1000).toISOString(),
metadata: null,
...overrides,
};
}

View File

@@ -29,6 +29,9 @@ interface ParsedFrontmatter {
tools?: string[];
description?: string;
model?: string;
// v1.8.2: optional per-agent tool-loop budget. Absent → inference resolves
// from the agent's toolset at runtime.
max_tool_calls?: number;
}
function stripQuotes(s: string): string {
@@ -89,6 +92,21 @@ function parseFrontmatter(yaml: string): { data: ParsedFrontmatter; errors: stri
data.description = stripQuotes(valueRaw);
} else if (key === 'model') {
data.model = stripQuotes(valueRaw);
} else if (key === 'max_tool_calls') {
// v1.8.2: 1..100 inclusive integer. Out-of-range values are skipped
// with a warning rather than throwing — agents shouldn't be unusable
// because of a typo on a defaulted field. Non-numeric or non-integer
// still hard-fails the block, matching `temperature` behavior.
const n = Number(valueRaw);
if (Number.isInteger(n) && n >= 1 && n <= 100) {
data.max_tool_calls = n;
} else if (Number.isInteger(n)) {
console.warn(
`agents: max_tool_calls ${n} out of range 1-100, ignoring (falling back to default)`,
);
} else {
errors.push(`max_tool_calls must be an integer 1-100 (got "${valueRaw}")`);
}
}
// Unknown keys silently ignored — forward-compat.
}
@@ -177,6 +195,7 @@ function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
temperature: typeof fm.temperature === 'number' ? fm.temperature : DEFAULT_TEMPERATURE,
tools: filteredTools,
model: typeof fm.model === 'string' && fm.model.length > 0 ? fm.model : null,
max_tool_calls: typeof fm.max_tool_calls === 'number' ? fm.max_tool_calls : null,
};
}

View File

@@ -1,8 +1,23 @@
import type { FastifyBaseLogger } from 'fastify';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import type { Agent, Message, Project, Session, ToolCall, UserStreamFrame } from '../types/api.js';
import { ALL_TOOLS, TOOLS_BY_NAME, toolJsonSchemas, type ToolJsonSchema } from './tools.js';
import type {
Agent,
ErrorReason,
Message,
MessageMetadata,
Project,
Session,
ToolCall,
UserStreamFrame,
} from '../types/api.js';
import {
ALL_TOOLS,
READ_ONLY_TOOL_NAMES,
TOOLS_BY_NAME,
toolJsonSchemas,
type ToolJsonSchema,
} from './tools.js';
import { PathScopeError, resolveProjectRoot } from './path_guard.js';
import { maybeAutoNameChat } from './auto_name.js';
import { getAgentById } from './agents.js';
@@ -11,7 +26,39 @@ const BASE_SYSTEM_PROMPT = (projectPath: string) =>
`You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`;
const DB_FLUSH_INTERVAL_MS = 500;
const MAX_TOOL_LOOP_DEPTH = 15;
// v1.8.2: tool-call budget defaults. Resolved per-turn by resolveToolBudget.
// - Agent with explicit max_tool_calls: that value.
// - Agent with read-only-only tools: BUDGET_READ_ONLY (30).
// - Agent with any non-read-only tool: BUDGET_NON_READ_ONLY (10).
// - No agent (raw chat): BUDGET_NO_AGENT (15).
const BUDGET_READ_ONLY = 30;
const BUDGET_NON_READ_ONLY = 10;
const BUDGET_NO_AGENT = 15;
const READ_ONLY_SET: ReadonlySet<string> = new Set(READ_ONLY_TOOL_NAMES);
function resolveToolBudget(agent: Agent | null): number {
if (agent?.max_tool_calls != null) return agent.max_tool_calls;
if (!agent) return BUDGET_NO_AGENT;
const allReadOnly = agent.tools.every((t) => READ_ONLY_SET.has(t));
return allReadOnly ? BUDGET_READ_ONLY : BUDGET_NON_READ_ONLY;
}
// Synthetic system note appended to the cap-hit summary call. Verbatim from
// the v1.8.2 spec — do not paraphrase: the model is more reliable when the
// instruction is short, declarative, and identical across calls.
const CAP_HIT_SUMMARY_NOTE = (limit: number) =>
`You've reached the tool budget (${limit} calls). Produce the best answer you can with what you have. Do not call more tools.`;
function isCapHitSentinel(m: Message): boolean {
return (
m.role === 'system' &&
m.metadata !== null &&
typeof m.metadata === 'object' &&
(m.metadata as { kind?: unknown }).kind === 'cap_hit'
);
}
export interface InferenceFrame {
type:
@@ -29,12 +76,22 @@ export interface InferenceFrame {
chat_id?: string;
tool_message_id?: string;
tool_call_id?: string;
role?: 'assistant' | 'tool' | 'user';
// v1.8.2: 'system' added so cap-hit sentinel messages can announce themselves
// through the normal message_started → delta → message_complete sequence.
role?: 'assistant' | 'tool' | 'user' | 'system';
content?: string;
tool_call?: ToolCall;
output?: unknown;
truncated?: boolean;
error?: string;
// v1.8.2: structured error reason. Set on `type: 'error'` so the UI can
// surface a specific message; `error` stays the human-readable text.
reason?: ErrorReason;
// v1.8.2: piggybacks on `message_complete` so static or terminally-resolved
// messages can carry their persisted metadata to the live stream without a
// refetch (sentinels carry { kind: 'cap_hit', ... }; failed messages carry
// { kind: 'error', ... }).
metadata?: MessageMetadata | null;
tokens_used?: number | null;
ctx_used?: number | null;
ctx_max?: number | null;
@@ -92,9 +149,11 @@ export interface InferenceContext {
publishUser: (frame: UserStreamFrame) => void;
}
// Resolution order: base prompt < agent.system_prompt < session.system_prompt.
// Agent prompts layer on top of the base; session prompt is the most specific
// override and stacks last so callers can append per-session instructions.
// Resolution order: base prompt < agent.system_prompt < user prompt, where
// user prompt = session.system_prompt if non-empty, else project's
// default_system_prompt if non-empty, else nothing. Empty/whitespace-only
// counts as "no override" for both layers (v1.9 inherit semantics — keeps
// the column non-nullable so the existing key/value store stays put).
export function buildSystemPrompt(
project: Project,
session: Session,
@@ -104,8 +163,11 @@ export function buildSystemPrompt(
if (agent && agent.system_prompt.trim().length > 0) {
out += '\n\n' + agent.system_prompt.trim();
}
if (session.system_prompt && session.system_prompt.trim().length > 0) {
out += '\n\n' + session.system_prompt.trim();
const sessionPrompt = session.system_prompt?.trim() ?? '';
const projectPrompt = project.default_system_prompt?.trim() ?? '';
const userPrompt = sessionPrompt || projectPrompt;
if (userPrompt.length > 0) {
out += '\n\n' + userPrompt;
}
return out;
}
@@ -135,6 +197,11 @@ export function buildMessagesPayload(
out.push({ role: 'system', content: m.content });
continue;
}
// v1.8.2: cap-hit sentinels are UI-only — never send them to the LLM. The
// synthetic "you've reached the tool budget" note lives only inside the
// summary call's messages array and is never persisted, so on Continue
// the model resumes with a clean context.
if (isCapHitSentinel(m)) continue;
if (m.role === 'assistant' && m.status === 'streaming') continue;
if (m.role === 'assistant' && m.status === 'cancelled') continue;
if (m.role === 'tool') {
@@ -178,14 +245,16 @@ async function loadContext(
chatId: string
): Promise<{ session: Session; project: Project; history: Message[] } | null> {
const sessionRows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at,
agent_id, web_search_enabled
FROM sessions WHERE id = ${sessionId}
`;
if (sessionRows.length === 0) return null;
const session = sessionRows[0]!;
const projectRows = await sql<Project[]>`
SELECT id, name, path, added_at, last_session_id
SELECT id, name, path, added_at, last_session_id, status, gitea_remote,
default_system_prompt, default_web_search_enabled
FROM projects WHERE id = ${session.project_id}
`;
if (projectRows.length === 0) return null;
@@ -193,7 +262,7 @@ async function loadContext(
const history = await sql<Message[]>`
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
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata
FROM messages
WHERE chat_id = ${chatId}
ORDER BY created_at ASC, id ASC
@@ -379,7 +448,10 @@ interface TurnArgs {
sessionId: string;
chatId: string;
assistantMessageId: string;
depth: number;
// v1.8.2: cumulative tool calls executed this run. Compared against the
// resolved budget at the top of each turn. Replaces the older `depth`
// counter (which counted iterations, not invocations).
toolsUsed: number;
signal: AbortSignal | undefined;
}
@@ -480,13 +552,32 @@ async function handleAbortOrError(
const { sessionId, chatId, assistantMessageId } = args;
const isAbort = err instanceof Error && err.name === 'AbortError';
const finalStatus = isAbort ? 'cancelled' : 'failed';
await ctx.sql`
UPDATE messages
SET status = ${finalStatus},
content = ${accumulated},
finished_at = clock_timestamp()
WHERE id = ${assistantMessageId}
`;
const errMsg = err instanceof Error ? err.message : String(err);
// v1.8.2: persist a structured error metadata blob on genuine failures so
// the bubble can render the reason on reload without re-deriving from the
// (one-shot) WS error frame. User-initiated abort skips this — there's no
// "reason" to surface for a stop the user already explicitly chose.
const errorMetadata: MessageMetadata | null = isAbort
? null
: { kind: 'error', error_reason: 'llm_provider_error', error_text: errMsg };
if (errorMetadata) {
await ctx.sql`
UPDATE messages
SET status = ${finalStatus},
content = ${accumulated},
finished_at = clock_timestamp(),
metadata = ${ctx.sql.json(errorMetadata as never)}
WHERE id = ${assistantMessageId}
`;
} else {
await ctx.sql`
UPDATE messages
SET status = ${finalStatus},
content = ${accumulated},
finished_at = clock_timestamp()
WHERE id = ${assistantMessageId}
`;
}
const [failSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>`
UPDATE sessions SET updated_at = clock_timestamp()
WHERE id = ${sessionId}
@@ -494,9 +585,10 @@ async function handleAbortOrError(
`;
ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: failSessRow!.project_id, name: failSessRow!.name, updated_at: failSessRow!.updated_at });
// v1.8 mobile-tabs: cancellation is a user-initiated stop, treat as idle;
// genuine errors flip the dot red.
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: isAbort ? 'idle' : 'error', at: new Date().toISOString() });
// genuine errors flip the dot red. v1.8.2: error path also carries a
// machine-readable `reason` so the UI can render specifics inline.
if (isAbort) {
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() });
ctx.publish(sessionId, {
type: 'message_complete',
message_id: assistantMessageId,
@@ -504,12 +596,19 @@ async function handleAbortOrError(
});
ctx.log.info({ sessionId, chatId, assistantMessageId }, 'inference cancelled');
} else {
const errMsg = err instanceof Error ? err.message : String(err);
ctx.publishUser({
type: 'chat_status',
chat_id: chatId,
status: 'error',
at: new Date().toISOString(),
reason: 'llm_provider_error',
});
ctx.publish(sessionId, {
type: 'error',
message_id: assistantMessageId,
chat_id: chatId,
error: errMsg,
reason: 'llm_provider_error',
});
ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed');
}
@@ -523,7 +622,7 @@ async function executeToolPhase(
session: Session,
projectRoot: string
): Promise<void> {
const { sessionId, chatId, assistantMessageId, depth, signal } = args;
const { sessionId, chatId, assistantMessageId, toolsUsed, signal } = args;
const { content, toolCalls, promptTokens, completionTokens, nCtx } = result;
const [updated] = await ctx.sql<
@@ -607,7 +706,10 @@ async function executeToolPhase(
sessionId,
chatId,
assistantMessageId: nextAssistant!.id,
depth: depth + 1,
// v1.8.2: charge this turn's actual tool invocations against the budget.
// One assistant message can emit multiple tool_calls, so we add the run
// count, not 1. The next turn's budget check sees the cumulative total.
toolsUsed: toolsUsed + result.toolCalls.length,
signal,
});
}
@@ -671,25 +773,7 @@ async function runAssistantTurn(
ctx: InferenceContext,
args: TurnArgs,
): Promise<void> {
const { sessionId, chatId, assistantMessageId, depth } = args;
if (depth > MAX_TOOL_LOOP_DEPTH) {
await ctx.sql`
UPDATE messages
SET status = 'failed',
content = ${'tool loop depth exceeded'},
finished_at = clock_timestamp()
WHERE id = ${assistantMessageId}
`;
ctx.publish(sessionId, {
type: 'error',
message_id: assistantMessageId,
chat_id: chatId,
error: 'tool loop depth exceeded',
});
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'error', at: new Date().toISOString() });
return;
}
const { sessionId, chatId } = args;
const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (!loaded) {
@@ -704,6 +788,17 @@ async function runAssistantTurn(
const agent = session.agent_id
? await getAgentById(project.path, session.agent_id)
: null;
// v1.8.2: cap-hit replaces the older "tool loop depth exceeded" failure.
// When we've already burned the budget *before* this turn even runs, we
// skip straight to the summary flow — the in-flight assistant message slot
// gets reused for the wrap-up reply instead of being marked failed.
const budget = resolveToolBudget(agent);
if (args.toolsUsed >= budget) {
await runCapHitSummary(ctx, args, session, project, history, agent, budget);
return;
}
const messages = buildMessagesPayload(session, project, history, agent);
const state: StreamPhaseState = { accumulated: '', startedAt: null };
@@ -730,7 +825,264 @@ export async function runInference(
assistantMessageId: string,
signal?: AbortSignal
): Promise<void> {
return runAssistantTurn(ctx, { sessionId, chatId, assistantMessageId, depth: 0, signal });
// v1.8.2: every fresh inference (initial send, regenerate, force_send,
// continue) starts with a clean budget. Tool-call accumulation across
// Continue invocations is what the hard ceiling guards against, not the
// per-call budget.
return runAssistantTurn(ctx, { sessionId, chatId, assistantMessageId, toolsUsed: 0, signal });
}
// v1.8.2: cap-hit summary flow. Called instead of erroring when the loop
// hits its budget. Reuses the in-flight assistant message slot to stream a
// short wrap-up reply with the synthetic note prepended and tools disabled,
// then always inserts a cap_hit sentinel afterward (regardless of summary
// outcome) so the UI can show a Continue affordance.
async function runCapHitSummary(
ctx: InferenceContext,
args: TurnArgs,
session: Session,
project: Project,
history: Message[],
agent: Agent | null,
budget: number,
): Promise<void> {
const { sessionId, chatId, assistantMessageId, signal } = args;
const messages = buildMessagesPayload(session, project, history, agent);
messages.push({ role: 'system', content: CAP_HIT_SUMMARY_NOTE(budget) });
const startedRow = await ctx.sql<{ started_at: string }[]>`
UPDATE messages
SET started_at = clock_timestamp()
WHERE id = ${assistantMessageId}
RETURNING started_at
`;
const startedAt = startedRow[0]?.started_at ?? null;
ctx.publish(sessionId, {
type: 'message_started',
message_id: assistantMessageId,
chat_id: chatId,
role: 'assistant',
});
let accumulated = '';
let pendingFlushTimer: NodeJS.Timeout | null = null;
let flushPromise: Promise<unknown> = Promise.resolve();
const flushNow = () => {
if (pendingFlushTimer) {
clearTimeout(pendingFlushTimer);
pendingFlushTimer = null;
}
const snapshot = accumulated;
flushPromise = flushPromise.then(() =>
ctx.sql`UPDATE messages SET content = ${snapshot} WHERE id = ${assistantMessageId}`
);
};
const scheduleFlush = () => {
if (pendingFlushTimer) return;
pendingFlushTimer = setTimeout(() => {
pendingFlushTimer = null;
flushNow();
}, DB_FLUSH_INTERVAL_MS);
};
let summaryOk = false;
let summarySoftCancelled = false;
let summaryError: string | null = null;
let result: StreamResult | null = null;
try {
result = await streamCompletion(
ctx,
session.model,
messages,
{ tools: null, temperature: agent?.temperature },
(delta) => {
accumulated += delta;
ctx.publish(sessionId, {
type: 'delta',
message_id: assistantMessageId,
chat_id: chatId,
content: delta,
});
scheduleFlush();
},
signal,
);
summaryOk = true;
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
summarySoftCancelled = true;
} else {
summaryError = err instanceof Error ? err.message : String(err);
}
} finally {
if (pendingFlushTimer) {
clearTimeout(pendingFlushTimer);
pendingFlushTimer = null;
}
await flushPromise;
}
// Finalize the summary message based on the three outcomes. The sentinel
// is inserted regardless so the user always has the Continue affordance —
// even on a partial / failed summary the chat history shows where the
// budget was hit.
if (summaryOk && result) {
const [updated] = await ctx.sql<
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
>`
UPDATE messages
SET content = ${result.content},
status = 'complete',
tokens_used = ${result.completionTokens},
ctx_used = ${result.promptTokens},
ctx_max = ${result.nCtx},
finished_at = clock_timestamp()
WHERE id = ${assistantMessageId}
RETURNING tokens_used, ctx_used, ctx_max, finished_at
`;
ctx.publish(sessionId, {
type: 'message_complete',
message_id: assistantMessageId,
chat_id: chatId,
tokens_used: updated?.tokens_used ?? null,
ctx_used: updated?.ctx_used ?? null,
ctx_max: updated?.ctx_max ?? null,
started_at: startedAt,
finished_at: updated?.finished_at ?? null,
model: session.model,
});
} else if (summarySoftCancelled) {
await ctx.sql`
UPDATE messages
SET content = ${accumulated},
status = 'cancelled',
finished_at = clock_timestamp()
WHERE id = ${assistantMessageId}
`;
ctx.publish(sessionId, {
type: 'message_complete',
message_id: assistantMessageId,
chat_id: chatId,
});
} else {
const errMeta: MessageMetadata = {
kind: 'error',
error_reason: 'summary_after_cap_failed',
error_text: summaryError ?? 'summary failed',
};
await ctx.sql`
UPDATE messages
SET content = ${accumulated},
status = 'failed',
finished_at = clock_timestamp(),
metadata = ${ctx.sql.json(errMeta as never)}
WHERE id = ${assistantMessageId}
`;
ctx.publish(sessionId, {
type: 'error',
message_id: assistantMessageId,
chat_id: chatId,
error: summaryError ?? 'summary failed',
reason: 'summary_after_cap_failed',
});
}
// Bump session/chat updated_at exactly once for this turn.
const [sessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>`
UPDATE sessions SET updated_at = clock_timestamp()
WHERE id = ${sessionId}
RETURNING project_id, name, updated_at
`;
ctx.publishUser({
type: 'session_updated',
session_id: sessionId,
project_id: sessRow!.project_id,
name: sessRow!.name,
updated_at: sessRow!.updated_at,
});
await insertCapHitSentinel(ctx, sessionId, chatId, agent, budget);
// Status frame fires last so the dot color reflects the terminal state.
// Success → idle, abort → idle (user-driven stop), error → error+reason.
if (summaryOk) {
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() });
} else if (summarySoftCancelled) {
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() });
} else {
ctx.publishUser({
type: 'chat_status',
chat_id: chatId,
status: 'error',
at: new Date().toISOString(),
reason: 'summary_after_cap_failed',
});
}
ctx.log.info(
{ sessionId, chatId, assistantMessageId, budget, summaryOk, summaryCancelled: summarySoftCancelled },
'inference cap-hit summary finished',
);
}
async function insertCapHitSentinel(
ctx: InferenceContext,
sessionId: string,
chatId: string,
agent: Agent | null,
budget: number,
): Promise<void> {
// Hard ceiling: count prior cap_hit sentinels in this chat. After two
// continues (sentinel count of 2), the next sentinel reports can_continue
// false and the UI disables the Continue button.
const priorRows = await ctx.sql<{ count: number }[]>`
SELECT COUNT(*)::int AS count
FROM messages
WHERE chat_id = ${chatId}
AND role = 'system'
AND metadata->>'kind' = 'cap_hit'
`;
const priorCount = priorRows[0]?.count ?? 0;
const canContinue = priorCount < 2;
const metadata: MessageMetadata = {
kind: 'cap_hit',
used: budget,
limit: budget,
agent_name: agent?.name ?? null,
can_continue: canContinue,
};
const content = `Reached tool budget (${budget}/${budget}). Continue to extend.`;
const [row] = await ctx.sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at, metadata)
VALUES (${sessionId}, ${chatId}, 'system', ${content}, 'complete', clock_timestamp(), ${ctx.sql.json(metadata as never)})
RETURNING id
`;
// The sentinel content is static, but we still walk the standard frame
// sequence (started → delta → complete) so useSessionStream's reducer
// appends it via the same path it uses for streaming assistant messages.
// The delta carries the full text in one chunk.
ctx.publish(sessionId, {
type: 'message_started',
message_id: row!.id,
chat_id: chatId,
role: 'system',
});
ctx.publish(sessionId, {
type: 'delta',
message_id: row!.id,
chat_id: chatId,
content,
});
ctx.publish(sessionId, {
type: 'message_complete',
message_id: row!.id,
chat_id: chatId,
metadata,
});
}
const COMPACT_SYSTEM_PROMPT =

View File

@@ -308,6 +308,19 @@ export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
gitStatus as ToolDef<unknown>,
];
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
// fully contained in this set gets a generous default tool budget (30);
// anything outside means the agent can mutate state and gets a tighter
// default (10). Every tool in v1.8.2 happens to be read-only, so the
// non-RO branch only takes effect once BooCoder lands write tools.
export const READ_ONLY_TOOL_NAMES = [
'view_file',
'list_dir',
'grep',
'find_files',
'git_status',
] as const;
export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
ALL_TOOLS.map((t) => [t.name, t])
);

View File

@@ -10,6 +10,12 @@ export interface Project {
last_session_id: string | null;
status: ProjectStatus;
gitea_remote: string | null;
// v1.9: per-project defaults inherited by new sessions. Empty string on
// default_system_prompt means "no override" — the model gets the base
// BooCode system prompt only. default_web_search_enabled is the inherited
// value for sessions where web_search_enabled is null.
default_system_prompt: string;
default_web_search_enabled: boolean;
}
export interface AvailableProject {
@@ -29,6 +35,10 @@ export interface Session {
created_at: string;
updated_at: string;
agent_id: string | null;
// v1.9: per-session override for web_search. null = inherit from
// project.default_web_search_enabled. Plumbed but inert in v1.9 — the
// actual web_search tool ships in Batch 8.
web_search_enabled: boolean | null;
}
// v1.8.1: agents come from two sources. 'global' = /data/AGENTS.md (always
@@ -45,6 +55,10 @@ export interface Agent {
tools: string[]; // whitelist of tool names; empty = no tools allowed
model: string | null; // null means "session.model wins"
source: AgentSource;
// v1.8.2: per-agent tool-loop budget. null means resolve at runtime from the
// agent's toolset (30 if all tools are read-only, 10 otherwise) or 15 for
// raw chat with no agent.
max_tool_calls: number | null;
}
// One entry per malformed `## Name` block. Per-block errors don't fail the
@@ -100,6 +114,31 @@ export interface ToolResult {
error?: string;
}
// v1.8.2: structured reason codes for failed inferences. `error` carries the
// human text; `reason` is the machine-readable discriminator the UI matches
// on (with `error` as fallback when reason is absent or unrecognized).
export type ErrorReason =
| 'llm_provider_error'
| 'tool_execution_failed'
| 'summary_after_cap_failed';
// v1.8.2: shapes stored in messages.metadata. Discriminated on `kind`.
// cap_hit — system sentinel emitted when tool budget is exhausted
// error — attached to a failed assistant message so UI can show reason
export type MessageMetadata =
| {
kind: 'cap_hit';
used: number;
limit: number;
agent_name: string | null;
can_continue: boolean;
}
| {
kind: 'error';
error_reason: ErrorReason;
error_text: string;
};
export interface Message {
id: string;
session_id: string;
@@ -117,6 +156,9 @@ export interface Message {
started_at: string | null;
finished_at: string | null;
created_at: string;
// v1.8.2: per-message metadata. See MessageMetadata for the discriminated
// shapes currently in use.
metadata: MessageMetadata | null;
}
export interface ModelInfo {
@@ -257,11 +299,14 @@ export interface ProjectUpdatedFrame {
}
// v1.8 mobile-tabs: server can't know about client-side panes, so status
// is keyed by chat_id. Frontend dot derives pane status from pane.activeChatId.
// v1.8.2: optional `reason` carries a machine-readable code when status is
// 'error'. UI prefers reason; falls back to no detail when absent.
export interface ChatStatusFrame {
type: 'chat_status';
chat_id: string;
status: 'working' | 'idle' | 'error';
at: string;
reason?: ErrorReason;
}
export type UserStreamFrame =
| ProjectCreatedFrame

View File

@@ -4,8 +4,31 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BooCode</title>
<script>
// themes-v1 FOUC guard: read the last-applied theme from localStorage
// and stamp the class on <html> before React mounts. Falls back to
// obsidian + dark when no cache. Light-only themes (ivory, chalk) with
// a dark mode pref fall back to obsidian dark — mirrors the rule in
// lib/theme.ts effectiveThemeId().
(function () {
try {
var t = JSON.parse(localStorage.getItem('boocode.theme') || '{}');
var id = t.id || 'obsidian';
var mode = t.mode || 'dark';
if (mode === 'system') {
mode = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
if ((id === 'ivory' || id === 'chalk') && mode === 'dark') {
id = 'obsidian';
}
document.documentElement.className = 'theme-' + id + (mode === 'dark' ? ' dark' : '');
} catch (e) {
document.documentElement.className = 'theme-obsidian dark';
}
})();
</script>
</head>
<body class="bg-neutral-950 text-neutral-100">
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

View File

@@ -6,8 +6,10 @@ import { RightRail } from '@/components/RightRail';
import { Home } from '@/pages/Home';
import { Project } from '@/pages/Project';
import { Session } from '@/pages/Session';
import { Settings } from '@/pages/Settings';
import { Toaster } from '@/components/ui/sonner';
import { useUserEvents } from '@/hooks/useUserEvents';
import { useTheme } from '@/lib/theme';
import { SidebarDrawerProvider, useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { RightRailDrawerProvider, useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport';
@@ -61,9 +63,13 @@ function MobileRightRailBackdrop() {
}
function AppShell() {
// themes-v1: useTheme() owns the matchMedia subscription for system mode
// and reconciles cache with /api/settings on mount. Mounted first so the
// theme class on <html> is correct before any child renders.
useTheme();
useUserEvents();
return (
<div className="dark h-screen flex bg-background text-foreground">
<div className="h-screen flex bg-background text-foreground">
<ProjectSidebar />
<MobileBackdrop />
<main className="flex-1 flex flex-col min-w-0">
@@ -71,6 +77,7 @@ function AppShell() {
<Route path="/" element={<Home />} />
<Route path="/project/:id" element={<Project />} />
<Route path="/session/:id" element={<Session />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</main>
<MobileRightRailBackdrop />

View File

@@ -51,15 +51,29 @@ export const api = {
method: 'POST',
body: JSON.stringify(body),
}),
update: (id: string, body: { name: string }) =>
update: (
id: string,
body: Partial<Pick<Project, 'name' | 'default_system_prompt' | 'default_web_search_enabled'>>,
) =>
request<Project>(`/api/projects/${id}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
get: (id: string) => request<Project>(`/api/projects/${id}`),
archive: (id: string) =>
request<void>(`/api/projects/${id}/archive`, { method: 'POST' }),
unarchive: (id: string) =>
request<Project>(`/api/projects/${id}/unarchive`, { method: 'POST' }),
// v1.9: bulk-archive every open session in this project. Server publishes
// one session_archived frame per affected id, so the sidebar reducer
// updates incrementally rather than waiting for a refetch.
archiveAllSessions: (id: string) =>
request<{ archived: number; ids: string[] }>(
`/api/projects/${id}/sessions/archive-all`,
{ method: 'POST' },
),
openSessionsCount: (id: string) =>
request<{ count: number }>(`/api/projects/${id}/sessions/open-count`),
create: (body: {
name: string;
commit_message?: string;
@@ -106,7 +120,7 @@ export const api = {
get: (id: string) => request<Session>(`/api/sessions/${id}`),
update: (
id: string,
body: Partial<Pick<Session, 'name' | 'model' | 'system_prompt' | 'agent_id'>>
body: Partial<Pick<Session, 'name' | 'model' | 'system_prompt' | 'agent_id' | 'web_search_enabled'>>
) =>
request<Session>(`/api/sessions/${id}`, {
method: 'PATCH',
@@ -118,6 +132,15 @@ export const api = {
request<void>(`/api/sessions/${id}/archive`, { method: 'POST' }),
unarchive: (id: string) =>
request<Session>(`/api/sessions/${id}/unarchive`, { method: 'POST' }),
// v1.9: bulk-archive every open chat in this session. Same pattern as
// archiveAllSessions — server publishes one chat_archived per id.
archiveAllChats: (id: string) =>
request<{ archived: number; ids: string[] }>(
`/api/sessions/${id}/chats/archive-all`,
{ method: 'POST' },
),
openChatsCount: (id: string) =>
request<{ count: number }>(`/api/sessions/${id}/chats/open-count`),
},
chats: {
@@ -152,6 +175,13 @@ export const api = {
`/api/chats/${chatId}/force_send`,
{ method: 'POST', body: JSON.stringify({ content }) }
),
// v1.8.2: extend an inference that hit the tool budget. `sentinelMessageId`
// is the cap-hit sentinel message the user clicked Continue on.
continue: (chatId: string, sentinelMessageId: string) =>
request<{ assistant_message_id: string }>(
`/api/chats/${chatId}/continue`,
{ method: 'POST', body: JSON.stringify({ sentinel_message_id: sentinelMessageId }) }
),
fork: (chatId: string, body: { messageId: string; name?: string }) =>
request<Chat>(`/api/chats/${chatId}/fork`, {
method: 'POST',

View File

@@ -9,6 +9,10 @@ export interface Project {
last_session_id: string | null;
status: ProjectStatus;
gitea_remote: string | null;
// v1.9: per-project defaults. Empty string on default_system_prompt means
// "no override" — inference falls through to the base system prompt.
default_system_prompt: string;
default_web_search_enabled: boolean;
}
export interface AvailableProject {
@@ -28,6 +32,8 @@ export interface Session {
created_at: string;
updated_at: string;
agent_id: string | null;
// v1.9: null = inherit from project.default_web_search_enabled.
web_search_enabled: boolean | null;
}
// v1.8.1: 'global' = /data/AGENTS.md (always-on), 'project' = per-project
@@ -44,6 +50,10 @@ export interface Agent {
tools: string[];
model: string | null;
source: AgentSource;
// v1.8.2: per-agent tool-loop budget. null means resolve at runtime from
// the agent's toolset (30 for all read-only, 10 otherwise) or 15 for raw
// chat with no agent.
max_tool_calls: number | null;
}
export interface AgentParseError {
@@ -89,6 +99,32 @@ export interface ToolResult {
error?: string;
}
// v1.8.2: structured reason codes that flow through error frames / metadata.
// `error` text stays human; `reason` is the discriminator the UI matches on.
export type ErrorReason =
| 'llm_provider_error'
| 'tool_execution_failed'
| 'summary_after_cap_failed';
// v1.8.2: shapes stored in Message.metadata. Discriminated on `kind`.
// cap_hit — sentinel emitted when the tool budget is hit; carries the
// budget + agent name + whether Continue is still allowed.
// error — attached to a failed assistant message so the bubble can show
// a specific reason on reload (WS error frame is one-shot).
export type MessageMetadata =
| {
kind: 'cap_hit';
used: number;
limit: number;
agent_name: string | null;
can_continue: boolean;
}
| {
kind: 'error';
error_reason: ErrorReason;
error_text: string;
};
export interface Message {
id: string;
session_id: string;
@@ -106,6 +142,9 @@ export interface Message {
started_at: string | null;
finished_at: string | null;
created_at: string;
// v1.8.2: per-message metadata; see MessageMetadata. null for the vast
// majority of messages.
metadata: MessageMetadata | null;
}
export interface ModelInfo {
@@ -192,7 +231,10 @@ export interface GitMeta {
behind: number;
}
export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty';
// v1.9: 'settings' is an ephemeral pane kind — never persisted, always
// singleton per workspace. The pane hook filters it out before writing to
// localStorage and dedupes on insertion via openOrFocusSettingsPane().
export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty' | 'settings';
export interface WorkspacePane {
id: string;
@@ -225,7 +267,13 @@ export type WsFrame =
ctx_max?: number | null;
started_at?: string | null;
finished_at?: string | null;
// v1.8.2: piggybacks the persisted metadata onto the terminal frame so
// cap-hit sentinels (and any future stamped-on-complete metadata) flow
// to the client without a refetch.
metadata?: MessageMetadata | null;
}
| { type: 'messages_deleted'; message_ids: string[]; chat_id?: string }
| { type: 'chat_renamed'; chat_id: string; name: string }
| { type: 'error'; message_id?: string; chat_id?: string; error: string };
// v1.8.2: `reason` discriminates structured failures (the UI prefers it
// over `error` text when present).
| { type: 'error'; message_id?: string; chat_id?: string; error: string; reason?: ErrorReason };

View File

@@ -0,0 +1,90 @@
import { useState } from 'react';
import { AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { Message } from '@/api/types';
import { Button } from '@/components/ui/button';
interface Props {
message: Message;
// 1-indexed position among cap-hit sentinels in this chat. The first
// cap-hit is 1, second is 2, third is 3 (hard ceiling).
capHitPosition: number;
// Only the most recent sentinel shows the Continue button. Older ones
// render text-only — they've already been continued past.
isLatest: boolean;
}
// Hard ceiling = 3 cap-hits per chat ⇒ 2 continues max. Lives here in sync
// with insertCapHitSentinel's `canContinue = priorCount < 2` rule in
// services/inference.ts.
const MAX_CONTINUES = 2;
export function CapHitSentinel({ message, capHitPosition, isLatest }: Props) {
const meta = message.metadata;
// Defensive parse — if the row is somehow missing metadata we still render
// the bare text rather than crashing the chat.
const isCapHit =
meta !== null && typeof meta === 'object' && meta.kind === 'cap_hit';
const limit = isCapHit ? meta.limit : null;
const canContinue = isCapHit ? meta.can_continue : false;
const agentName = isCapHit ? meta.agent_name : null;
// `capHitPosition` is 1-indexed; `MAX_CONTINUES - (position - 1)` is the
// number of continues remaining including this one. Clamped to ≥0.
const remaining = Math.max(0, MAX_CONTINUES - (capHitPosition - 1));
const [continuing, setContinuing] = useState(false);
async function handleContinue() {
if (continuing || !canContinue || !isLatest) return;
setContinuing(true);
try {
await api.chats.continue(message.chat_id, message.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'continue failed');
} finally {
setContinuing(false);
}
}
// Tooltip wording from the v1.8.2 spec. Disabled state takes precedence —
// the spec text "Hard limit reached — start a new chat" matches what the
// server returns when canContinue is false.
const enabledTooltip = limit
? `Resumes with a fresh budget of ${limit} tool calls. ${remaining} continue${remaining === 1 ? '' : 's'} remaining on this chat.`
: undefined;
const disabledTooltip = 'Hard limit reached — start a new chat';
return (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 text-sm">
<div className="px-3 py-2 flex items-start gap-2">
<AlertCircle className="size-4 text-amber-500 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0 space-y-1">
<div className="text-xs font-medium text-amber-700 dark:text-amber-300">
{isCapHit && limit !== null
? `Reached tool budget (${limit}/${limit})${agentName ? `${agentName}` : ''}.`
: 'Reached tool budget.'}
</div>
<div className="text-xs text-muted-foreground">
{message.content}
</div>
{isLatest && (
<div className="pt-1">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void handleContinue()}
disabled={!canContinue || continuing}
title={canContinue ? enabledTooltip : disabledTooltip}
>
{continuing ? 'Continuing…' : 'Continue'}
</Button>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,8 +1,14 @@
import { useCallback, useEffect, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
import { Send } from 'lucide-react';
import { Check, Plus, Send } from 'lucide-react';
import { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
flattenToMessage,
inferLanguage,
@@ -29,11 +35,18 @@ interface Props {
// When omitted, the toolbar row is hidden entirely.
agentId?: string | null;
onAgentChange?: (agentId: string | null) => void | Promise<void>;
// v1.9: when sessionId + webSearchEnabled are both provided, the + menu
// renders next to the AgentPicker with a single "Web search" toggle item.
// The check reflects the *stored* session value (not the effective one):
// null counts as unchecked. Clicking PATCHes session.web_search_enabled
// with the inverted boolean (null → true, true → false, false → true).
sessionId?: string;
webSearchEnabled?: boolean | null;
onSend: (content: string) => void | Promise<void>;
onForceSend?: (content: string) => void | Promise<void>;
}
export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend, onForceSend }: Props) {
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend }: Props) {
const { isMobile } = useViewport();
const [value, setValue] = useState('');
const [busy, setBusy] = useState(false);
@@ -425,16 +438,51 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend,
))}
</div>
)}
{/* Batch 9 toolbar — agent picker. Sits above the input row so it
doesn't compete with the send button for vertical alignment.
When Batch 7 lands, ModelPicker and the + button join this row. */}
{onAgentChange && (
{/* Batch 9 toolbar — agent picker. v1.9 adds the icon-only + menu next
to it for quick toggles (currently: Web search). When omitted at the
callsite the row stays collapsed so nothing else has to change. */}
{(onAgentChange || sessionId) && (
<div className="px-4 pt-2 flex items-center gap-1.5">
<AgentPicker
projectId={projectId}
value={agentId ?? null}
onChange={onAgentChange}
/>
{onAgentChange && (
<AgentPicker
projectId={projectId}
value={agentId ?? null}
onChange={onAgentChange}
/>
)}
{sessionId && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<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'}`} />
Web search
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
<div className="px-4 py-3 flex items-end gap-2">

View File

@@ -4,10 +4,10 @@ import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import type { Chat, Message } from '@/api/types';
import type { Chat, ErrorReason, Message } from '@/api/types';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { ToolCallCard } from './ToolCallCard';
import { CapHitSentinel } from './CapHitSentinel';
import { CodeBlock } from './CodeBlock';
import { Button } from '@/components/ui/button';
import {
@@ -19,6 +19,15 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
// v1.8.2: human labels for the machine-readable error reasons that ride on
// failed assistant messages via metadata.kind === 'error'. Kept short so the
// inline render under "message failed" stays a single muted line.
const ERROR_REASON_LABELS: Record<ErrorReason, string> = {
llm_provider_error: 'LLM provider error',
tool_execution_failed: 'Tool execution failed',
summary_after_cap_failed: 'Summary after tool budget hit failed',
};
// Match path-shaped substrings ending in `.ext`. Additionally require a `/`
// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't
// match, but `src/foo.ts` will). False positives at the edges are accepted
@@ -94,6 +103,9 @@ function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
interface Props {
message: Message;
sessionChats?: Chat[];
// v1.8.2: passed by MessageList's render-item pass for cap-hit sentinels.
// Only the most recent sentinel shows the Continue button.
capHitInfo?: { position: number; isLatest: boolean };
}
function MarkdownBody({ content }: { content: string }) {
@@ -464,15 +476,34 @@ function CompactCard({ message, sessionChats }: { message: Message; sessionChats
);
}
export function MessageBubble({ message, sessionChats }: Props) {
export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
if (message.kind === 'compact') {
return <CompactCard message={message} sessionChats={sessionChats} />;
}
if (message.role === 'tool') {
return <ToolCallCard message={message} />;
// v1.8.2: cap-hit sentinels render as a distinct system bubble with a
// Continue button. MessageList's pre-render pass tags each sentinel with
// its position; only the latest gets the actionable button.
if (
message.role === 'system' &&
message.metadata?.kind === 'cap_hit' &&
capHitInfo
) {
return (
<CapHitSentinel
message={message}
capHitPosition={capHitInfo.position}
isLatest={capHitInfo.isLatest}
/>
);
}
// v1.8.2: tool messages and assistant tool_calls are now rendered by
// MessageList via ToolCallLine / ToolCallGroup. Tool-role messages reach
// this point only if MessageList didn't consume them (shouldn't happen,
// but guard against it by rendering nothing rather than a stale card).
if (message.role === 'tool') return null;
if (message.role === 'user') {
return (
<div className="group flex flex-col items-end gap-1">
@@ -487,14 +518,17 @@ export function MessageBubble({ message, sessionChats }: Props) {
const isStreaming = message.status === 'streaming';
const failed = message.status === 'failed';
const hasContent = message.content.length > 0;
const hasToolCalls = (message.tool_calls?.length ?? 0) > 0;
// v1.8.2: if metadata stamps an error reason, surface it inline under the
// generic "message failed" line. Keeps the user's eye where it already is
// rather than introducing a separate banner.
const errorMeta =
message.metadata !== null && message.metadata.kind === 'error'
? message.metadata
: null;
return (
<div className="group flex flex-col gap-2">
{message.tool_calls?.map((tc) => (
<ToolCallCard key={tc.id} toolCall={tc} />
))}
{(hasContent || (!hasToolCalls && isStreaming)) && (
{(hasContent || isStreaming) && (
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
{hasContent ? <MarkdownBody content={message.content} /> : null}
{isStreaming && (
@@ -503,12 +537,18 @@ export function MessageBubble({ message, sessionChats }: Props) {
</div>
)}
{failed && (
<div className="text-xs text-destructive">message failed</div>
<div className="text-xs text-destructive">
message failed
{errorMeta && (
<span className="block text-muted-foreground mt-0.5">
{ERROR_REASON_LABELS[errorMeta.error_reason]}
{errorMeta.error_text ? `${errorMeta.error_text}` : ''}
</span>
)}
</div>
)}
{!isStreaming && <StatsLine message={message} />}
{!isStreaming && (hasContent || hasToolCalls) && (
<ActionRow message={message} />
)}
{!isStreaming && hasContent && <ActionRow message={message} />}
</div>
);
}

View File

@@ -1,15 +1,128 @@
import { useEffect, useRef } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import type { Chat, Message } from '@/api/types';
import { MessageBubble } from './MessageBubble';
import { ToolCallGroup } from './ToolCallGroup';
import { ToolCallLine, type ToolRun } from './ToolCallLine';
interface Props {
messages: Message[];
sessionChats?: Chat[];
}
// v1.8.2: pre-render units. The single linear `messages` array gets walked
// into a render-time list where each tool_call is a first-class item and
// tool_result messages are folded onto their matching tool_run by id.
type RenderItem =
| { kind: 'message'; message: Message; capHitInfo?: { position: number; isLatest: boolean } }
| { kind: 'tool_run'; run: ToolRun; key: string }
| { kind: 'tool_group'; runs: ToolRun[]; key: string };
const GROUP_THRESHOLD = 3;
function isCapHitSentinel(m: Message): boolean {
return m.role === 'system' && m.metadata?.kind === 'cap_hit';
}
// First pass: walk messages chronologically, expanding assistant tool_calls
// into per-call run items and folding tool_result messages onto their
// matching runs. Tool messages themselves never produce a render item.
// Assistant messages produce a text render item only when they have text;
// pure tool-call messages are "transparent" so consecutive tool runs can
// still group across them.
function flatten(messages: Message[]): RenderItem[] {
const items: RenderItem[] = [];
const runsByCallId = new Map<string, ToolRun>();
for (const m of messages) {
if (m.role === 'tool') {
if (m.tool_results) {
const run = runsByCallId.get(m.tool_results.tool_call_id);
if (run) run.result = m.tool_results;
}
continue;
}
const hasToolCalls = m.tool_calls != null && m.tool_calls.length > 0;
const hasText = m.content.length > 0;
if (m.role === 'assistant' && hasToolCalls) {
if (hasText || m.status === 'streaming') {
items.push({ kind: 'message', message: m });
}
for (const tc of m.tool_calls!) {
const run: ToolRun = { call: tc, result: null };
runsByCallId.set(tc.id, run);
items.push({ kind: 'tool_run', run, key: tc.id });
}
continue;
}
items.push({ kind: 'message', message: m });
}
return items;
}
// Second pass: collapse runs of >=GROUP_THRESHOLD consecutive tool_run items
// of the same tool name into a single tool_group. Any other render item
// (text bubble, sentinel, user message) breaks the chain.
function group(items: RenderItem[]): RenderItem[] {
const out: RenderItem[] = [];
let i = 0;
while (i < items.length) {
const item = items[i]!;
if (item.kind !== 'tool_run') {
out.push(item);
i += 1;
continue;
}
const name = item.run.call.name;
let j = i + 1;
while (
j < items.length &&
items[j]!.kind === 'tool_run' &&
(items[j] as { kind: 'tool_run'; run: ToolRun }).run.call.name === name
) {
j += 1;
}
const run = items.slice(i, j) as Array<{ kind: 'tool_run'; run: ToolRun; key: string }>;
if (run.length >= GROUP_THRESHOLD) {
out.push({
kind: 'tool_group',
runs: run.map((r) => r.run),
key: `group-${run[0]!.key}`,
});
} else {
for (const r of run) out.push(r);
}
i = j;
}
return out;
}
// Third pass: number cap-hit sentinels (1-indexed) and mark the latest.
// CapHitSentinel uses position to compute the "N continues remaining"
// tooltip, and isLatest to gate the Continue button (only the most recent
// sentinel is actionable).
function stampCapHits(items: RenderItem[]): RenderItem[] {
const totalCapHits = items.reduce(
(n, it) => n + (it.kind === 'message' && isCapHitSentinel(it.message) ? 1 : 0),
0,
);
if (totalCapHits === 0) return items;
let index = 0;
return items.map((it) => {
if (it.kind !== 'message' || !isCapHitSentinel(it.message)) return it;
index += 1;
return {
...it,
capHitInfo: { position: index, isLatest: index === totalCapHits },
};
});
}
export function MessageList({ messages, sessionChats }: Props) {
const endRef = useRef<HTMLDivElement>(null);
const renderItems = useMemo(() => stampCapHits(group(flatten(messages))), [messages]);
useEffect(() => {
endRef.current?.scrollIntoView({ block: 'end' });
}, [messages]);
@@ -25,9 +138,22 @@ export function MessageList({ messages, sessionChats }: Props) {
return (
<div className="flex-1 overflow-y-auto">
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
{messages.map((m) => (
<MessageBubble key={m.id} message={m} sessionChats={sessionChats} />
))}
{renderItems.map((item) => {
if (item.kind === 'message') {
return (
<MessageBubble
key={item.message.id}
message={item.message}
sessionChats={sessionChats}
capHitInfo={item.capHitInfo}
/>
);
}
if (item.kind === 'tool_run') {
return <ToolCallLine key={item.key} run={item.run} />;
}
return <ToolCallGroup key={item.key} runs={item.runs} />;
})}
<div ref={endRef} />
</div>
</div>

View File

@@ -5,6 +5,7 @@ import {
Edit2,
MessageSquare,
MoreHorizontal,
Settings as SettingsIcon,
Terminal,
X,
} from 'lucide-react';
@@ -33,6 +34,7 @@ interface Props {
function paneIcon(kind: WorkspacePane['kind']) {
if (kind === 'terminal') return <Terminal size={14} />;
if (kind === 'agent') return <Bot size={14} />;
if (kind === 'settings') return <SettingsIcon size={14} />;
return <MessageSquare size={14} />;
}
@@ -53,6 +55,7 @@ function paneLabel(pane: WorkspacePane, chats: Chat[]): string {
if (pane.kind === 'chat') return 'Chat';
if (pane.kind === 'terminal') return 'Terminal';
if (pane.kind === 'agent') return 'Agent';
if (pane.kind === 'settings') return 'Settings';
return 'Empty';
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Check, ChevronDown } from 'lucide-react';
import { Check, ChevronDown, Cpu } from 'lucide-react';
import { api } from '@/api/client';
import type { ModelInfo } from '@/api/types';
import {
@@ -8,26 +8,94 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { BottomSheet } from '@/components/BottomSheet';
import { useViewport } from '@/hooks/useViewport';
interface Props {
value: string;
onChange: (model: string) => void | Promise<void>;
}
// v1.9: shared list rendered inside both shells. Lazy-fetches /api/models on
// first open so the picker doesn't pay for a request when it's never shown.
function ModelList({
models,
error,
value,
onPick,
}: {
models: ModelInfo[] | null;
error: string | null;
value: string;
onPick: (id: string) => void;
}) {
if (error) {
return <div className="px-2 py-1.5 text-xs text-destructive">{error}</div>;
}
if (models === null) {
return <div className="px-2 py-1.5 text-xs text-muted-foreground">Loading</div>;
}
return (
<>
{models.map((m) => (
<button
key={m.id}
type="button"
onClick={() => onPick(m.id)}
className="w-full text-left flex items-center gap-2 font-mono text-xs px-2 py-1.5 hover:bg-accent rounded"
>
<Check className={`size-3 ${m.id === value ? 'opacity-100' : 'opacity-0'}`} />
<span className="truncate">{m.id}</span>
</button>
))}
</>
);
}
export function ModelPicker({ value, onChange }: Props) {
const { isMobile } = useViewport();
const [models, setModels] = useState<ModelInfo[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open || models !== null) return;
api.models()
api
.models()
.then(setModels)
.catch((err) =>
setError(err instanceof Error ? err.message : 'failed to load models')
setError(err instanceof Error ? err.message : 'failed to load models'),
);
}, [open, models]);
function handlePick(id: string) {
setOpen(false);
void onChange(id);
}
// v1.9: mobile = icon-only trigger + bottom-sheet shell. Desktop = labeled
// trigger (model name + chevron) + dropdown. Same ModelList under the hood.
if (isMobile) {
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
aria-label={`Model: ${value}`}
title={value}
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded text-muted-foreground hover:text-foreground"
>
<Cpu className="size-4" />
</button>
<BottomSheet open={open} onClose={() => setOpen(false)} title="Model">
<div className="px-2 py-2 space-y-1">
<ModelList models={models} error={error} value={value} onPick={handlePick} />
</div>
</BottomSheet>
</>
);
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
@@ -49,7 +117,7 @@ export function ModelPicker({ value, onChange }: Props) {
{models?.map((m) => (
<DropdownMenuItem
key={m.id}
onSelect={() => void onChange(m.id)}
onSelect={() => handlePick(m.id)}
className="font-mono text-xs"
>
<Check

View File

@@ -1,8 +1,9 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus } from 'lucide-react';
import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus, Settings as SettingsIcon } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { sessionEvents } from '@/hooks/sessionEvents';
import {
ContextMenu,
ContextMenuContent,
@@ -198,7 +199,7 @@ export function ProjectSidebar() {
const rowCls = (active: boolean) =>
active ? 'bg-sidebar-accent text-sidebar-accent-foreground' : 'hover:bg-sidebar-accent/60';
const { open: drawerOpen } = useSidebarDrawer();
const { open: drawerOpen, setOpen: setDrawerOpen } = useSidebarDrawer();
const { isMobile } = useViewport();
const pull = usePullToRefresh(() => retry(), { enabled: isMobile });
@@ -412,6 +413,30 @@ export function ProjectSidebar() {
})}
</nav>
{/* v1.9: bottom-pinned Settings button. In a session, opens/focuses the
workspace settings pane via the sessionEvents bus (Session.tsx owns
the panesHook). Outside a session there's no workspace to mount the
pane in, so we navigate to /settings (themes page) instead. */}
<div className="border-t shrink-0 p-2">
<button
type="button"
onClick={() => {
if (activeSession) {
sessionEvents.emit({ type: 'open_settings_pane' });
if (isMobile) setDrawerOpen(false);
} else {
navigate('/settings');
if (isMobile) setDrawerOpen(false);
}
}}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-sidebar-accent/60 text-sidebar-foreground"
aria-label="Settings"
>
<SettingsIcon className="size-3.5 shrink-0 opacity-70" />
<span className="flex-1 text-left">Settings</span>
</button>
</div>
<AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={() => {}} />
<Dialog open={archiveProjectConfirm !== null} onOpenChange={(open) => { if (!open) setArchiveProjectConfirm(null); }}>

View File

@@ -0,0 +1,122 @@
import { useState } from 'react';
import { Check } from 'lucide-react';
import { toast } from 'sonner';
import { Card } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { THEMES, setTheme, useTheme, type ThemeId, type ThemeMode } from '@/lib/theme';
import { cn } from '@/lib/utils';
// v1.9: lifted out of pages/Settings.tsx so the SettingsPane Theme tab and
// the standalone /settings route render the same picker. Theme is global —
// not per-project, not per-session — so no contextual props are needed.
const MODES: { value: ThemeMode; label: string; hint: string }[] = [
{ value: 'dark', label: 'Dark', hint: 'Use the dark variant.' },
{ value: 'light', label: 'Light', hint: 'Use the light variant.' },
{ value: 'system', label: 'System', hint: 'Follow OS preference.' },
];
export function ThemePicker() {
const { id: currentId, mode: currentMode } = useTheme();
// Track the most recent in-flight pick so the picker can show a subtle
// "applying…" state on the targeted card while the PATCH is in flight.
const [pending, setPending] = useState<
{ kind: 'theme'; id: ThemeId } | { kind: 'mode'; mode: ThemeMode } | null
>(null);
async function pickTheme(id: ThemeId) {
if (id === currentId || pending) return;
setPending({ kind: 'theme', id });
try {
await setTheme(id, currentMode);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to apply theme');
} finally {
setPending(null);
}
}
async function pickMode(mode: ThemeMode) {
if (mode === currentMode || pending) return;
setPending({ kind: 'mode', mode });
try {
await setTheme(currentId, mode);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to apply mode');
} finally {
setPending(null);
}
}
return (
<div className="space-y-8">
<section className="space-y-3">
<h2 className="text-sm font-medium">Mode</h2>
<RadioGroup
value={currentMode}
onValueChange={(v) => void pickMode(v as ThemeMode)}
className="flex flex-wrap gap-4"
>
{MODES.map((m) => (
<div key={m.value} className="flex items-center gap-2">
<RadioGroupItem id={`mode-${m.value}`} value={m.value} />
<Label htmlFor={`mode-${m.value}`} className="cursor-pointer">
<span className="font-medium">{m.label}</span>
<span className="ml-2 text-xs text-muted-foreground">{m.hint}</span>
</Label>
</div>
))}
</RadioGroup>
</section>
<section className="space-y-3">
<h2 className="text-sm font-medium">Theme</h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{THEMES.map((t) => {
const isActive = t.id === currentId;
const isPending = pending?.kind === 'theme' && pending.id === t.id;
const isLightOnly = !t.supportsDark;
return (
<Card
key={t.id}
onClick={() => void pickTheme(t.id)}
className={cn(
'p-3 cursor-pointer transition-colors',
'hover:bg-accent/10',
isActive && 'ring-2 ring-ring',
isPending && 'opacity-60',
)}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="font-mono text-sm truncate">{t.name}</div>
<div className="text-xs text-muted-foreground">{t.family}</div>
</div>
{isActive && (
<span className="inline-flex items-center gap-1 text-xs text-primary shrink-0">
<Check className="size-3" /> Selected
</span>
)}
</div>
<div className="flex mt-2 rounded overflow-hidden border border-border/40">
{t.anchors.map((hex, i) => (
<div
key={i}
className="flex-1 h-6"
style={{ backgroundColor: hex }}
aria-hidden="true"
/>
))}
</div>
{isLightOnly && (
<div className="mt-2 text-xs text-muted-foreground italic">Light only</div>
)}
</Card>
);
})}
</div>
</section>
</div>
);
}

View File

@@ -1,102 +0,0 @@
import { useState } from 'react';
import type { ReactNode } from 'react';
import { ChevronRight, Wrench } from 'lucide-react';
import type { Message, ToolCall } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
interface Props {
message?: Message;
toolCall?: ToolCall;
}
// Same regex/heuristic as MessageBubble: paths ending in `.ext` with at
// least one `/`. Linkifies file paths emitted by tools like grep / find_files
// so they're clickable.
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
function linkifyOutput(text: string): ReactNode[] {
const out: ReactNode[] = [];
let lastIdx = 0;
let idx = 0;
for (const match of text.matchAll(PATH_REGEX)) {
const matchedText = match[0];
const start = match.index ?? 0;
if (!matchedText.includes('/')) continue;
if (start > lastIdx) out.push(text.slice(lastIdx, start));
out.push(
<button
key={idx}
type="button"
onClick={() =>
sessionEvents.emit({
type: 'open_file_in_browser',
path: matchedText,
})
}
className="text-primary underline cursor-pointer hover:text-primary/80"
>
{matchedText}
</button>
);
lastIdx = start + matchedText.length;
idx += 1;
}
if (lastIdx < text.length) out.push(text.slice(lastIdx));
return out.length > 0 ? out : [text];
}
export function ToolCallCard({ message, toolCall }: Props) {
const [open, setOpen] = useState(false);
const tc = toolCall ?? message?.tool_calls?.[0];
const result = message?.tool_results;
const name = tc?.name ?? 'tool';
const args = tc?.args ?? {};
const error = result?.error;
const output = result?.output;
const truncated = result?.truncated;
return (
<div className="rounded-md border border-border bg-muted/30 text-sm overflow-hidden">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center gap-2 px-2.5 py-1.5 hover:bg-muted/60 text-left"
>
<ChevronRight
className={`size-3.5 transition-transform ${open ? 'rotate-90' : ''}`}
/>
<Wrench className="size-3.5 opacity-70" />
<span className="font-mono font-medium">{name}</span>
<span className="font-mono text-xs text-muted-foreground truncate min-w-0 flex-1">
{JSON.stringify(args)}
</span>
{error && (
<span className="text-xs text-destructive font-medium ml-2">error</span>
)}
{truncated && (
<span className="text-xs text-muted-foreground ml-2">truncated</span>
)}
</button>
{open && (
<div className="px-2.5 py-2 border-t bg-background/40">
{error ? (
<pre className="text-xs text-destructive font-mono whitespace-pre-wrap">
{error}
</pre>
) : output !== undefined ? (
<pre className="text-xs font-mono whitespace-pre-wrap overflow-x-auto max-h-72 overflow-y-auto">
{linkifyOutput(
typeof output === 'string'
? output
: JSON.stringify(output, null, 2)
)}
</pre>
) : (
<div className="text-xs text-muted-foreground">no result yet</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { useState } from 'react';
import { ChevronRight } from 'lucide-react';
import { ToolCallLine, runStatus, type ToolRun } from './ToolCallLine';
interface Props {
// All runs must share the same tool name. Caller (MessageList grouping
// pass) enforces that invariant.
runs: ToolRun[];
}
export function ToolCallGroup({ runs }: Props) {
const [open, setOpen] = useState(false);
if (runs.length === 0) return null;
const toolName = runs[0]!.call.name;
const count = runs.length;
// Group-level status: pending if any are still running, error if any
// finished with an error, otherwise success. Matches the visual the user
// gets when scanning a long run of greps / view_files.
let pending = 0;
let errored = 0;
for (const r of runs) {
const s = runStatus(r);
if (s === 'pending') pending += 1;
else if (s === 'error') errored += 1;
}
const summaryParts: string[] = [];
if (pending > 0) summaryParts.push(`${pending} running`);
if (errored > 0) summaryParts.push(`${errored} failed`);
const summary = summaryParts.length > 0 ? ` (${summaryParts.join(', ')})` : '';
return (
<div className="rounded border border-border/60 bg-muted/20 text-xs">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center gap-1.5 px-2 py-1 hover:bg-muted/40 text-left"
>
<ChevronRight
className={`size-3 text-muted-foreground/60 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`}
/>
<span className="text-muted-foreground/60 select-none shrink-0"></span>
<span className="font-mono text-foreground/90">
{count} {toolName} call{count === 1 ? '' : 's'}
</span>
{summary && (
<span className="text-muted-foreground truncate">{summary}</span>
)}
<span className="ml-auto text-muted-foreground/60 shrink-0">tap</span>
</button>
{open && (
<div className="border-t border-border/40 px-2 py-1 space-y-0.5">
{runs.map((run, i) => (
<ToolCallLine
key={`${run.call.id}-${i}`}
run={run}
insideGroup
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,167 @@
import { useState } from 'react';
import type { ReactNode } from 'react';
import { Check, ChevronRight, Loader2, X } from 'lucide-react';
import type { ToolCall, ToolResult } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
// v1.8.2: cap on the inline arg-summary length. Expanded view shows full
// args + full result, so this is purely a single-line render budget.
const ARG_SUMMARY_MAX = 60;
export interface ToolRun {
call: ToolCall;
// null while the call is in flight or the matching tool result hasn't
// arrived yet on the WS stream.
result: ToolResult | null;
}
function truncate(s: string, n: number): string {
return s.length > n ? s.slice(0, n - 1) + '…' : s;
}
// Per-tool argument summary mapping from the v1.8.2 spec. Goal is a single
// scannable line that surfaces the *what* (path / pattern) without
// overwhelming the chat with full JSON.
export function formatToolArgs(name: string, args: Record<string, unknown>): string {
if (name === 'view_file') {
const path = String(args.path ?? '');
const start = args.start_line;
const end = args.end_line;
if (typeof start === 'number' && typeof end === 'number') {
return truncate(`${path}:${start}-${end}`, ARG_SUMMARY_MAX);
}
if (typeof start === 'number') {
return truncate(`${path}:${start}`, ARG_SUMMARY_MAX);
}
return truncate(path, ARG_SUMMARY_MAX);
}
if (name === 'list_dir') {
return truncate(String(args.path ?? '.'), ARG_SUMMARY_MAX);
}
if (name === 'grep') {
const pattern = String(args.pattern ?? '');
const path = args.path ? ` ${String(args.path)}` : '';
return truncate(`"${pattern}"${path}`, ARG_SUMMARY_MAX);
}
if (name === 'find_files') {
return truncate(String(args.pattern ?? ''), ARG_SUMMARY_MAX);
}
if (name === 'git_status') {
return '';
}
// Unknown tool — surface first arg value or the literal {} so the user can
// see something happened. Forward-compatible with future tools.
const keys = Object.keys(args);
if (keys.length === 0) return '{}';
const first = keys[0]!;
return truncate(`${first}: ${String(args[first])}`, ARG_SUMMARY_MAX);
}
export function runStatus(run: ToolRun): 'pending' | 'success' | 'error' {
if (run.result === null) return 'pending';
if (run.result.error) return 'error';
return 'success';
}
// Path-shaped paths in tool output text get a click handler so users can
// jump to the file. Same heuristic as MessageBubble.linkifyPaths.
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
function linkifyOutput(text: string): ReactNode[] {
const out: ReactNode[] = [];
let lastIdx = 0;
let idx = 0;
for (const match of text.matchAll(PATH_REGEX)) {
const matchedText = match[0];
const start = match.index ?? 0;
if (!matchedText.includes('/')) continue;
if (start > lastIdx) out.push(text.slice(lastIdx, start));
out.push(
<button
key={idx}
type="button"
onClick={() =>
sessionEvents.emit({ type: 'open_file_in_browser', path: matchedText })
}
className="text-primary underline cursor-pointer hover:text-primary/80"
>
{matchedText}
</button>
);
lastIdx = start + matchedText.length;
idx += 1;
}
if (lastIdx < text.length) out.push(text.slice(lastIdx));
return out.length > 0 ? out : [text];
}
interface Props {
run: ToolRun;
// When rendered inside a ToolCallGroup the line is already nested under a
// shared header, so the leading arrow is dropped to avoid double indent.
insideGroup?: boolean;
}
export function ToolCallLine({ run, insideGroup }: Props) {
const [open, setOpen] = useState(false);
const status = runStatus(run);
const args = run.call.args ?? {};
const summary = formatToolArgs(run.call.name, args);
return (
<div className="text-xs">
<button
type="button"
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"
>
{!insideGroup && (
<span className="text-muted-foreground/60 select-none shrink-0"></span>
)}
<ChevronRight
className={`size-3 text-muted-foreground/60 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`}
/>
<span className="font-mono text-foreground/90 shrink-0">{run.call.name}</span>
{summary && (
<span className="font-mono text-muted-foreground truncate min-w-0 flex-1">
{summary}
</span>
)}
{!summary && <span className="flex-1" />}
<span className="shrink-0 ml-1">
{status === 'pending' && (
<Loader2 className="size-3 text-muted-foreground animate-spin" aria-label="running" />
)}
{status === 'success' && (
<Check className="size-3 text-emerald-500" aria-label="success" />
)}
{status === 'error' && (
<X className="size-3 text-destructive" aria-label="error" />
)}
</span>
</button>
{open && (
<div className="ml-5 mt-1 mb-1 space-y-1">
<pre className="text-[10px] text-muted-foreground font-mono whitespace-pre-wrap break-all bg-muted/30 rounded px-2 py-1">
{JSON.stringify(args, null, 2)}
</pre>
{run.result && (
<pre className="text-[11px] font-mono whitespace-pre-wrap bg-muted/30 rounded px-2 py-1 max-h-72 overflow-y-auto">
{run.result.error ? (
<span className="text-destructive">{run.result.error}</span>
) : (
linkifyOutput(
typeof run.result.output === 'string'
? run.result.output
: JSON.stringify(run.result.output, null, 2)
)
)}
{run.result.truncated && (
<div className="text-muted-foreground/60 mt-1"> output truncated </div>
)}
</pre>
)}
</div>
)}
</div>
);
}

View File

@@ -1,9 +1,11 @@
import { useEffect, useState } from 'react';
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
import type { Chat, WorkspacePane } from '@/api/types';
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
import { useViewport } from '@/hooks/useViewport';
import { ChatPane } from '@/components/panes/ChatPane';
import { SettingsPane } from '@/components/panes/SettingsPane';
import { ChatTabBar } from '@/components/ChatTabBar';
import { SessionLandingPage } from '@/components/SessionLandingPage';
import {
@@ -24,6 +26,9 @@ interface Props {
// (MobileTabSwitcher) can share state with the pane grid.
panesHook: UseWorkspacePanesResult;
chatsHook: UseSessionChatsResult;
// v1.9: passed through to SettingsPane when one is mounted in the grid.
session: Session;
project: Project | null;
}
export function Workspace({
@@ -33,6 +38,8 @@ export function Workspace({
onAgentChange,
panesHook,
chatsHook,
session,
project,
}: Props) {
const {
panes,
@@ -67,6 +74,28 @@ export function Workspace({
const { isMobile } = useViewport();
// v1.9: workspace-level maximize state for the settings pane. CSS-only:
// sibling panes get display:none, the maximized pane fills the grid cell.
// ESC listener only mounted while maximized. Mobile is always full-width
// for a single pane so maximize doesn't apply.
const [maximized, setMaximized] = useState(false);
const settingsIdx = panes.findIndex((p) => p.kind === 'settings');
useEffect(() => {
if (!maximized) return;
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setMaximized(false);
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [maximized]);
// If the settings pane was closed (no longer in panes) while maximized,
// clear the maximize state so the grid renders normally.
useEffect(() => {
if (maximized && settingsIdx < 0) setMaximized(false);
}, [maximized, settingsIdx]);
function chatsForPane(pane: WorkspacePane): Chat[] {
return pane.chatIds
.map((id) => chats.find((c) => c.id === id))
@@ -81,10 +110,12 @@ export function Workspace({
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={panes.length >= MAX_PANES}
// v1.9: settings panes excluded from the MAX cap (decision c).
disabled={panes.filter((p) => p.kind !== 'settings').length >= MAX_PANES}
className={cn(
'flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted',
panes.length >= MAX_PANES && 'opacity-40 cursor-not-allowed hover:bg-transparent'
panes.filter((p) => p.kind !== 'settings').length >= MAX_PANES &&
'opacity-40 cursor-not-allowed hover:bg-transparent'
)}
>
<PanelRight size={14} />
@@ -114,12 +145,24 @@ export function Workspace({
style={
isMobile
? undefined
: { gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))` }
: maximized && settingsIdx >= 0
? { gridTemplateColumns: 'minmax(0, 1fr)' }
: { gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))` }
}
>
{panes.map((pane, idx) => {
const visible = !isMobile || idx === activePaneIdx;
if (!visible) return null;
const isSettings = pane.kind === 'settings';
// v1.9: when maximized, hide every pane except the settings one.
// display:none keeps the React tree mounted so streams / drafts
// survive the toggle without re-mount cost.
const hiddenForMaximize = !isMobile && maximized && idx !== settingsIdx;
const visible = (!isMobile || idx === activePaneIdx) && !hiddenForMaximize;
if (!visible) {
if (hiddenForMaximize) {
return <div key={pane.id} className="hidden" />;
}
return null;
}
return (
<div
key={pane.id}
@@ -131,19 +174,19 @@ export function Workspace({
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
)}
onClick={() => setActivePaneIdx(idx)}
onDragOver={!isMobile && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
onDragLeave={!isMobile && panes.length > 1 ? handlePaneDragLeave : undefined}
onDrop={!isMobile && panes.length > 1 ? handlePaneDrop(idx) : undefined}
onDragOver={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
onDragLeave={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragLeave : undefined}
onDrop={!isMobile && !isSettings && panes.length > 1 ? handlePaneDrop(idx) : undefined}
>
<div
draggable={!isMobile && panes.length > 1}
onDragStart={!isMobile && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={!isMobile && panes.length > 1 ? handlePaneDragEnd : undefined}
draggable={!isMobile && !isSettings && panes.length > 1}
onDragStart={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragEnd : undefined}
>
{/* Hidden on mobile per v1.8: chat-within-pane navigation
is not exposed on small screens; users switch panes via
the header pill instead. */}
{!isMobile && (
{/* Hidden on mobile per v1.8; settings panes own their own
section nav / maximize toggle so they skip ChatTabBar
entirely. */}
{!isMobile && !isSettings && (
<ChatTabBar
pane={pane}
tabs={chatsForPane(pane)}
@@ -161,7 +204,15 @@ export function Workspace({
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{pane.kind === 'chat' && pane.chatId ? (
{isSettings && project ? (
<SettingsPane
session={session}
project={project}
maximized={maximized}
onToggleMaximize={() => setMaximized((v) => !v)}
isMobile={isMobile}
/>
) : pane.kind === 'chat' && pane.chatId ? (
<ChatPane
sessionId={sessionId}
chatId={pane.chatId}
@@ -169,6 +220,7 @@ export function Workspace({
agentId={agentId}
onAgentChange={onAgentChange}
sessionChats={chats}
webSearchEnabled={session.web_search_enabled}
/>
) : (
<SessionLandingPage

View File

@@ -22,9 +22,13 @@ interface Props {
agentId?: string | null;
onAgentChange?: (agentId: string | null) => void | Promise<void>;
sessionChats?: import('@/api/types').Chat[];
// v1.9: threaded down to ChatInput's + menu (Web search quick toggle).
// null means "inherit project default" — ChatInput PATCHes with the
// opposite of the effective value.
webSearchEnabled?: boolean | null;
}
export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, sessionChats }: Props) {
export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, sessionChats, webSearchEnabled }: Props) {
const stream = useSessionStream(sessionId);
const lastErrorRef = useRef<string | null>(null);
const [queue, setQueue] = useState<string[]>([]);
@@ -173,8 +177,10 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
<ChatInput
disabled={false}
projectId={projectId}
sessionId={sessionId}
agentId={agentId}
onAgentChange={onAgentChange}
webSearchEnabled={webSearchEnabled}
onSend={handleSend}
onForceSend={streaming ? handleForceSend : undefined}
/>

View File

@@ -0,0 +1,520 @@
import { useEffect, useState } from 'react';
import { Archive, Maximize2, Minimize2 } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { Project, Session } from '@/api/types';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ModelPicker } from '@/components/ModelPicker';
import { ThemePicker } from '@/components/ThemePicker';
import { cn } from '@/lib/utils';
type Section = 'session' | 'project' | 'theme';
interface Props {
session: Session;
project: Project;
maximized: boolean;
onToggleMaximize: () => void;
isMobile: boolean;
}
// v1.9: hand-rolled Switch primitive. No shadcn switch in the existing
// ui/ set and the dispatch said don't pnpm dlx for v1.9 either. Single
// purpose — clicking flips aria-checked + calls onCheckedChange.
function Switch({
checked,
onCheckedChange,
disabled,
id,
}: {
checked: boolean;
onCheckedChange: (v: boolean) => void;
disabled?: boolean;
id?: string;
}) {
return (
<button
id={id}
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onCheckedChange(!checked)}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors',
checked ? 'bg-primary' : 'bg-muted',
disabled && 'opacity-50 cursor-not-allowed',
)}
>
<span
className={cn(
'inline-block h-4 w-4 transform rounded-full bg-background transition-transform',
checked ? 'translate-x-[1.125rem]' : 'translate-x-0.5',
)}
/>
</button>
);
}
export function SettingsPane({ session, project, maximized, onToggleMaximize, isMobile }: Props) {
const [activeSection, setActiveSection] = useState<Section>('session');
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
<div className="flex items-center gap-1 flex-1 min-w-0">
{(['session', 'project', 'theme'] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setActiveSection(s)}
className={cn(
'text-xs px-2 py-1 rounded capitalize',
activeSection === s
? 'bg-background text-foreground'
: 'text-muted-foreground hover:bg-muted',
)}
>
{s}
</button>
))}
</div>
{!isMobile && (
<button
type="button"
onClick={onToggleMaximize}
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label={maximized ? 'Restore' : 'Maximize'}
title={maximized ? 'Restore (Esc)' : 'Maximize'}
>
{maximized ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</button>
)}
</div>
<div className="flex-1 overflow-y-auto">
<div className="max-w-[720px] mx-auto w-full px-4 py-4 space-y-6">
{activeSection === 'session' && <SessionSection session={session} project={project} />}
{activeSection === 'project' && <ProjectSection project={project} />}
{activeSection === 'theme' && <ThemePicker />}
</div>
</div>
</div>
);
}
function SessionSection({ session, project }: { session: Session; project: Project }) {
const [name, setName] = useState(session.name);
const [systemPrompt, setSystemPrompt] = useState(session.system_prompt);
// v1.9: tri-state on the wire (null = inherit). UI surfaces a 3-way toggle
// via "Inherit project default" checkbox plus the override switch.
const [webSearch, setWebSearch] = useState<boolean | null>(session.web_search_enabled);
const [saving, setSaving] = useState(false);
// v1.9: bulk-archive chats. Two-step: openChatsCount → confirm dialog →
// archiveAllChats. Server publishes one chat_archived frame per id so
// useSidebar / chat lists update incrementally.
const [archiveOpen, setArchiveOpen] = useState(false);
const [archiveCount, setArchiveCount] = useState(0);
const [archiving, setArchiving] = useState(false);
useEffect(() => {
setName(session.name);
setSystemPrompt(session.system_prompt);
setWebSearch(session.web_search_enabled);
}, [session.id, session.name, session.system_prompt, session.web_search_enabled]);
const dirty =
name !== session.name ||
systemPrompt !== session.system_prompt ||
webSearch !== session.web_search_enabled;
const effectiveWebSearch = webSearch ?? project.default_web_search_enabled;
const projectPreview = project.default_system_prompt.trim().slice(0, 200);
async function save() {
if (saving) return;
setSaving(true);
try {
await api.sessions.update(session.id, {
name: name.trim() || session.name,
system_prompt: systemPrompt,
web_search_enabled: webSearch,
});
toast.success('Session saved');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'save failed');
} finally {
setSaving(false);
}
}
async function resetSystemPrompt() {
if (saving) return;
setSaving(true);
try {
await api.sessions.update(session.id, { system_prompt: '' });
toast.success('Reset to project default');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'reset failed');
} finally {
setSaving(false);
}
}
async function openArchiveDialog() {
if (archiving) return;
try {
const { count } = await api.sessions.openChatsCount(session.id);
if (count === 0) {
toast('No open chats to archive.');
return;
}
setArchiveCount(count);
setArchiveOpen(true);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to count chats');
}
}
async function confirmArchive() {
if (archiving) return;
setArchiving(true);
try {
const { archived } = await api.sessions.archiveAllChats(session.id);
toast.success(`Archived ${archived} chat${archived === 1 ? '' : 's'}`);
setArchiveOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'archive failed');
} finally {
setArchiving(false);
}
}
return (
<div className="space-y-6">
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Session name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Model
</label>
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1">
<ModelPicker
value={session.model}
onChange={async (model) => {
try {
await api.sessions.update(session.id, { model });
toast.success('Model updated');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to set model');
}
}}
/>
</div>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<label htmlFor="session-web-search" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Web search
</label>
<Switch
id="session-web-search"
checked={effectiveWebSearch}
onCheckedChange={(v) => setWebSearch(v)}
/>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
id="session-web-search-inherit"
checked={webSearch === null}
onChange={(e) => setWebSearch(e.target.checked ? null : project.default_web_search_enabled)}
/>
<label htmlFor="session-web-search-inherit" className="cursor-pointer">
Inherit project default ({project.default_web_search_enabled ? 'on' : 'off'})
</label>
</div>
<p className="text-xs text-muted-foreground italic">
Plumbed for Batch 8 (web_search tool). No effect yet.
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
System prompt
</label>
<button
type="button"
onClick={() => void resetSystemPrompt()}
disabled={saving || session.system_prompt === ''}
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
>
Reset to project default
</button>
</div>
<Textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
rows={6}
className="resize-y min-h-[120px] max-h-[60vh]"
placeholder="Per-session override (optional). Empty = inherit project default."
/>
{systemPrompt.trim().length === 0 && projectPreview.length > 0 && (
<p className="text-xs text-muted-foreground">
Falls back to project default: <span className="italic">{projectPreview}{projectPreview.length === 200 ? '…' : ''}</span>
</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button onClick={() => void save()} disabled={!dirty || saving}>
{saving ? 'Saving…' : 'Save'}
</Button>
</div>
<div className="border-t pt-4">
<Button
variant="outline"
onClick={() => void openArchiveDialog()}
disabled={archiving}
className="gap-1.5"
>
<Archive size={14} /> Archive all chats
</Button>
</div>
<Dialog open={archiveOpen} onOpenChange={(open) => { if (!archiving) setArchiveOpen(open); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Archive all chats?</DialogTitle>
<DialogDescription>
Archive {archiveCount} open chat{archiveCount === 1 ? '' : 's'} in this session?
Archived chats stay accessible via the archive view.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setArchiveOpen(false)} disabled={archiving}>
Cancel
</Button>
<Button onClick={() => void confirmArchive()} disabled={archiving}>
{archiving ? 'Archiving…' : `Archive ${archiveCount}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function ProjectSection({ project }: { project: Project }) {
const [name, setName] = useState(project.name);
const [defaultPrompt, setDefaultPrompt] = useState(project.default_system_prompt);
const [defaultWebSearch, setDefaultWebSearch] = useState(project.default_web_search_enabled);
const [saving, setSaving] = useState(false);
// v1.9: bulk-archive sessions. Same shape as the chats-archive flow in
// SessionSection — count, confirm, fire.
const [archiveOpen, setArchiveOpen] = useState(false);
const [archiveCount, setArchiveCount] = useState(0);
const [archiving, setArchiving] = useState(false);
useEffect(() => {
setName(project.name);
setDefaultPrompt(project.default_system_prompt);
setDefaultWebSearch(project.default_web_search_enabled);
}, [
project.id,
project.name,
project.default_system_prompt,
project.default_web_search_enabled,
]);
const dirty =
name !== project.name ||
defaultPrompt !== project.default_system_prompt ||
defaultWebSearch !== project.default_web_search_enabled;
async function save() {
if (saving) return;
setSaving(true);
try {
await api.projects.update(project.id, {
name: name.trim() || project.name,
default_system_prompt: defaultPrompt,
default_web_search_enabled: defaultWebSearch,
});
toast.success('Project saved');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'save failed');
} finally {
setSaving(false);
}
}
async function clearDefaultPrompt() {
if (saving) return;
setSaving(true);
try {
await api.projects.update(project.id, { default_system_prompt: '' });
toast.success('Cleared');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'clear failed');
} finally {
setSaving(false);
}
}
async function openArchiveDialog() {
if (archiving) return;
try {
const { count } = await api.projects.openSessionsCount(project.id);
if (count === 0) {
toast('No open sessions to archive.');
return;
}
setArchiveCount(count);
setArchiveOpen(true);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to count sessions');
}
}
async function confirmArchive() {
if (archiving) return;
setArchiving(true);
try {
const { archived } = await api.projects.archiveAllSessions(project.id);
toast.success(`Archived ${archived} session${archived === 1 ? '' : 's'}`);
setArchiveOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'archive failed');
} finally {
setArchiving(false);
}
}
return (
<div className="space-y-6">
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Project name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Root path
</label>
<div className="font-mono text-xs text-muted-foreground bg-muted/40 rounded px-2 py-1.5 select-all">
{project.path}
</div>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<label htmlFor="project-default-web-search" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Default web search
</label>
<Switch
id="project-default-web-search"
checked={defaultWebSearch}
onCheckedChange={setDefaultWebSearch}
/>
</div>
<p className="text-xs text-muted-foreground italic">
Applies to new sessions only. Plumbed for Batch 8.
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Default system prompt
</label>
<button
type="button"
onClick={() => void clearDefaultPrompt()}
disabled={saving || project.default_system_prompt === ''}
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
>
Clear
</button>
</div>
<Textarea
value={defaultPrompt}
onChange={(e) => setDefaultPrompt(e.target.value)}
rows={6}
className="resize-y min-h-[120px] max-h-[60vh]"
placeholder="Prepended to every new session's system prompt (when its own is empty). Empty = no project default."
/>
</div>
<p className="text-xs text-muted-foreground">
Existing sessions are not affected by changes here.
</p>
<div className="flex justify-end gap-2">
<Button onClick={() => void save()} disabled={!dirty || saving}>
{saving ? 'Saving…' : 'Save'}
</Button>
</div>
<div className="border-t pt-4">
<Button
variant="outline"
onClick={() => void openArchiveDialog()}
disabled={archiving}
className="gap-1.5"
>
<Archive size={14} /> Archive all sessions
</Button>
</div>
<Dialog open={archiveOpen} onOpenChange={(open) => { if (!archiving) setArchiveOpen(open); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Archive all sessions?</DialogTitle>
<DialogDescription>
Archive {archiveCount} open session{archiveCount === 1 ? '' : 's'} in this project?
Archived sessions stay accessible via the archive view.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setArchiveOpen(false)} disabled={archiving}>
Cancel
</Button>
<Button onClick={() => void confirmArchive()} disabled={archiving}>
{archiving ? 'Archiving…' : `Archive ${archiveCount}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,42 @@
import * as React from "react"
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid w-full gap-2", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="flex size-4 items-center justify-center"
>
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@@ -2,7 +2,7 @@
// across hooks (e.g. AI rename arriving via WS in the session view needs to
// also refresh the sidebar's session list).
import type { Chat, Project, Session } from '@/api/types';
import type { Chat, ErrorReason, Project, Session } from '@/api/types';
import type { Attachment } from '@/lib/attachments';
export interface SessionRenamedEvent {
@@ -62,6 +62,14 @@ export interface OpenChatInActivePaneEvent {
chat_id: string;
}
// v1.9: client-side event fired by the sidebar Settings button when a
// session is currently mounted. Session.tsx subscribes and calls
// panesHook.openOrFocusSettingsPane(). Sidebar handles the no-session case
// by navigating to /settings (themes page) directly.
export interface OpenSettingsPaneEvent {
type: 'open_settings_pane';
}
export interface SessionArchivedEvent {
type: 'session_archived';
session_id: string;
@@ -118,11 +126,14 @@ export interface ProjectUpdatedEvent {
// v1.8 mobile-tabs: broadcast on user channel from inference.ts so any device
// subscribed sees a chat working/idle/error. Frontend stores per-chat; panes
// derive their dot from pane.activeChatId.
// v1.8.2: optional `reason` carries a machine-readable code when status is
// 'error'. UI prefers reason for inline error rendering.
export interface ChatStatusEvent {
type: 'chat_status';
chat_id: string;
status: 'working' | 'idle' | 'error';
at: string;
reason?: ErrorReason;
}
export type SessionEvent =
@@ -136,6 +147,7 @@ export type SessionEvent =
| OpenFileInBrowserEvent
| AttachChatFileEvent
| OpenChatInActivePaneEvent
| OpenSettingsPaneEvent
| SessionArchivedEvent
| ChatCreatedEvent
| ChatUpdatedEvent

View File

@@ -29,7 +29,9 @@ function applyFrame(state: State, frame: WsFrame): State {
kind: 'message',
tool_calls: null,
tool_results: null,
status: 'streaming',
// v1.8.2: cap-hit sentinels arrive role='system' and are static, so
// skipping the streaming dot for them keeps the UI accurate.
status: frame.role === 'system' ? 'complete' : 'streaming',
last_seq: 0,
tokens_used: null,
ctx_used: null,
@@ -37,6 +39,7 @@ function applyFrame(state: State, frame: WsFrame): State {
started_at: null,
finished_at: null,
created_at: new Date().toISOString(),
metadata: null,
};
return { ...state, messages: [...state.messages, newMsg] };
}
@@ -96,6 +99,7 @@ function applyFrame(state: State, frame: WsFrame): State {
started_at: null,
finished_at: null,
created_at: new Date().toISOString(),
metadata: null,
};
return { ...state, messages: [...state.messages, newMsg] };
}
@@ -110,6 +114,10 @@ function applyFrame(state: State, frame: WsFrame): State {
...(frame.ctx_max !== undefined ? { ctx_max: frame.ctx_max } : {}),
...(frame.started_at !== undefined ? { started_at: frame.started_at } : {}),
...(frame.finished_at !== undefined ? { finished_at: frame.finished_at } : {}),
// v1.8.2: cap-hit sentinels (and future stamped metadata) ride
// in on this terminal frame so the reducer can attach it
// without waiting for a refetch.
...(frame.metadata !== undefined ? { metadata: frame.metadata } : {}),
}
: m
);
@@ -133,9 +141,22 @@ function applyFrame(state: State, frame: WsFrame): State {
return state;
}
case 'error': {
// v1.8.2: when the frame carries a structured reason, stamp it onto the
// failed message's metadata so the bubble can render specifics inline
// (the WS error frame is one-shot; refresh-safe rendering needs the
// value persisted on the message).
const errorMeta = frame.reason
? { kind: 'error' as const, error_reason: frame.reason, error_text: frame.error }
: null;
const next = frame.message_id
? state.messages.map((m) =>
m.id === frame.message_id ? { ...m, status: 'failed' as const } : m
m.id === frame.message_id
? {
...m,
status: 'failed' as const,
...(errorMeta ? { metadata: errorMeta } : {}),
}
: m
)
: state.messages;
return { ...state, messages: next, error: frame.error };

View File

@@ -151,6 +151,10 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
case 'open_chat_in_active_pane':
// Consumed by Workspace; sidebar has no business with pane state.
return prev;
case 'open_settings_pane':
// v1.9: consumed by Session.tsx (calls openOrFocusSettingsPane on its
// panesHook). Sidebar data is untouched.
return prev;
case 'session_archived': {
let changed = false;
const projects = prev.projects.map((p) => {

View File

@@ -19,6 +19,26 @@ function chatPane(chatId: string): WorkspacePane {
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
}
// v1.9: settings pane factory. No chats, no state beyond identity — the
// SettingsPane component renders Session/Project sections from the
// surrounding session/project.
function settingsPane(): WorkspacePane {
return { id: generateId(), kind: 'settings', chatIds: [], activeChatIdx: -1 };
}
// v1.9: settings panes are ephemeral. Filter them out before persisting so a
// page reload always returns to a clean workspace; the user re-opens via the
// sidebar Settings button when needed.
function persistablePanes(panes: WorkspacePane[]): WorkspacePane[] {
return panes.filter((p) => p.kind !== 'settings');
}
// v1.9: per recon decision (c), settings panes don't count toward MAX_PANES.
// Helper used at every pane-insertion site so the rule lives in one place.
function nonSettingsCount(panes: WorkspacePane[]): number {
return panes.reduce((n, p) => n + (p.kind === 'settings' ? 0 : 1), 0);
}
function loadPanes(sessionId: string): WorkspacePane[] | null {
try {
const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`);
@@ -33,7 +53,10 @@ function loadPanes(sessionId: string): WorkspacePane[] | null {
function savePanes(sessionId: string, panes: WorkspacePane[]): void {
try {
localStorage.setItem(`${STORAGE_KEY}.${sessionId}`, JSON.stringify(panes));
localStorage.setItem(
`${STORAGE_KEY}.${sessionId}`,
JSON.stringify(persistablePanes(panes)),
);
} catch { /* quota or disabled */ }
}
@@ -50,6 +73,10 @@ export interface UseWorkspacePanesResult {
closeAllTabs: (paneIdx: number) => void;
showLandingPage: (paneIdx: number) => void;
addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => void;
// v1.9: idempotent open-or-focus for the settings pane singleton. Appends
// a new settings pane if none exists, otherwise just focuses the existing
// one. Always succeeds — settings panes don't count toward MAX_PANES.
openOrFocusSettingsPane: () => void;
removePane: (idx: number) => void;
removeChatFromPanes: (chatId: string) => void;
initializeFirstChatIfEmpty: (chatId: string) => void;
@@ -216,7 +243,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
return;
}
setPanes((prev) => {
if (prev.length >= MAX_PANES) {
// v1.9: settings panes are excluded from the MAX cap (decision c).
if (nonSettingsCount(prev) >= MAX_PANES) {
toast.error(`Maximum ${MAX_PANES} panes`);
return prev;
}
@@ -226,6 +254,19 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
});
}, []);
const openOrFocusSettingsPane = useCallback(() => {
setPanes((prev) => {
const existingIdx = prev.findIndex((p) => p.kind === 'settings');
if (existingIdx >= 0) {
setActivePaneIdx(existingIdx);
return prev;
}
const next = [...prev, settingsPane()];
setActivePaneIdx(next.length - 1);
return next;
});
}, []);
const removePane = useCallback((idx: number) => {
setPanes((prev) => {
if (prev.length <= 1) return prev;
@@ -318,6 +359,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
setActivePaneIdx,
activePaneIdxRef,
openChatInPane,
openOrFocusSettingsPane,
switchTab,
removeTab,
closeOtherTabs,

226
apps/web/src/lib/theme.ts Normal file
View File

@@ -0,0 +1,226 @@
import { useEffect, useState } from 'react';
import { api } from '@/api/client';
// themes-v1: source of truth for the 18 presets. id and name are surfaced in
// the picker; family groups visually; supportsDark/supportsLight reflect
// whether the corresponding selector exists in styles/themes/<id>.css; anchors
// are the 5 dark swatches (or the light palette for the two light-only themes)
// used in the picker preview strip.
export type ThemeId =
| 'obsidian'
| 'gunmetal'
| 'espresso'
| 'volcanic-brown'
| 'copper'
| 'gold'
| 'oxblood'
| 'crimson'
| 'elderflower'
| 'plum'
| 'steel-pink'
| 'fuchsia-noir'
| 'matrix'
| 'sage'
| 'ivory'
| 'chalk'
| 'cobalt'
| 'midnight-sapphire';
export type ThemeMode = 'dark' | 'light' | 'system';
export interface ThemeMeta {
id: ThemeId;
name: string;
family: string;
supportsDark: boolean;
supportsLight: boolean;
anchors: [string, string, string, string, string];
}
export const THEMES: readonly ThemeMeta[] = [
{ id: 'obsidian', name: 'Obsidian', family: 'Charcoal', supportsDark: true, supportsLight: true,
anchors: ['#0c0c0e', '#15151a', '#1f1f23', '#6b6b75', '#8b5cf6'] },
{ id: 'gunmetal', name: 'Gunmetal', family: 'Charcoal', supportsDark: true, supportsLight: true,
anchors: ['#0d1117', '#161b22', '#21262d', '#7d8590', '#388bfd'] },
{ id: 'espresso', name: 'Espresso', family: 'Brown', supportsDark: true, supportsLight: true,
anchors: ['#1c1410', '#241a14', '#2e2218', '#8a7058', '#c8a880'] },
{ id: 'volcanic-brown', name: 'Volcanic Brown', family: 'Brown', supportsDark: true, supportsLight: true,
anchors: ['#140906', '#1e0e0a', '#2e1610', '#7a4030', '#cc4a1a'] },
{ id: 'copper', name: 'Copper', family: 'Amber', supportsDark: true, supportsLight: true,
anchors: ['#100800', '#1c1408', '#2e1f0a', '#8a6040', '#b87333'] },
{ id: 'gold', name: 'Gold', family: 'Amber', supportsDark: true, supportsLight: true,
anchors: ['#0e0800', '#1a1200', '#2a1f00', '#a07c30', '#d4af37'] },
{ id: 'oxblood', name: 'Oxblood', family: 'Crimson', supportsDark: true, supportsLight: true,
anchors: ['#0a0303', '#180606', '#2a0808', '#7a3028', '#8b1a1a'] },
{ id: 'crimson', name: 'Crimson', family: 'Crimson', supportsDark: true, supportsLight: true,
anchors: ['#0e0404', '#1a0808', '#2e0a0a', '#8a3030', '#dc143c'] },
{ id: 'elderflower', name: 'Elderflower', family: 'Violet', supportsDark: true, supportsLight: true,
anchors: ['#100818', '#1c1024', '#2c1830', '#8a78a0', '#b89cd8'] },
{ id: 'plum', name: 'Plum', family: 'Violet', supportsDark: true, supportsLight: true,
anchors: ['#0c0814', '#180e20', '#241830', '#7a4878', '#8e4585'] },
{ id: 'steel-pink', name: 'Steel Pink', family: 'Magenta', supportsDark: true, supportsLight: true,
anchors: ['#0e0408', '#1a080e', '#2e0c1a', '#9a4070', '#cc33aa'] },
{ id: 'fuchsia-noir', name: 'Fuchsia Noir', family: 'Magenta', supportsDark: true, supportsLight: true,
anchors: ['#0a0610', '#14081a', '#2a0c2e', '#8a3878', '#ff1493'] },
{ id: 'matrix', name: 'Matrix', family: 'Green', supportsDark: true, supportsLight: true,
anchors: ['#000a00', '#031403', '#0a200a', '#208030', '#00ff41'] },
{ id: 'sage', name: 'Sage', family: 'Green', supportsDark: true, supportsLight: true,
anchors: ['#0a0e08', '#141a10', '#1e2e1a', '#7a8870', '#9caf88'] },
{ id: 'ivory', name: 'Ivory', family: 'Light', supportsDark: false, supportsLight: true,
anchors: ['#fdfcf8', '#f5f2e8', '#e8e4d8', '#8a8478', '#3a3328'] },
{ id: 'chalk', name: 'Chalk', family: 'Light', supportsDark: false, supportsLight: true,
anchors: ['#fafaf7', '#f0f0ec', '#e5e5e0', '#75756e', '#2a2a28'] },
{ id: 'cobalt', name: 'Cobalt', family: 'Blue', supportsDark: true, supportsLight: true,
anchors: ['#020817', '#061434', '#0c2244', '#3060a0', '#0047ab'] },
{ id: 'midnight-sapphire', name: 'Midnight Sapphire', family: 'Blue', supportsDark: true, supportsLight: true,
anchors: ['#02050e', '#060c1f', '#0e1a36', '#4a6088', '#1e3a8a'] },
] as const;
export const DEFAULT_THEME_ID: ThemeId = 'obsidian';
export const DEFAULT_THEME_MODE: ThemeMode = 'dark';
export const STORAGE_KEY = 'boocode.theme';
const THEME_IDS_SET: ReadonlySet<string> = new Set(THEMES.map((t) => t.id));
export function isThemeId(s: string): s is ThemeId {
return THEME_IDS_SET.has(s);
}
function resolvedMode(mode: ThemeMode): 'dark' | 'light' {
if (mode !== 'system') return mode;
if (typeof window === 'undefined') return 'dark';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// Light-only themes (ivory, chalk) can't render dark — fall back to obsidian
// dark per spec §8 decision 1. Keeps the fallback explicit so the caller
// doesn't accidentally apply theme-ivory.dark (which has no rule block).
function effectiveThemeId(id: ThemeId, mode: 'dark' | 'light'): ThemeId {
if (mode === 'dark') {
const meta = THEMES.find((t) => t.id === id);
if (meta && !meta.supportsDark) return DEFAULT_THEME_ID;
}
return id;
}
export function applyTheme(id: ThemeId, mode: ThemeMode): void {
if (typeof document === 'undefined') return;
const resolved = resolvedMode(mode);
const effective = effectiveThemeId(id, resolved);
document.documentElement.className =
`theme-${effective}${resolved === 'dark' ? ' dark' : ''}`;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ id, mode }));
} catch {
// quota / disabled storage — apply still succeeds, persistence falls
// back to the next /api/settings round-trip.
}
}
interface ThemeState {
id: ThemeId;
mode: ThemeMode;
}
// Module-level singleton, mirrors the useChatStatus / useSidebar pattern.
// One shared state across every useTheme() consumer; setTheme() mutates it
// and notifies subscribers so the App-level hook (which owns the matchMedia
// listener) and the Settings picker stay in lockstep without prop drilling.
function readCache(): ThemeState | null {
if (typeof localStorage === 'undefined') return null;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as { id?: unknown; mode?: unknown };
if (typeof parsed.id !== 'string' || !isThemeId(parsed.id)) return null;
const m = parsed.mode;
if (m !== 'dark' && m !== 'light' && m !== 'system') return null;
return { id: parsed.id, mode: m };
} catch {
return null;
}
}
let _state: ThemeState = readCache() ?? { id: DEFAULT_THEME_ID, mode: DEFAULT_THEME_MODE };
let _initialized = false;
const _subscribers = new Set<(s: ThemeState) => void>();
function notify(): void {
for (const sub of _subscribers) {
try {
sub(_state);
} catch {
// swallow — one bad subscriber shouldn't break others
}
}
}
// Optimistic update: applies immediately, PATCHes server, reverts on failure
// so the picker can show a toast without manual state juggling. Throws on
// failure so the caller can surface the error.
export async function setTheme(id: ThemeId, mode: ThemeMode): Promise<void> {
const prev = _state;
_state = { id, mode };
applyTheme(id, mode);
notify();
try {
await api.settings.patch({ theme_id: id, theme_mode: mode });
} catch (err) {
_state = prev;
applyTheme(prev.id, prev.mode);
notify();
throw err;
}
}
// useTheme — mounts as many times as needed across the tree. The first mount
// (initialized=false) triggers a single /api/settings fetch to reconcile the
// local cache with the server. Every mount installs the matchMedia listener
// when mode === 'system'; cleanup runs on unmount or when mode flips away.
export function useTheme(): ThemeState {
const [state, setState] = useState<ThemeState>(_state);
useEffect(() => {
_subscribers.add(setState);
// Ensure the DOM reflects current state on mount — the FOUC script in
// index.html runs before this hook, but we re-apply in case the cache
// was stale relative to a fresh fetch above.
applyTheme(_state.id, _state.mode);
if (!_initialized) {
_initialized = true;
api.settings
.get()
.then((s) => {
const rawId = s['theme_id'];
const rawMode = s['theme_mode'];
const id =
typeof rawId === 'string' && isThemeId(rawId) ? rawId : DEFAULT_THEME_ID;
const mode: ThemeMode =
rawMode === 'dark' || rawMode === 'light' || rawMode === 'system'
? rawMode
: DEFAULT_THEME_MODE;
_state = { id, mode };
applyTheme(id, mode);
notify();
})
.catch(() => {
// Settings fetch failed — keep whatever the FOUC script applied.
// The picker still works; PATCH will retry on next selection.
});
}
return () => {
_subscribers.delete(setState);
};
}, []);
useEffect(() => {
if (state.mode !== 'system') return;
if (typeof window === 'undefined') return;
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = () => applyTheme(state.id, 'system');
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, [state.id, state.mode]);
return state;
}

View File

@@ -47,6 +47,11 @@ export function Home() {
last_session_id: null,
status: 'archived' as const,
gitea_remote: fromSidebar.gitea_remote,
// v1.9: synthesized stub for an archived project that only the
// sidebar cache has — defaults match the schema NOT NULL DEFAULT
// values. The full row gets re-fetched on unarchive.
default_system_prompt: '',
default_web_search_enabled: false,
},
...prev,
];

View File

@@ -116,9 +116,32 @@ function SessionInner({ sessionId }: { sessionId: string }) {
event.session_id === sessionId
) {
navigate(`/project/${event.project_id}`);
return;
}
// v1.9: any session_updated for this session triggers a full refetch so
// SettingsPane (mounted in a workspace pane) picks up system_prompt /
// web_search_enabled / model edits made from another tab.
if (event.type === 'session_updated' && event.session_id === sessionId) {
void api.sessions.get(sessionId).then((s) => {
setSession(s);
setName((prev) => (editingName ? prev : s.name));
}).catch(() => {});
return;
}
// v1.9: project_updated → refetch project so the Project section in
// SettingsPane reflects the new defaults.
if (event.type === 'project_updated' && project && event.project_id === project.id) {
void api.projects.get(project.id).then(setProject).catch(() => {});
return;
}
// v1.9: sidebar Settings button broadcasts this when a session is
// mounted; we own the workspace pane state, so we open/focus the
// singleton settings pane here.
if (event.type === 'open_settings_pane') {
panesHook.openOrFocusSettingsPane();
}
});
}, [sessionId, editingName, navigate]);
}, [sessionId, editingName, navigate, project, panesHook]);
// v1.8: URL ?pane= sync (mobile only). Lifted from Workspace.tsx so
// MobileTabSwitcher's onSwitchPane can push the same URL state and the
@@ -211,15 +234,13 @@ function SessionInner({ sessionId }: { sessionId: string }) {
</div>
{session && (
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1 shrink-0">
<ModelPicker
value={session.model}
onChange={async (model) => {
const updated = await api.sessions.update(session.id, { model });
setSession(updated);
}}
/>
</div>
<ModelPicker
value={session.model}
onChange={async (model) => {
const updated = await api.sessions.update(session.id, { model });
setSession(updated);
}}
/>
)}
<button
@@ -337,6 +358,8 @@ function SessionInner({ sessionId }: { sessionId: string }) {
}}
panesHook={panesHook}
chatsHook={chatsHook}
session={session}
project={project}
/>
)}
</div>

View File

@@ -0,0 +1,47 @@
import { ArrowLeft } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { ThemePicker } from '@/components/ThemePicker';
// v1.9: thin wrapper around <ThemePicker />. The picker itself moved to a
// reusable component (also rendered in the workspace SettingsPane Theme tab).
// This page-level shell adds the back affordance + heading chrome that's
// appropriate when the picker is the entire route.
export function Settings() {
const navigate = useNavigate();
function handleBack() {
// History-aware: jump back to where the user came from when possible.
// Direct loads of /settings (no history) land on Home so the button
// always does *something* useful.
if (window.history.length > 1) {
navigate(-1);
} else {
navigate('/');
}
}
return (
<div className="flex-1 overflow-y-auto">
<div className="max-w-[1000px] mx-auto w-full px-6 py-6 space-y-8">
<header className="space-y-2">
<button
type="button"
onClick={handleBack}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground -ml-1 px-1 py-0.5 rounded"
aria-label="Back"
>
<ArrowLeft className="size-4" />
<span>Back</span>
</button>
<div>
<h1 className="text-xl font-semibold">Settings</h1>
<p className="text-sm text-muted-foreground mt-1">
Theme appearance. Saved on change, applies immediately.
</p>
</div>
</header>
<ThemePicker />
</div>
</div>
);
}

View File

@@ -4,6 +4,29 @@
@import "@fontsource-variable/inter";
@import "@fontsource-variable/jetbrains-mono";
/* themes-v1: 18 preset palettes. Order matches docs/themes_v1.md §1 with
obsidian first (default). Each file declares .theme-<id> for the light
variant and .theme-<id>.dark for the dark variant (except ivory/chalk
which are light-only). lib/theme.ts owns the class composition on <html>. */
@import "./themes/obsidian.css";
@import "./themes/gunmetal.css";
@import "./themes/espresso.css";
@import "./themes/volcanic-brown.css";
@import "./themes/copper.css";
@import "./themes/gold.css";
@import "./themes/oxblood.css";
@import "./themes/crimson.css";
@import "./themes/elderflower.css";
@import "./themes/plum.css";
@import "./themes/steel-pink.css";
@import "./themes/fuchsia-noir.css";
@import "./themes/matrix.css";
@import "./themes/sage.css";
@import "./themes/ivory.css";
@import "./themes/chalk.css";
@import "./themes/cobalt.css";
@import "./themes/midnight-sapphire.css";
@custom-variant dark (&:is(.dark *));
:root {

View File

@@ -0,0 +1,33 @@
/* themes-v1: Chalk (family: light-only).
Anchors used directly as light palette: #fafaf7 #f0f0ec #e5e5e0 #75756e #2a2a28.
No .theme-chalk.dark — selecting dark mode on chalk falls back to obsidian
dark via lib/theme.ts. */
.theme-chalk {
--background: #fafaf7;
--foreground: #2a2a28;
--card: #f0f0ec;
--card-foreground: #2a2a28;
--popover: #f0f0ec;
--popover-foreground: #2a2a28;
--primary: #2a2a28;
--primary-foreground: #fafaf7;
--secondary: #e5e5e0;
--secondary-foreground: #2a2a28;
--muted: #e5e5e0;
--muted-foreground: #75756e;
--accent: #2a2a28;
--accent-foreground: #fafaf7;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #e5e5e0;
--input: #e5e5e0;
--ring: #2a2a28;
--sidebar: #f0f0ec;
--sidebar-foreground: #2a2a28;
--sidebar-primary: #2a2a28;
--sidebar-primary-foreground: #fafaf7;
--sidebar-accent: #e5e5e0;
--sidebar-accent-foreground: #2a2a28;
--sidebar-border: #e5e5e0;
--sidebar-ring: #2a2a28;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Cobalt (family: blue).
Dark anchors: #020817 #061434 #0c2244 #3060a0 #0047ab. */
.theme-cobalt {
--background: #f4f8ff;
--foreground: #0a1428;
--card: #e8f0fc;
--card-foreground: #0a1428;
--popover: #e8f0fc;
--popover-foreground: #0a1428;
--primary: #003278;
--primary-foreground: #ffffff;
--secondary: #d4e0f4;
--secondary-foreground: #0a1428;
--muted: #d4e0f4;
--muted-foreground: #284878;
--accent: #003278;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #d4e0f4;
--input: #d4e0f4;
--ring: #003278;
--sidebar: #e8f0fc;
--sidebar-foreground: #0a1428;
--sidebar-primary: #003278;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #d4e0f4;
--sidebar-accent-foreground: #0a1428;
--sidebar-border: #d4e0f4;
--sidebar-ring: #003278;
}
.theme-cobalt.dark {
--background: #020817;
--foreground: #dce4f0;
--card: #061434;
--card-foreground: #dce4f0;
--popover: #061434;
--popover-foreground: #dce4f0;
--primary: #0047ab;
--primary-foreground: #dce4f0;
--secondary: #0c2244;
--secondary-foreground: #dce4f0;
--muted: #0c2244;
--muted-foreground: #3060a0;
--accent: #0047ab;
--accent-foreground: #dce4f0;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #0c2244;
--input: #0c2244;
--ring: #0047ab;
--sidebar: #061434;
--sidebar-foreground: #dce4f0;
--sidebar-primary: #0047ab;
--sidebar-primary-foreground: #dce4f0;
--sidebar-accent: #0047ab;
--sidebar-accent-foreground: #dce4f0;
--sidebar-border: #0c2244;
--sidebar-ring: #0047ab;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Copper (family: orange/amber).
Dark anchors: #100800 #1c1408 #2e1f0a #8a6040 #b87333. */
.theme-copper {
--background: #fdf8f0;
--foreground: #2a1f0a;
--card: #faf0e0;
--card-foreground: #2a1f0a;
--popover: #faf0e0;
--popover-foreground: #2a1f0a;
--primary: #8a5424;
--primary-foreground: #ffffff;
--secondary: #f0e0c0;
--secondary-foreground: #2a1f0a;
--muted: #f0e0c0;
--muted-foreground: #6e4828;
--accent: #8a5424;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #f0e0c0;
--input: #f0e0c0;
--ring: #8a5424;
--sidebar: #faf0e0;
--sidebar-foreground: #2a1f0a;
--sidebar-primary: #8a5424;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #f0e0c0;
--sidebar-accent-foreground: #2a1f0a;
--sidebar-border: #f0e0c0;
--sidebar-ring: #8a5424;
}
.theme-copper.dark {
--background: #100800;
--foreground: #f8e8c8;
--card: #1c1408;
--card-foreground: #f8e8c8;
--popover: #1c1408;
--popover-foreground: #f8e8c8;
--primary: #b87333;
--primary-foreground: #100800;
--secondary: #2e1f0a;
--secondary-foreground: #f8e8c8;
--muted: #2e1f0a;
--muted-foreground: #8a6040;
--accent: #b87333;
--accent-foreground: #100800;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #2e1f0a;
--input: #2e1f0a;
--ring: #b87333;
--sidebar: #1c1408;
--sidebar-foreground: #f8e8c8;
--sidebar-primary: #b87333;
--sidebar-primary-foreground: #100800;
--sidebar-accent: #b87333;
--sidebar-accent-foreground: #100800;
--sidebar-border: #2e1f0a;
--sidebar-ring: #b87333;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Crimson (family: red/crimson).
Dark anchors: #0e0404 #1a0808 #2e0a0a #8a3030 #dc143c. */
.theme-crimson {
--background: #fef4f6;
--foreground: #2a0a12;
--card: #fde6ea;
--card-foreground: #2a0a12;
--popover: #fde6ea;
--popover-foreground: #2a0a12;
--primary: #a40e2d;
--primary-foreground: #ffffff;
--secondary: #f4d0d8;
--secondary-foreground: #2a0a12;
--muted: #f4d0d8;
--muted-foreground: #6a2030;
--accent: #a40e2d;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #f4d0d8;
--input: #f4d0d8;
--ring: #a40e2d;
--sidebar: #fde6ea;
--sidebar-foreground: #2a0a12;
--sidebar-primary: #a40e2d;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #f4d0d8;
--sidebar-accent-foreground: #2a0a12;
--sidebar-border: #f4d0d8;
--sidebar-ring: #a40e2d;
}
.theme-crimson.dark {
--background: #0e0404;
--foreground: #f0d4d8;
--card: #1a0808;
--card-foreground: #f0d4d8;
--popover: #1a0808;
--popover-foreground: #f0d4d8;
--primary: #dc143c;
--primary-foreground: #0e0404;
--secondary: #2e0a0a;
--secondary-foreground: #f0d4d8;
--muted: #2e0a0a;
--muted-foreground: #8a3030;
--accent: #dc143c;
--accent-foreground: #0e0404;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #2e0a0a;
--input: #2e0a0a;
--ring: #dc143c;
--sidebar: #1a0808;
--sidebar-foreground: #f0d4d8;
--sidebar-primary: #dc143c;
--sidebar-primary-foreground: #0e0404;
--sidebar-accent: #dc143c;
--sidebar-accent-foreground: #0e0404;
--sidebar-border: #2e0a0a;
--sidebar-ring: #dc143c;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Elderflower (family: purple/violet).
Dark anchors: #100818 #1c1024 #2c1830 #8a78a0 #b89cd8. */
.theme-elderflower {
--background: #faf8fd;
--foreground: #1f1428;
--card: #f4eef9;
--card-foreground: #1f1428;
--popover: #f4eef9;
--popover-foreground: #1f1428;
--primary: #8a70b4;
--primary-foreground: #ffffff;
--secondary: #e8def0;
--secondary-foreground: #1f1428;
--muted: #e8def0;
--muted-foreground: #6e5a82;
--accent: #8a70b4;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #e8def0;
--input: #e8def0;
--ring: #8a70b4;
--sidebar: #f4eef9;
--sidebar-foreground: #1f1428;
--sidebar-primary: #8a70b4;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #e8def0;
--sidebar-accent-foreground: #1f1428;
--sidebar-border: #e8def0;
--sidebar-ring: #8a70b4;
}
.theme-elderflower.dark {
--background: #100818;
--foreground: #ece4f0;
--card: #1c1024;
--card-foreground: #ece4f0;
--popover: #1c1024;
--popover-foreground: #ece4f0;
--primary: #b89cd8;
--primary-foreground: #100818;
--secondary: #2c1830;
--secondary-foreground: #ece4f0;
--muted: #2c1830;
--muted-foreground: #8a78a0;
--accent: #b89cd8;
--accent-foreground: #100818;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #2c1830;
--input: #2c1830;
--ring: #b89cd8;
--sidebar: #1c1024;
--sidebar-foreground: #ece4f0;
--sidebar-primary: #b89cd8;
--sidebar-primary-foreground: #100818;
--sidebar-accent: #b89cd8;
--sidebar-accent-foreground: #100818;
--sidebar-border: #2c1830;
--sidebar-ring: #b89cd8;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Espresso (family: brown/earth).
Dark anchors: #1c1410 #241a14 #2e2218 #8a7058 #c8a880. */
.theme-espresso {
--background: #faf6f0;
--foreground: #2a1f15;
--card: #f3ece0;
--card-foreground: #2a1f15;
--popover: #f3ece0;
--popover-foreground: #2a1f15;
--primary: #9c7948;
--primary-foreground: #ffffff;
--secondary: #e6dccc;
--secondary-foreground: #2a1f15;
--muted: #e6dccc;
--muted-foreground: #6e5944;
--accent: #9c7948;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #e6dccc;
--input: #e6dccc;
--ring: #9c7948;
--sidebar: #f3ece0;
--sidebar-foreground: #2a1f15;
--sidebar-primary: #9c7948;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #e6dccc;
--sidebar-accent-foreground: #2a1f15;
--sidebar-border: #e6dccc;
--sidebar-ring: #9c7948;
}
.theme-espresso.dark {
--background: #1c1410;
--foreground: #f0e8d8;
--card: #241a14;
--card-foreground: #f0e8d8;
--popover: #241a14;
--popover-foreground: #f0e8d8;
--primary: #c8a880;
--primary-foreground: #1c1410;
--secondary: #2e2218;
--secondary-foreground: #f0e8d8;
--muted: #2e2218;
--muted-foreground: #8a7058;
--accent: #c8a880;
--accent-foreground: #1c1410;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #2e2218;
--input: #2e2218;
--ring: #c8a880;
--sidebar: #241a14;
--sidebar-foreground: #f0e8d8;
--sidebar-primary: #c8a880;
--sidebar-primary-foreground: #1c1410;
--sidebar-accent: #c8a880;
--sidebar-accent-foreground: #1c1410;
--sidebar-border: #2e2218;
--sidebar-ring: #c8a880;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Fuchsia Noir (family: pink/magenta).
Dark anchors: #0a0610 #14081a #2a0c2e #8a3878 #ff1493. */
.theme-fuchsia-noir {
--background: #fdf4f8;
--foreground: #2a0a1c;
--card: #fbe6f0;
--card-foreground: #2a0a1c;
--popover: #fbe6f0;
--popover-foreground: #2a0a1c;
--primary: #c20070;
--primary-foreground: #ffffff;
--secondary: #f4d0e4;
--secondary-foreground: #2a0a1c;
--muted: #f4d0e4;
--muted-foreground: #7a2860;
--accent: #c20070;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #f4d0e4;
--input: #f4d0e4;
--ring: #c20070;
--sidebar: #fbe6f0;
--sidebar-foreground: #2a0a1c;
--sidebar-primary: #c20070;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #f4d0e4;
--sidebar-accent-foreground: #2a0a1c;
--sidebar-border: #f4d0e4;
--sidebar-ring: #c20070;
}
.theme-fuchsia-noir.dark {
--background: #0a0610;
--foreground: #f0d8e8;
--card: #14081a;
--card-foreground: #f0d8e8;
--popover: #14081a;
--popover-foreground: #f0d8e8;
--primary: #ff1493;
--primary-foreground: #0a0610;
--secondary: #2a0c2e;
--secondary-foreground: #f0d8e8;
--muted: #2a0c2e;
--muted-foreground: #8a3878;
--accent: #ff1493;
--accent-foreground: #0a0610;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #2a0c2e;
--input: #2a0c2e;
--ring: #ff1493;
--sidebar: #14081a;
--sidebar-foreground: #f0d8e8;
--sidebar-primary: #ff1493;
--sidebar-primary-foreground: #0a0610;
--sidebar-accent: #ff1493;
--sidebar-accent-foreground: #0a0610;
--sidebar-border: #2a0c2e;
--sidebar-ring: #ff1493;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Gold (family: orange/amber).
Dark anchors: #0e0800 #1a1200 #2a1f00 #a07c30 #d4af37. */
.theme-gold {
--background: #fffbf0;
--foreground: #2a200a;
--card: #fdf3d0;
--card-foreground: #2a200a;
--popover: #fdf3d0;
--popover-foreground: #2a200a;
--primary: #a18229;
--primary-foreground: #ffffff;
--secondary: #f0e0a0;
--secondary-foreground: #2a200a;
--muted: #f0e0a0;
--muted-foreground: #786020;
--accent: #a18229;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #f0e0a0;
--input: #f0e0a0;
--ring: #a18229;
--sidebar: #fdf3d0;
--sidebar-foreground: #2a200a;
--sidebar-primary: #a18229;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #f0e0a0;
--sidebar-accent-foreground: #2a200a;
--sidebar-border: #f0e0a0;
--sidebar-ring: #a18229;
}
.theme-gold.dark {
--background: #0e0800;
--foreground: #fff0d0;
--card: #1a1200;
--card-foreground: #fff0d0;
--popover: #1a1200;
--popover-foreground: #fff0d0;
--primary: #d4af37;
--primary-foreground: #0e0800;
--secondary: #2a1f00;
--secondary-foreground: #fff0d0;
--muted: #2a1f00;
--muted-foreground: #a07c30;
--accent: #d4af37;
--accent-foreground: #0e0800;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #2a1f00;
--input: #2a1f00;
--ring: #d4af37;
--sidebar: #1a1200;
--sidebar-foreground: #fff0d0;
--sidebar-primary: #d4af37;
--sidebar-primary-foreground: #0e0800;
--sidebar-accent: #d4af37;
--sidebar-accent-foreground: #0e0800;
--sidebar-border: #2a1f00;
--sidebar-ring: #d4af37;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Gunmetal (family: charcoal/black).
Dark anchors: #0d1117 #161b22 #21262d #7d8590 #388bfd. */
.theme-gunmetal {
--background: #fafafa;
--foreground: #14181f;
--card: #f1f3f6;
--card-foreground: #14181f;
--popover: #f1f3f6;
--popover-foreground: #14181f;
--primary: #0c6dd0;
--primary-foreground: #ffffff;
--secondary: #dde1e8;
--secondary-foreground: #14181f;
--muted: #dde1e8;
--muted-foreground: #5a6470;
--accent: #0c6dd0;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #dde1e8;
--input: #dde1e8;
--ring: #0c6dd0;
--sidebar: #f1f3f6;
--sidebar-foreground: #14181f;
--sidebar-primary: #0c6dd0;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #dde1e8;
--sidebar-accent-foreground: #14181f;
--sidebar-border: #dde1e8;
--sidebar-ring: #0c6dd0;
}
.theme-gunmetal.dark {
--background: #0d1117;
--foreground: #e6ecf0;
--card: #161b22;
--card-foreground: #e6ecf0;
--popover: #161b22;
--popover-foreground: #e6ecf0;
--primary: #388bfd;
--primary-foreground: #0d1117;
--secondary: #21262d;
--secondary-foreground: #e6ecf0;
--muted: #21262d;
--muted-foreground: #7d8590;
--accent: #388bfd;
--accent-foreground: #0d1117;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #21262d;
--input: #21262d;
--ring: #388bfd;
--sidebar: #161b22;
--sidebar-foreground: #e6ecf0;
--sidebar-primary: #388bfd;
--sidebar-primary-foreground: #0d1117;
--sidebar-accent: #388bfd;
--sidebar-accent-foreground: #0d1117;
--sidebar-border: #21262d;
--sidebar-ring: #388bfd;
}

View File

@@ -0,0 +1,33 @@
/* themes-v1: Ivory (family: light-only).
Anchors used directly as light palette: #fdfcf8 #f5f2e8 #e8e4d8 #8a8478 #3a3328.
No .theme-ivory.dark — selecting dark mode on ivory falls back to obsidian
dark via lib/theme.ts. */
.theme-ivory {
--background: #fdfcf8;
--foreground: #3a3328;
--card: #f5f2e8;
--card-foreground: #3a3328;
--popover: #f5f2e8;
--popover-foreground: #3a3328;
--primary: #3a3328;
--primary-foreground: #fdfcf8;
--secondary: #e8e4d8;
--secondary-foreground: #3a3328;
--muted: #e8e4d8;
--muted-foreground: #8a8478;
--accent: #3a3328;
--accent-foreground: #fdfcf8;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #e8e4d8;
--input: #e8e4d8;
--ring: #3a3328;
--sidebar: #f5f2e8;
--sidebar-foreground: #3a3328;
--sidebar-primary: #3a3328;
--sidebar-primary-foreground: #fdfcf8;
--sidebar-accent: #e8e4d8;
--sidebar-accent-foreground: #3a3328;
--sidebar-border: #e8e4d8;
--sidebar-ring: #3a3328;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Matrix (family: green, neon).
Dark anchors: #000a00 #031403 #0a200a #208030 #00ff41. */
.theme-matrix {
--background: #f0fff4;
--foreground: #0a2a15;
--card: #e0f8e8;
--card-foreground: #0a2a15;
--popover: #e0f8e8;
--popover-foreground: #0a2a15;
--primary: #00b830;
--primary-foreground: #ffffff;
--secondary: #c0e8d0;
--secondary-foreground: #0a2a15;
--muted: #c0e8d0;
--muted-foreground: #208048;
--accent: #00b830;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #c0e8d0;
--input: #c0e8d0;
--ring: #00b830;
--sidebar: #e0f8e8;
--sidebar-foreground: #0a2a15;
--sidebar-primary: #00b830;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #c0e8d0;
--sidebar-accent-foreground: #0a2a15;
--sidebar-border: #c0e8d0;
--sidebar-ring: #00b830;
}
.theme-matrix.dark {
--background: #000a00;
--foreground: #d8f8e0;
--card: #031403;
--card-foreground: #d8f8e0;
--popover: #031403;
--popover-foreground: #d8f8e0;
--primary: #00ff41;
--primary-foreground: #000a00;
--secondary: #0a200a;
--secondary-foreground: #d8f8e0;
--muted: #0a200a;
--muted-foreground: #208030;
--accent: #00ff41;
--accent-foreground: #000a00;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #0a200a;
--input: #0a200a;
--ring: #00ff41;
--sidebar: #031403;
--sidebar-foreground: #d8f8e0;
--sidebar-primary: #00ff41;
--sidebar-primary-foreground: #000a00;
--sidebar-accent: #00ff41;
--sidebar-accent-foreground: #000a00;
--sidebar-border: #0a200a;
--sidebar-ring: #00ff41;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Midnight Sapphire (family: blue).
Dark anchors: #02050e #060c1f #0e1a36 #4a6088 #1e3a8a. */
.theme-midnight-sapphire {
--background: #f4f6fc;
--foreground: #0a1024;
--card: #e6eaf6;
--card-foreground: #0a1024;
--popover: #e6eaf6;
--popover-foreground: #0a1024;
--primary: #142a60;
--primary-foreground: #ffffff;
--secondary: #d0d8ec;
--secondary-foreground: #0a1024;
--muted: #d0d8ec;
--muted-foreground: #36507a;
--accent: #142a60;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #d0d8ec;
--input: #d0d8ec;
--ring: #142a60;
--sidebar: #e6eaf6;
--sidebar-foreground: #0a1024;
--sidebar-primary: #142a60;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #d0d8ec;
--sidebar-accent-foreground: #0a1024;
--sidebar-border: #d0d8ec;
--sidebar-ring: #142a60;
}
.theme-midnight-sapphire.dark {
--background: #02050e;
--foreground: #dce0f0;
--card: #060c1f;
--card-foreground: #dce0f0;
--popover: #060c1f;
--popover-foreground: #dce0f0;
--primary: #1e3a8a;
--primary-foreground: #dce0f0;
--secondary: #0e1a36;
--secondary-foreground: #dce0f0;
--muted: #0e1a36;
--muted-foreground: #4a6088;
--accent: #1e3a8a;
--accent-foreground: #dce0f0;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #0e1a36;
--input: #0e1a36;
--ring: #1e3a8a;
--sidebar: #060c1f;
--sidebar-foreground: #dce0f0;
--sidebar-primary: #1e3a8a;
--sidebar-primary-foreground: #dce0f0;
--sidebar-accent: #1e3a8a;
--sidebar-accent-foreground: #dce0f0;
--sidebar-border: #0e1a36;
--sidebar-ring: #1e3a8a;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Obsidian (family: charcoal/black). Default theme.
Dark anchors: #0c0c0e #15151a #1f1f23 #6b6b75 #8b5cf6. Light variant per spec §3. */
.theme-obsidian {
--background: #fafafa;
--foreground: #18181b;
--card: #f4f4f5;
--card-foreground: #18181b;
--popover: #f4f4f5;
--popover-foreground: #18181b;
--primary: #6d40e8;
--primary-foreground: #ffffff;
--secondary: #e4e4e7;
--secondary-foreground: #18181b;
--muted: #e4e4e7;
--muted-foreground: #71717a;
--accent: #6d40e8;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #e4e4e7;
--input: #e4e4e7;
--ring: #6d40e8;
--sidebar: #f4f4f5;
--sidebar-foreground: #18181b;
--sidebar-primary: #6d40e8;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #e4e4e7;
--sidebar-accent-foreground: #18181b;
--sidebar-border: #e4e4e7;
--sidebar-ring: #6d40e8;
}
.theme-obsidian.dark {
--background: #0c0c0e;
--foreground: #ece9f0;
--card: #15151a;
--card-foreground: #ece9f0;
--popover: #15151a;
--popover-foreground: #ece9f0;
--primary: #8b5cf6;
--primary-foreground: #0c0c0e;
--secondary: #1f1f23;
--secondary-foreground: #ece9f0;
--muted: #1f1f23;
--muted-foreground: #6b6b75;
--accent: #8b5cf6;
--accent-foreground: #0c0c0e;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #1f1f23;
--input: #1f1f23;
--ring: #8b5cf6;
--sidebar: #15151a;
--sidebar-foreground: #ece9f0;
--sidebar-primary: #8b5cf6;
--sidebar-primary-foreground: #0c0c0e;
--sidebar-accent: #8b5cf6;
--sidebar-accent-foreground: #0c0c0e;
--sidebar-border: #1f1f23;
--sidebar-ring: #8b5cf6;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Oxblood (family: red/crimson).
Dark anchors: #0a0303 #180606 #2a0808 #7a3028 #8b1a1a. */
.theme-oxblood {
--background: #fdf4f4;
--foreground: #2a0a0a;
--card: #fae6e6;
--card-foreground: #2a0a0a;
--popover: #fae6e6;
--popover-foreground: #2a0a0a;
--primary: #5e1010;
--primary-foreground: #ffffff;
--secondary: #f0d0d0;
--secondary-foreground: #2a0a0a;
--muted: #f0d0d0;
--muted-foreground: #582020;
--accent: #5e1010;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #f0d0d0;
--input: #f0d0d0;
--ring: #5e1010;
--sidebar: #fae6e6;
--sidebar-foreground: #2a0a0a;
--sidebar-primary: #5e1010;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #f0d0d0;
--sidebar-accent-foreground: #2a0a0a;
--sidebar-border: #f0d0d0;
--sidebar-ring: #5e1010;
}
.theme-oxblood.dark {
--background: #0a0303;
--foreground: #f0d8d8;
--card: #180606;
--card-foreground: #f0d8d8;
--popover: #180606;
--popover-foreground: #f0d8d8;
--primary: #8b1a1a;
--primary-foreground: #0a0303;
--secondary: #2a0808;
--secondary-foreground: #f0d8d8;
--muted: #2a0808;
--muted-foreground: #7a3028;
--accent: #8b1a1a;
--accent-foreground: #0a0303;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #2a0808;
--input: #2a0808;
--ring: #8b1a1a;
--sidebar: #180606;
--sidebar-foreground: #f0d8d8;
--sidebar-primary: #8b1a1a;
--sidebar-primary-foreground: #0a0303;
--sidebar-accent: #8b1a1a;
--sidebar-accent-foreground: #0a0303;
--sidebar-border: #2a0808;
--sidebar-ring: #8b1a1a;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Plum (family: purple/violet).
Dark anchors: #0c0814 #180e20 #241830 #7a4878 #8e4585. */
.theme-plum {
--background: #fbf7fd;
--foreground: #1f0f24;
--card: #f4ebf6;
--card-foreground: #1f0f24;
--popover: #f4ebf6;
--popover-foreground: #1f0f24;
--primary: #6a3263;
--primary-foreground: #ffffff;
--secondary: #e8d8ea;
--secondary-foreground: #1f0f24;
--muted: #e8d8ea;
--muted-foreground: #5e3858;
--accent: #6a3263;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #e8d8ea;
--input: #e8d8ea;
--ring: #6a3263;
--sidebar: #f4ebf6;
--sidebar-foreground: #1f0f24;
--sidebar-primary: #6a3263;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #e8d8ea;
--sidebar-accent-foreground: #1f0f24;
--sidebar-border: #e8d8ea;
--sidebar-ring: #6a3263;
}
.theme-plum.dark {
--background: #0c0814;
--foreground: #ecd8ec;
--card: #180e20;
--card-foreground: #ecd8ec;
--popover: #180e20;
--popover-foreground: #ecd8ec;
--primary: #8e4585;
--primary-foreground: #0c0814;
--secondary: #241830;
--secondary-foreground: #ecd8ec;
--muted: #241830;
--muted-foreground: #7a4878;
--accent: #8e4585;
--accent-foreground: #0c0814;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #241830;
--input: #241830;
--ring: #8e4585;
--sidebar: #180e20;
--sidebar-foreground: #ecd8ec;
--sidebar-primary: #8e4585;
--sidebar-primary-foreground: #0c0814;
--sidebar-accent: #8e4585;
--sidebar-accent-foreground: #0c0814;
--sidebar-border: #241830;
--sidebar-ring: #8e4585;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Sage (family: green, warm).
Dark anchors: #0a0e08 #141a10 #1e2e1a #7a8870 #9caf88. */
.theme-sage {
--background: #f4f8f0;
--foreground: #1a2510;
--card: #ebf2e2;
--card-foreground: #1a2510;
--popover: #ebf2e2;
--popover-foreground: #1a2510;
--primary: #708868;
--primary-foreground: #ffffff;
--secondary: #d8e2c8;
--secondary-foreground: #1a2510;
--muted: #d8e2c8;
--muted-foreground: #5a6850;
--accent: #708868;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #d8e2c8;
--input: #d8e2c8;
--ring: #708868;
--sidebar: #ebf2e2;
--sidebar-foreground: #1a2510;
--sidebar-primary: #708868;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #d8e2c8;
--sidebar-accent-foreground: #1a2510;
--sidebar-border: #d8e2c8;
--sidebar-ring: #708868;
}
.theme-sage.dark {
--background: #0a0e08;
--foreground: #e8eee0;
--card: #141a10;
--card-foreground: #e8eee0;
--popover: #141a10;
--popover-foreground: #e8eee0;
--primary: #9caf88;
--primary-foreground: #0a0e08;
--secondary: #1e2e1a;
--secondary-foreground: #e8eee0;
--muted: #1e2e1a;
--muted-foreground: #7a8870;
--accent: #9caf88;
--accent-foreground: #0a0e08;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #1e2e1a;
--input: #1e2e1a;
--ring: #9caf88;
--sidebar: #141a10;
--sidebar-foreground: #e8eee0;
--sidebar-primary: #9caf88;
--sidebar-primary-foreground: #0a0e08;
--sidebar-accent: #9caf88;
--sidebar-accent-foreground: #0a0e08;
--sidebar-border: #1e2e1a;
--sidebar-ring: #9caf88;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Steel Pink (family: pink/magenta).
Dark anchors: #0e0408 #1a080e #2e0c1a #9a4070 #cc33aa. */
.theme-steel-pink {
--background: #fdf4fa;
--foreground: #2a0a1f;
--card: #fbe8f4;
--card-foreground: #2a0a1f;
--popover: #fbe8f4;
--popover-foreground: #2a0a1f;
--primary: #952382;
--primary-foreground: #ffffff;
--secondary: #f4d4ea;
--secondary-foreground: #2a0a1f;
--muted: #f4d4ea;
--muted-foreground: #7a3058;
--accent: #952382;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #f4d4ea;
--input: #f4d4ea;
--ring: #952382;
--sidebar: #fbe8f4;
--sidebar-foreground: #2a0a1f;
--sidebar-primary: #952382;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #f4d4ea;
--sidebar-accent-foreground: #2a0a1f;
--sidebar-border: #f4d4ea;
--sidebar-ring: #952382;
}
.theme-steel-pink.dark {
--background: #0e0408;
--foreground: #f0d8e8;
--card: #1a080e;
--card-foreground: #f0d8e8;
--popover: #1a080e;
--popover-foreground: #f0d8e8;
--primary: #cc33aa;
--primary-foreground: #0e0408;
--secondary: #2e0c1a;
--secondary-foreground: #f0d8e8;
--muted: #2e0c1a;
--muted-foreground: #9a4070;
--accent: #cc33aa;
--accent-foreground: #0e0408;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #2e0c1a;
--input: #2e0c1a;
--ring: #cc33aa;
--sidebar: #1a080e;
--sidebar-foreground: #f0d8e8;
--sidebar-primary: #cc33aa;
--sidebar-primary-foreground: #0e0408;
--sidebar-accent: #cc33aa;
--sidebar-accent-foreground: #0e0408;
--sidebar-border: #2e0c1a;
--sidebar-ring: #cc33aa;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Volcanic Brown (family: brown/earth).
Dark anchors: #140906 #1e0e0a #2e1610 #7a4030 #cc4a1a. */
.theme-volcanic-brown {
--background: #faf3ee;
--foreground: #2a1410;
--card: #f3e6dc;
--card-foreground: #2a1410;
--popover: #f3e6dc;
--popover-foreground: #2a1410;
--primary: #983614;
--primary-foreground: #ffffff;
--secondary: #e8d4c4;
--secondary-foreground: #2a1410;
--muted: #e8d4c4;
--muted-foreground: #5e2818;
--accent: #983614;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #e8d4c4;
--input: #e8d4c4;
--ring: #983614;
--sidebar: #f3e6dc;
--sidebar-foreground: #2a1410;
--sidebar-primary: #983614;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #e8d4c4;
--sidebar-accent-foreground: #2a1410;
--sidebar-border: #e8d4c4;
--sidebar-ring: #983614;
}
.theme-volcanic-brown.dark {
--background: #140906;
--foreground: #f0e0d4;
--card: #1e0e0a;
--card-foreground: #f0e0d4;
--popover: #1e0e0a;
--popover-foreground: #f0e0d4;
--primary: #cc4a1a;
--primary-foreground: #140906;
--secondary: #2e1610;
--secondary-foreground: #f0e0d4;
--muted: #2e1610;
--muted-foreground: #7a4030;
--accent: #cc4a1a;
--accent-foreground: #140906;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #2e1610;
--input: #2e1610;
--ring: #cc4a1a;
--sidebar: #1e0e0a;
--sidebar-foreground: #f0e0d4;
--sidebar-primary: #cc4a1a;
--sidebar-primary-foreground: #140906;
--sidebar-accent: #cc4a1a;
--sidebar-accent-foreground: #140906;
--sidebar-border: #2e1610;
--sidebar-ring: #cc4a1a;
}

View File

@@ -323,6 +323,10 @@ Full inventory in `boocode_code_review.md`. Headline items:
- **codeprysm rejected** — embedding-based; node/edge taxonomy noted as reference if we ever build our own graph.
- **Batch 9 decoupled from Batch 7 (2026-05-16).** AgentPicker mounts in `ChatInput.tsx` toolbar only. SettingsDrawer agent entry and Header active-agent badge moved to Batch 7. Builtin defaults shipped: six agents (Code Reviewer, Debugger, Refactorer, Architect, Security Auditor, Prompt Builder) with no `model` field — session model wins by default.
## Follow-ups (post-ship docs / cleanup)
- **After v1.8.2 ships:** Add explicit `max_tool_calls: 30` to all 6 agents in `/data/AGENTS.md` and `/opt/boocode/AGENTS.md`. Purely for documentation/discoverability — defaults handle behavior identically (all 6 agents use only read-only tools, default is already 30).
-----
## Workflow

263
docs/themes_v1.md Normal file
View File

@@ -0,0 +1,263 @@
# BooCode — Theme System v1
Standalone BooCode (`/opt/boocode/`). Tailwind v4 + shadcn nova preset. 18 preset themes × 2 modes (dark/light) = 36 palettes. User-selectable in Settings only. Persists to `settings` table.
-----
## 1. Theme list (locked)
| # | id | Display name | Family | Mode default |
|---|---------------------|-------------------|----------------|----------------------|
| 1 | `obsidian` | Obsidian | Charcoal/Black | dark (default theme) |
| 2 | `gunmetal` | Gunmetal | Charcoal/Black | dark |
| 3 | `espresso` | Espresso | Brown/Earth | dark |
| 4 | `volcanic-brown` | Volcanic Brown | Brown/Earth | dark |
| 5 | `copper` | Copper | Orange/Amber | dark |
| 6 | `gold` | Gold | Orange/Amber | dark |
| 7 | `oxblood` | Oxblood | Red/Crimson | dark |
| 8 | `crimson` | Crimson | Red/Crimson | dark |
| 9 | `elderflower` | Elderflower | Purple/Violet | dark |
| 10| `plum` | Plum | Purple/Violet | dark |
| 11| `steel-pink` | Steel Pink | Pink/Magenta | dark |
| 12| `fuchsia-noir` | Fuchsia Noir | Pink/Magenta | dark |
| 13| `matrix` | Matrix | Green | dark |
| 14| `sage` | Sage | Green | dark |
| 15| `ivory` | Ivory | Light | light (always) |
| 16| `chalk` | Chalk | Light | light (always) |
| 17| `cobalt` | Cobalt | Blue | dark |
| 18| `midnight-sapphire` | Midnight Sapphire | Blue | dark |
**Default on first load:** `obsidian` (dark).
**Light variants:** every dark theme ships a paired light variant. `ivory` and `chalk` have no dark variant — they are light-only.
-----
## 2. Storage model
### Schema change
Additive only. In `apps/server/src/schema.sql`:
```sql
ALTER TABLE settings ADD COLUMN IF NOT EXISTS theme_id TEXT NOT NULL DEFAULT 'obsidian';
ALTER TABLE settings ADD COLUMN IF NOT EXISTS theme_mode TEXT NOT NULL DEFAULT 'dark'
CHECK (theme_mode IN ('dark', 'light', 'system'));
```
### API surface
Extend `GET /api/settings` and `PATCH /api/settings`. No new routes.
`GET` response includes `theme_id`, `theme_mode`. `PATCH` accepts both. Validation:
- `theme_id` must be one of the 18 ids listed in section 1.
- `theme_mode``{dark, light, system}`.
- Reject otherwise with 400.
-----
## 3. CSS token layer
Tailwind v4 + shadcn nova uses CSS custom properties.
### File layout
```
apps/web/src/styles/
├── globals.css # existing Tailwind entrypoint
└── themes/
├── obsidian.css
├── gunmetal.css
├── espresso.css
├── volcanic-brown.css
├── copper.css
├── gold.css
├── oxblood.css
├── crimson.css
├── elderflower.css
├── plum.css
├── steel-pink.css
├── fuchsia-noir.css
├── matrix.css
├── sage.css
├── ivory.css
├── chalk.css
├── cobalt.css
└── midnight-sapphire.css
```
Each per-theme file declares `.theme-<id>` (light tokens) and `.theme-<id>.dark` (dark tokens), overriding the shadcn nova CSS variables. `ivory.css` and `chalk.css` declare only the light selector.
`globals.css` imports all 18 theme files after the existing Tailwind/shadcn `@import` lines.
### Tokens overridden per theme
```
--background
--foreground
--card
--card-foreground
--popover
--popover-foreground
--primary
--primary-foreground
--secondary
--secondary-foreground
--muted
--muted-foreground
--accent
--accent-foreground
--destructive
--destructive-foreground
--border
--input
--ring
--sidebar
--sidebar-foreground
--sidebar-primary
--sidebar-primary-foreground
--sidebar-accent
--sidebar-accent-foreground
--sidebar-border
--sidebar-ring
```
`--radius` is locked at `0.5rem` (not per-theme). `--destructive` stays in the red family across all themes — error states are not theme-shifted.
### Anchor-to-token mapping
Five anchor swatches per theme map as follows:
```
background ← anchor 1 (deepest)
card / popover / sidebar ← anchor 2 (surface)
border / input / muted ← anchor 3 (line)
muted-foreground ← anchor 4 (dimmed text)
primary / accent / ring /
sidebar-primary /
sidebar-accent ← anchor 5 (accent)
foreground ← computed: anchor-5 hue, ~92% L, ~25% S
(warm tint for warm themes, cool for cool)
sidebar-foreground ← same as foreground
sidebar-border ← same as border (anchor 3)
sidebar-ring ← same as ring (anchor 5)
destructive ← red family — dark mode: #dc2626,
light mode: #b91c1c
*-foreground variants ← derived high-contrast against parent
```
### Dark anchor values
```
obsidian #0c0c0e #15151a #1f1f23 #6b6b75 #8b5cf6
gunmetal #0d1117 #161b22 #21262d #7d8590 #388bfd
espresso #1c1410 #241a14 #2e2218 #8a7058 #c8a880
volcanic-brown #140906 #1e0e0a #2e1610 #7a4030 #cc4a1a
copper #100800 #1c1408 #2e1f0a #8a6040 #b87333
gold #0e0800 #1a1200 #2a1f00 #a07c30 #d4af37
oxblood #0a0303 #180606 #2a0808 #7a3028 #8b1a1a
crimson #0e0404 #1a0808 #2e0a0a #8a3030 #dc143c
elderflower #100818 #1c1024 #2c1830 #8a78a0 #b89cd8
plum #0c0814 #180e20 #241830 #7a4878 #8e4585
steel-pink #0e0408 #1a080e #2e0c1a #9a4070 #cc33aa
fuchsia-noir #0a0610 #14081a #2a0c2e #8a3878 #ff1493
matrix #000a00 #031403 #0a200a #208030 #00ff41
sage #0a0e08 #141a10 #1e2e1a #7a8870 #9caf88
cobalt #020817 #061434 #0c2244 #3060a0 #0047ab
midnight-sapphire #02050e #060c1f #0e1a36 #4a6088 #1e3a8a
```
### Light-only anchors
`ivory` and `chalk` use these values directly as the light palette:
```
ivory #fdfcf8 #f5f2e8 #e8e4d8 #8a8478 #3a3328
chalk #fafaf7 #f0f0ec #e5e5e0 #75756e #2a2a28
```
### Light variants of the 16 dark themes
- lightest anchor → background
- accent darkens ~15% (reduce HSL lightness by 15 percentage points)
- foreground → near-black tinted toward family hue
- surfaces, borders scale up in lightness symmetrically
-----
## 4. Mode resolution (dark/light/system)
```ts
function resolvedMode(mode: 'dark' | 'light' | 'system'): 'dark' | 'light' {
if (mode === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return mode;
}
```
- If `theme_id` is light-only (`ivory`, `chalk`) and the resolved mode is `dark`, fall back to `obsidian` dark.
- Otherwise apply `<html class="theme-<id> dark?">`.
System-mode listener: subscribe to `matchMedia('(prefers-color-scheme: dark)').addEventListener('change', ...)` only when `theme_mode === 'system'`.
-----
## 5. Frontend wiring
### `apps/web/src/lib/theme.ts` (new)
Exports:
- `THEMES`: const array of `{ id, name, family, supportsDark, supportsLight, anchors }`
- `applyTheme(id, mode)`: writes class to `<html>`, updates localStorage cache
- `useTheme()`: hook reading `theme_id` + `theme_mode` from settings; applies on mount and on change; owns the matchMedia listener (only mounted when mode === 'system')
### `apps/web/src/App.tsx`
Call `useTheme()` at the top of the App component, before children, so the theme is applied before any child renders.
### `apps/web/index.html`
Inline `<script>` BEFORE the React entry that reads `localStorage['boocode.theme']` and sets `className` on `<html>` to prevent FOUC. Cache value: JSON `{ id, mode }`.
### `apps/web/src/pages/Settings.tsx` (new)
Route `/settings`. Layout:
- **Mode radio group** at top — Dark / Light / System (shadcn `radio-group` + `label`).
- **Theme grid** below — 18 cards. `grid-cols-2` mobile, `grid-cols-3` md+.
- Each card: shadcn `Card` containing theme name (font-mono, sm), family label (xs, muted), 5-swatch horizontal strip from `THEMES[i].anchors`, "Selected" badge if active, "Light only" hint on `ivory`/`chalk`.
- Click card → PATCH `/api/settings` → on 200, `applyTheme()` + update localStorage cache. Optimistic; revert on failure with toast.
- Mode radio change → same PATCH path.
### `apps/web/src/components/ui/`
Required shadcn primitives (verified present): `card`, `button`, `radio-group`, `label`.
-----
## 6. Build / verification
- `pnpm typecheck` (must pass)
- `pnpm -F web build` (must pass)
- Schema migration: `psql ... -c "\d settings"` shows `theme_id` and `theme_mode`.
- API: `curl GET /api/settings` returns new fields; `PATCH` accepts them; invalid id → 400.
- Visual: cycle themes, toggle mode, system follows OS, reload persists.
-----
## 7. Out of scope (v1)
- Custom user-defined palettes.
- Per-project or per-session themes (global only).
- Syntax-highlighting themes for `CodeBlock`.
- Header quick-switcher dropdown (Settings only).
-----
## 8. Decisions on ambiguous points
1. **Light-only theme + dark mode request:** fall back to `obsidian` dark. No inline message.
2. **Font per theme:** locked at Inter + JetBrains Mono. No theme changes typography in v1.
3. **Animation on swap:** instant class change. No CSS transitions on `--background` (they cause flicker on first paint).