Compare commits

...

16 Commits

Author SHA1 Message Date
adb5d7b3bb Merge v1.9-skills: skills + /skill slash command 2026-05-18 01:52:15 +00:00
80fd3d9fa9 feat(web): /skill slash command with autocomplete
Trigger /<name>, dropdown lists all skills filtered by name prefix,
arg passthrough sends the rest as the user message. Synthetic
skill_use tool_use renders identically to model-invoked skills.
2026-05-18 01:10:51 +00:00
eaacd432e8 feat(web): skills API types + client methods 2026-05-18 01:10:51 +00:00
529a77c959 feat(server): skills v1 — parser, tools, /api/skills, mount
- /data/skills mount (host: /opt/skills)
- skill_find, skill_use, skill_resource added to default read-only
  tool set; opt-in for agents with explicit tools: whitelist
- AGENTS.md builtin agents drop explicit tools: arrays to inherit
  the new default (now includes skill tools)
- POST /api/chats/:id/skill_invoke for slash-command flow
- 19 SKILL.md files seeded at /opt/skills/ across 6 source groups
2026-05-18 01:10:51 +00:00
9a7b35b677 build: harden .dockerignore (secrets/, data/)
The host-side docker-compose mounts secrets/ and data/ read-only at
runtime, but the build context still slurped them in. Add secrets/,
data/, and general SSH key patterns (*.pem, *.key, id_rsa*,
id_ed25519*, known_hosts, .ssh/) so private material can never be
baked into the image even by accident.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:50:37 +00:00
98b432ebce refactor: drop type-to-confirm gate on chat delete
The chat-delete dialog required typing the chat name to confirm
deletion. Single-user app — typing friction is annoying, not safety.
Match the archive dialog pattern in SettingsPane.tsx: title +
description naming the chat in mono font, plain Cancel + destructive
Delete button.

Removes the deleteInput state, deleteExpected / deleteEnabled
deriveds, the <Input> field, and its lone <Input> import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:50:30 +00:00
1ecccc112f fix: settings pane close affordance + sidebar toggle
The v1.9 settings pane had no way to dismiss once opened. ChatTabBar
(which owns the per-pane close X for chat panes) is skipped for
settings panes, and the pane header itself only rendered the maximize
toggle (desktop-only). Mobile users had zero controls beyond the
section tabs.

Add three close paths:

- X button in SettingsPane header, visible on mobile + desktop, sits
  next to the maximize toggle. Tap-target sized per the v1.6 mobile
  convention (max-md:min-h-[44px]).
- Esc when the settings pane is the active pane and no input/textarea/
  dialog has focus. Maximize-restore still wins when maximized.
- Sidebar Settings button is now a strict toggle: opens on first click,
  closes on second. Renamed openOrFocusSettingsPane →
  toggleSettingsPane in the panes hook.

Edge case: removing the settings pane when it's the only pane left
falls back to an empty pane to preserve the "always one pane"
invariant. In normal flow this is unreachable (the toggle only
appends), but defensive against future entry points.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:50:25 +00:00
b6469055d8 docs: reconcile roadmap with merged state
v1.8.3 (tool-call compaction), themes-v1, v1.9 (settings pane +
per-project defaults + bulk archive), and v1.11 (agents Tier 2) were
all marked Planned/in-flight in the roadmap despite being merged on
main. Reconcile the Batch summary table and reorder the Order of
operations to start at v1.10. Drop the stale "Active work" section —
themes-v1 description belongs in the past tense now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:50:16 +00:00
4bf2cd40c3 Merge v1.9 2026-05-17 17:37:38 +00:00
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
71 changed files with 5319 additions and 582 deletions

View File

@@ -10,3 +10,13 @@ dist
.vite .vite
coverage coverage
/tmp /tmp
# Secrets and runtime data
secrets/
data/
*.pem
*.key
id_rsa*
id_ed25519*
known_hosts
.ssh/

1
.gitignore vendored
View File

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

View File

@@ -3,7 +3,6 @@
## Code Reviewer ## Code Reviewer
--- ---
temperature: 0.3 temperature: 0.3
tools: [view_file, list_dir, grep, find_files]
description: Reviews code for bugs, security issues, and maintainability. Read-only. description: Reviews code for bugs, security issues, and maintainability. Read-only.
--- ---
You review code. Find real problems, not style nits. You review code. Find real problems, not style nits.
@@ -33,7 +32,6 @@ If nothing critical or major, say so in one line. Do not pad.
## Debugger ## Debugger
--- ---
temperature: 0.2 temperature: 0.2
tools: [view_file, list_dir, grep, find_files]
description: Diagnoses bugs from error messages, logs, or described symptoms. description: Diagnoses bugs from error messages, logs, or described symptoms.
--- ---
You diagnose bugs. Form a hypothesis, prove it with evidence from the code. You diagnose bugs. Form a hypothesis, prove it with evidence from the code.
@@ -62,7 +60,6 @@ Output:
## Refactorer ## Refactorer
--- ---
temperature: 0.3 temperature: 0.3
tools: [view_file, list_dir, grep, find_files]
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits. description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
--- ---
You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code. You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code.
@@ -95,7 +92,6 @@ Output:
## Architect ## Architect
--- ---
temperature: 0.5 temperature: 0.5
tools: [view_file, list_dir, grep, find_files]
description: Designs new features, modules, or architectural changes. Outputs a build plan. description: Designs new features, modules, or architectural changes. Outputs a build plan.
--- ---
You design. You produce build plans, not code. You design. You produce build plans, not code.
@@ -128,7 +124,6 @@ Output:
## Security Auditor ## Security Auditor
--- ---
temperature: 0.2 temperature: 0.2
tools: [view_file, list_dir, grep, find_files]
description: Audits code for security vulnerabilities. Read-only. description: Audits code for security vulnerabilities. Read-only.
--- ---
You audit for security issues. Concrete findings only, no generic warnings. You audit for security issues. Concrete findings only, no generic warnings.
@@ -165,7 +160,6 @@ If the code is clean, say so. Do not invent findings.
## Prompt Builder ## Prompt Builder
--- ---
temperature: 0.4 temperature: 0.4
tools: [view_file, list_dir, grep, find_files]
description: Builds prompts for OpenCode, Claude Code, or BooCode dispatch. description: Builds prompts for OpenCode, Claude Code, or BooCode dispatch.
--- ---
You write prompts that another coding agent will execute. Your output is the prompt, not the work. You write prompts that another coding agent will execute. Your output is the prompt, not the work.

View File

@@ -15,8 +15,10 @@ import { registerSidebarRoutes } from './routes/sidebar.js';
import { registerWebSocket } from './routes/ws.js'; import { registerWebSocket } from './routes/ws.js';
import { registerModelRoutes } from './routes/models.js'; import { registerModelRoutes } from './routes/models.js';
import { registerAgentRoutes } from './routes/agents.js'; import { registerAgentRoutes } from './routes/agents.js';
import { registerSkillsRoutes } from './routes/skills.js';
import { createInferenceRunner } from './services/inference.js'; import { createInferenceRunner } from './services/inference.js';
import { createBroker } from './services/broker.js'; import { createBroker } from './services/broker.js';
import { listSkills } from './services/skills.js';
async function main() { async function main() {
const config = loadConfig(); const config = loadConfig();
@@ -62,6 +64,15 @@ async function main() {
registerSidebarRoutes(app, sql); registerSidebarRoutes(app, sql);
registerChatRoutes(app, sql, broker); registerChatRoutes(app, sql, broker);
// Batch 9.6: warm the skills cache at boot and surface the count. Empty or
// missing /data/skills is non-fatal — the skill tools just return empty.
try {
const skills = await listSkills();
app.log.info(`skills loaded: ${skills.length}`);
} catch (err) {
app.log.warn({ err }, 'skills boot walk failed');
}
const inference = createInferenceRunner( const inference = createInferenceRunner(
{ {
sql, sql,
@@ -113,6 +124,33 @@ async function main() {
}); });
}, },
}); });
registerSkillsRoutes(app, sql, {
enqueueInference: (sessionId, chatId, assistantId, user) => {
inference.enqueue(sessionId, chatId, assistantId, user);
},
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
broker.publish(sessionId, {
type: 'message_started',
message_id: userMessageId,
chat_id: chatId,
role: 'user',
});
broker.publish(sessionId, {
type: 'delta',
message_id: userMessageId,
chat_id: chatId,
content,
});
broker.publish(sessionId, {
type: 'message_complete',
message_id: userMessageId,
chat_id: chatId,
});
},
publishSessionFrame: (sessionId, frame) => {
broker.publish(sessionId, frame);
},
});
registerWebSocket(app, sql, broker); registerWebSocket(app, sql, broker);
const webDist = process.env.WEB_DIST_PATH ?? resolve(process.cwd(), '../web/dist'); const webDist = process.env.WEB_DIST_PATH ?? resolve(process.cwd(), '../web/dist');

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 } }>( app.post<{ Params: { id: string } }>(
'/api/chats/:id/archive', '/api/chats/:id/archive',
async (req, reply) => { async (req, reply) => {
@@ -231,7 +278,7 @@ export function registerChatRoutes(
INSERT INTO messages ( INSERT INTO messages (
session_id, chat_id, role, content, kind, tool_calls, tool_results, session_id, chat_id, role, content, kind, tool_calls, tool_results,
status, tokens_used, ctx_used, ctx_max, started_at, finished_at, status, tokens_used, ctx_used, ctx_max, started_at, finished_at,
created_at created_at, metadata
) )
SELECT SELECT
${source.session_id}, ${chat!.id}, role, content, kind, ${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, tokens_used, ctx_used, ctx_max, started_at, finished_at,
clock_timestamp() + ( clock_timestamp() + (
ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) * INTERVAL '1 microsecond' ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) * INTERVAL '1 microsecond'
) ),
metadata
FROM messages FROM messages
WHERE chat_id = ${source.id} WHERE chat_id = ${source.id}
AND created_at <= ${target.created_at}::timestamptz AND created_at <= ${target.created_at}::timestamptz
@@ -268,7 +316,7 @@ export function registerChatRoutes(
} }
const rows = await sql<Message[]>` const rows = await sql<Message[]>`
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq, SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata
FROM messages FROM messages
WHERE chat_id = ${req.params.id} WHERE chat_id = ${req.params.id}
ORDER BY created_at ASC, id ASC ORDER BY created_at ASC, id ASC

View File

@@ -7,6 +7,13 @@ const SendBody = z.object({
content: z.string().min(1).max(64_000), 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 { interface MessageHandlers {
enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void; enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
enqueueCompact: (sessionId: string, chatId: string, compactMessageId: 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[]>` const rows = await sql<Message[]>`
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq, SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata
FROM messages FROM messages
WHERE session_id = ${req.params.id} WHERE session_id = ${req.params.id}
ORDER BY created_at ASC, id ASC ORDER BY created_at ASC, id ASC
@@ -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 } }>( app.post<{ Params: { id: string } }>(
'/api/chats/:id/force_send', '/api/chats/:id/force_send',
async (req, reply) => { async (req, reply) => {

View File

@@ -22,8 +22,14 @@ const AddProjectBody = z.object({
name: z.string().min(1).optional(), 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({ 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({ const CreateProjectBody = z.object({
@@ -70,7 +76,8 @@ export function registerProjectRoutes(
app.get<{ Querystring: { status?: string } }>('/api/projects', async (req) => { app.get<{ Querystring: { status?: string } }>('/api/projects', async (req) => {
const status = req.query.status === 'archived' ? 'archived' : 'open'; const status = req.query.status === 'archived' ? 'archived' : 'open';
const rows = await sql<Project[]>` 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 FROM projects
WHERE status = ${status} WHERE status = ${status}
ORDER BY added_at DESC ORDER BY added_at DESC
@@ -119,7 +126,8 @@ export function registerProjectRoutes(
const [row] = await sql<Project[]>` const [row] = await sql<Project[]>`
INSERT INTO projects (name, path, gitea_remote) INSERT INTO projects (name, path, gitea_remote)
VALUES (${parsed.data.name}, ${bootstrap.folder_real_path}, ${bootstrap.gitea_remote_url}) 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 }); broker.publishUser('default', { type: 'project_created', project: row as unknown as Project });
reply.code(201); reply.code(201);
@@ -173,7 +181,8 @@ export function registerProjectRoutes(
INSERT INTO projects (name, path) INSERT INTO projects (name, path)
VALUES (${name}, ${resolved.real}) VALUES (${name}, ${resolved.real})
ON CONFLICT (path) DO UPDATE SET status = 'open' 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) { if (existing.length === 0) {
@@ -187,22 +196,53 @@ export function registerProjectRoutes(
return row; 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) => { app.patch<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => {
const parsed = PatchProjectBody.safeParse(req.body); const parsed = PatchProjectBody.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
reply.code(400); reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() }; 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[]>` 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} 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) { if (rows.length === 0) {
reply.code(404); reply.code(404);
return { error: 'not found' }; return { error: 'not found' };
} }
const project = rows[0]!; 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', { broker.publishUser('default', {
type: 'project_updated', type: 'project_updated',
project_id: project.id, project_id: project.id,
@@ -229,7 +269,8 @@ export function registerProjectRoutes(
const rows = await sql<Project[]>` const rows = await sql<Project[]>`
UPDATE projects SET status = 'open' UPDATE projects SET status = 'open'
WHERE id = ${req.params.id} AND status = 'archived' 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) { if (rows.length === 0) {
reply.code(404); reply.code(404);

View File

@@ -19,6 +19,8 @@ const PatchBody = z.object({
model: z.string().min(1).max(200).optional(), model: z.string().min(1).max(200).optional(),
system_prompt: z.string().max(8000).optional(), system_prompt: z.string().max(8000).optional(),
agent_id: z.string().min(1).max(200).nullable().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> { 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 status = req.query.status === 'archived' ? 'archived' : 'open';
const rows = await sql<Session[]>` 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 FROM sessions
WHERE project_id = ${req.params.id} AND status = ${status} WHERE project_id = ${req.params.id} AND status = ${status}
ORDER BY updated_at DESC ORDER BY updated_at DESC
@@ -100,7 +102,7 @@ export function registerSessionRoutes(
const [session] = await tx<Session[]>` const [session] = await tx<Session[]>`
INSERT INTO sessions (project_id, name, model, system_prompt, agent_id) INSERT INTO sessions (project_id, name, model, system_prompt, agent_id)
VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt}, ${agentId}) 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` await tx`
INSERT INTO chats (session_id, name, status) 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) => { app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
const rows = await sql<Session[]>` 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} FROM sessions WHERE id = ${req.params.id}
`; `;
if (rows.length === 0) { if (rows.length === 0) {
@@ -139,10 +141,13 @@ export function registerSessionRoutes(
return { error: 'invalid body', details: parsed.error.flatten() }; return { error: 'invalid body', details: parsed.error.flatten() };
} }
const { name, model, system_prompt } = parsed.data; const { name, model, system_prompt } = parsed.data;
// agent_id is tri-state on the wire: omitted = no change, null = clear, // agent_id and web_search_enabled are both tri-state on the wire: omitted
// string = set. CASE WHEN inside SET handles all three atomically. // = no change, null = clear/inherit, value = set. CASE WHEN inside SET
// handles all three atomically.
const agentIdProvided = parsed.data.agent_id !== undefined; const agentIdProvided = parsed.data.agent_id !== undefined;
const newAgentId = parsed.data.agent_id ?? null; 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 // 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 // (PATCH { name: "Foo" } where the session is already "Foo"). The window
// between SELECT and UPDATE is sub-millisecond in the same request handler; // between SELECT and UPDATE is sub-millisecond in the same request handler;
@@ -159,9 +164,11 @@ export function registerSessionRoutes(
model = COALESCE(${model ?? null}, model), model = COALESCE(${model ?? null}, model),
system_prompt = COALESCE(${system_prompt ?? null}, system_prompt), system_prompt = COALESCE(${system_prompt ?? null}, system_prompt),
agent_id = CASE WHEN ${agentIdProvided} THEN ${newAgentId} ELSE agent_id END, 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() updated_at = clock_timestamp()
WHERE id = ${req.params.id} 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) { if (rows.length === 0) {
reply.code(404); reply.code(404);
@@ -175,10 +182,69 @@ export function registerSessionRoutes(
name: session.name, 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; 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 } }>( app.post<{ Params: { id: string } }>(
'/api/sessions/:id/archive', '/api/sessions/:id/archive',
async (req, reply) => { async (req, reply) => {
@@ -207,7 +273,7 @@ export function registerSessionRoutes(
const rows = await sql<Session[]>` const rows = await sql<Session[]>`
UPDATE sessions SET status = 'open', updated_at = clock_timestamp() UPDATE sessions SET status = 'open', updated_at = clock_timestamp()
WHERE id = ${req.params.id} AND status = 'archived' 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) { if (rows.length === 0) {
reply.code(404); 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()); const PatchBody = z.record(z.string(), z.unknown());
export function registerSettingsRoutes(app: FastifyInstance, sql: Sql): void { export function registerSettingsRoutes(app: FastifyInstance, sql: Sql): void {
@@ -38,6 +82,11 @@ export function registerSettingsRoutes(app: FastifyInstance, sql: Sql): void {
reply.code(400); reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() }; 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)) { for (const [k, v] of Object.entries(parsed.data)) {
await setSetting(sql, k, v); await setSetting(sql, k, v);
} }

View File

@@ -0,0 +1,156 @@
import { randomUUID } from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Chat } from '../types/api.js';
import { getSkillBody, listSkills } from '../services/skills.js';
// Batch 9.6 slash-invoke handlers. Mirrors the MessageHandlers shape in
// routes/messages.ts so index.ts can pass thin adapters around broker +
// inference runner without skills.ts importing them directly.
export interface SkillInvokeHandlers {
enqueueInference: (
sessionId: string,
chatId: string,
assistantMessageId: string,
user: string,
) => void;
publishUserMessage: (
sessionId: string,
chatId: string,
userMessageId: string,
content: string,
) => void;
publishSessionFrame: (
sessionId: string,
frame: Record<string, unknown> & { type: string },
) => void;
}
const SkillInvokeBody = z.object({
skill_name: z.string().min(1),
// Optional — server fills in a default if absent or whitespace-only so the
// model always has something to act on (matches the spec's "Apply this
// skill." filler).
user_message: z.string().max(64_000).nullable().optional(),
});
const DEFAULT_USER_MESSAGE = 'Apply this skill.';
export function registerSkillsRoutes(
app: FastifyInstance,
sql: Sql,
handlers: SkillInvokeHandlers,
): void {
// Debug/admin surface — the model interacts with skills via the three
// skill_* tools, not through this endpoint.
app.get('/api/skills', async () => {
return { skills: await listSkills() };
});
// POST /api/chats/:id/skill_invoke — slash-command entry point. Loads the
// skill body server-side (clients never get to forge file content),
// persists 4 messages in one transaction (synthetic assistant tool_use,
// synthetic tool result, real user message, streaming assistant), and
// enqueues inference against the updated history.
app.post<{ Params: { id: string } }>(
'/api/chats/:id/skill_invoke',
async (req, reply) => {
const parsed = SkillInvokeBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { skill_name } = parsed.data;
const userText = parsed.data.user_message?.trim() ? parsed.data.user_message : DEFAULT_USER_MESSAGE;
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;
const body = await getSkillBody(skill_name);
if (body === null) {
reply.code(404);
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` };
}
const toolCallId = randomUUID();
const toolCalls = [{ id: toolCallId, name: 'skill_use', args: { name: skill_name } }];
const toolResults = { tool_call_id: toolCallId, output: body, truncated: false };
const result = await sql.begin(async (tx) => {
const [synthAssistant] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, tool_calls, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'assistant', '', ${sql.json(toolCalls as never)}, 'complete', clock_timestamp())
RETURNING id
`;
const [toolMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, tool_results, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'tool', '', ${sql.json(toolResults as never)}, 'complete', clock_timestamp())
RETURNING id
`;
const [userMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'user', ${userText}, 'complete', clock_timestamp())
RETURNING id
`;
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 {
synth_assistant_id: synthAssistant!.id,
tool_message_id: toolMsg!.id,
user_message_id: userMsg!.id,
assistant_message_id: assistantMsg!.id,
};
});
// Synthetic frames so useSessionStream's reducer reflects the new
// history without a refetch. Frame shapes match the streaming-inference
// protocol (see services/inference.ts InferenceFrame).
handlers.publishSessionFrame(sessionId, {
type: 'message_started',
message_id: result.synth_assistant_id,
chat_id: chat.id,
role: 'assistant',
});
handlers.publishSessionFrame(sessionId, {
type: 'tool_call',
message_id: result.synth_assistant_id,
chat_id: chat.id,
tool_call: toolCalls[0]!,
});
handlers.publishSessionFrame(sessionId, {
type: 'message_complete',
message_id: result.synth_assistant_id,
chat_id: chat.id,
});
// The tool_result frame's reducer branch creates the tool-role message
// in-place when it doesn't already exist — no separate message_started
// is needed for the tool side.
handlers.publishSessionFrame(sessionId, {
type: 'tool_result',
tool_message_id: result.tool_message_id,
tool_call_id: toolCallId,
chat_id: chat.id,
output: body,
truncated: false,
});
handlers.publishUserMessage(sessionId, chat.id, result.user_message_id, userText);
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
reply.code(202);
return result;
},
);
}

View File

@@ -23,7 +23,7 @@ export function registerWebSocket(
const messages = await sql<Message[]>` const messages = await sql<Message[]>`
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq, SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata
FROM messages FROM messages
WHERE session_id = ${sessionId} WHERE session_id = ${sessionId}
ORDER BY created_at ASC, id ASC 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. -- 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". -- agent_id is the slugified agent name. NULL means "use BooCode defaults".
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS agent_id TEXT; 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', status: 'open',
created_at: new Date(0).toISOString(), created_at: new Date(0).toISOString(),
updated_at: new Date(0).toISOString(), updated_at: new Date(0).toISOString(),
agent_id: null,
web_search_enabled: null,
...overrides, ...overrides,
}; };
} }
@@ -34,6 +36,8 @@ function makeProject(overrides: Partial<Project> = {}): Project {
last_session_id: null, last_session_id: null,
status: 'open', status: 'open',
gitea_remote: null, gitea_remote: null,
default_system_prompt: '',
default_web_search_enabled: false,
...overrides, ...overrides,
}; };
} }
@@ -62,6 +66,7 @@ function makeMessage(
started_at: null, started_at: null,
finished_at: null, finished_at: null,
created_at: new Date(counter * 1000).toISOString(), created_at: new Date(counter * 1000).toISOString(),
metadata: null,
...overrides, ...overrides,
}; };
} }

View File

@@ -11,7 +11,14 @@ const GLOBAL_AGENTS_PATH = '/data/AGENTS.md';
const CACHE_TTL_MS = 60_000; const CACHE_TTL_MS = 60_000;
// Tools whitelist universe matches services/tools.ts ALL_TOOLS. Keep in sync. // Tools whitelist universe matches services/tools.ts ALL_TOOLS. Keep in sync.
const ALL_TOOL_NAMES = ['view_file', 'list_dir', 'grep', 'find_files', 'git_status'] as const; // Batch 9.6: skill_find / skill_use / skill_resource added. Agents without an
// explicit `tools:` field inherit the full default set (which now includes
// the skill tools); agents with an explicit `tools:` array must list any
// skill tool they want to use — strict opt-in.
const ALL_TOOL_NAMES = [
'view_file', 'list_dir', 'grep', 'find_files', 'git_status',
'skill_find', 'skill_use', 'skill_resource',
] as const;
const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES]; const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
const DEFAULT_TEMPERATURE = 0.7; const DEFAULT_TEMPERATURE = 0.7;
@@ -29,6 +36,9 @@ interface ParsedFrontmatter {
tools?: string[]; tools?: string[];
description?: string; description?: string;
model?: 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 { function stripQuotes(s: string): string {
@@ -89,6 +99,21 @@ function parseFrontmatter(yaml: string): { data: ParsedFrontmatter; errors: stri
data.description = stripQuotes(valueRaw); data.description = stripQuotes(valueRaw);
} else if (key === 'model') { } else if (key === 'model') {
data.model = stripQuotes(valueRaw); 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. // Unknown keys silently ignored — forward-compat.
} }
@@ -177,6 +202,7 @@ function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
temperature: typeof fm.temperature === 'number' ? fm.temperature : DEFAULT_TEMPERATURE, temperature: typeof fm.temperature === 'number' ? fm.temperature : DEFAULT_TEMPERATURE,
tools: filteredTools, tools: filteredTools,
model: typeof fm.model === 'string' && fm.model.length > 0 ? fm.model : null, 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 { FastifyBaseLogger } from 'fastify';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import type { Config } from '../config.js'; import type { Config } from '../config.js';
import type { Agent, Message, Project, Session, ToolCall, UserStreamFrame } from '../types/api.js'; import type {
import { ALL_TOOLS, TOOLS_BY_NAME, toolJsonSchemas, type ToolJsonSchema } from './tools.js'; 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 { PathScopeError, resolveProjectRoot } from './path_guard.js';
import { maybeAutoNameChat } from './auto_name.js'; import { maybeAutoNameChat } from './auto_name.js';
import { getAgentById } from './agents.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.`; `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 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 { export interface InferenceFrame {
type: type:
@@ -29,12 +76,22 @@ export interface InferenceFrame {
chat_id?: string; chat_id?: string;
tool_message_id?: string; tool_message_id?: string;
tool_call_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; content?: string;
tool_call?: ToolCall; tool_call?: ToolCall;
output?: unknown; output?: unknown;
truncated?: boolean; truncated?: boolean;
error?: string; 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; tokens_used?: number | null;
ctx_used?: number | null; ctx_used?: number | null;
ctx_max?: number | null; ctx_max?: number | null;
@@ -92,9 +149,11 @@ export interface InferenceContext {
publishUser: (frame: UserStreamFrame) => void; publishUser: (frame: UserStreamFrame) => void;
} }
// Resolution order: base prompt < agent.system_prompt < session.system_prompt. // Resolution order: base prompt < agent.system_prompt < user prompt, where
// Agent prompts layer on top of the base; session prompt is the most specific // user prompt = session.system_prompt if non-empty, else project's
// override and stacks last so callers can append per-session instructions. // 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( export function buildSystemPrompt(
project: Project, project: Project,
session: Session, session: Session,
@@ -104,8 +163,11 @@ export function buildSystemPrompt(
if (agent && agent.system_prompt.trim().length > 0) { if (agent && agent.system_prompt.trim().length > 0) {
out += '\n\n' + agent.system_prompt.trim(); out += '\n\n' + agent.system_prompt.trim();
} }
if (session.system_prompt && session.system_prompt.trim().length > 0) { const sessionPrompt = session.system_prompt?.trim() ?? '';
out += '\n\n' + 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; return out;
} }
@@ -135,6 +197,11 @@ export function buildMessagesPayload(
out.push({ role: 'system', content: m.content }); out.push({ role: 'system', content: m.content });
continue; 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 === 'streaming') continue;
if (m.role === 'assistant' && m.status === 'cancelled') continue; if (m.role === 'assistant' && m.status === 'cancelled') continue;
if (m.role === 'tool') { if (m.role === 'tool') {
@@ -178,14 +245,16 @@ async function loadContext(
chatId: string chatId: string
): Promise<{ session: Session; project: Project; history: Message[] } | null> { ): Promise<{ session: Session; project: Project; history: Message[] } | null> {
const sessionRows = await sql<Session[]>` 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} FROM sessions WHERE id = ${sessionId}
`; `;
if (sessionRows.length === 0) return null; if (sessionRows.length === 0) return null;
const session = sessionRows[0]!; const session = sessionRows[0]!;
const projectRows = await sql<Project[]>` 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} FROM projects WHERE id = ${session.project_id}
`; `;
if (projectRows.length === 0) return null; if (projectRows.length === 0) return null;
@@ -193,7 +262,7 @@ async function loadContext(
const history = await sql<Message[]>` const history = await sql<Message[]>`
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq, SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata
FROM messages FROM messages
WHERE chat_id = ${chatId} WHERE chat_id = ${chatId}
ORDER BY created_at ASC, id ASC ORDER BY created_at ASC, id ASC
@@ -379,7 +448,10 @@ interface TurnArgs {
sessionId: string; sessionId: string;
chatId: string; chatId: string;
assistantMessageId: 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; signal: AbortSignal | undefined;
} }
@@ -480,13 +552,32 @@ async function handleAbortOrError(
const { sessionId, chatId, assistantMessageId } = args; const { sessionId, chatId, assistantMessageId } = args;
const isAbort = err instanceof Error && err.name === 'AbortError'; const isAbort = err instanceof Error && err.name === 'AbortError';
const finalStatus = isAbort ? 'cancelled' : 'failed'; const finalStatus = isAbort ? 'cancelled' : 'failed';
await ctx.sql` const errMsg = err instanceof Error ? err.message : String(err);
UPDATE messages // v1.8.2: persist a structured error metadata blob on genuine failures so
SET status = ${finalStatus}, // the bubble can render the reason on reload without re-deriving from the
content = ${accumulated}, // (one-shot) WS error frame. User-initiated abort skips this — there's no
finished_at = clock_timestamp() // "reason" to surface for a stop the user already explicitly chose.
WHERE id = ${assistantMessageId} 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 }[]>` const [failSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>`
UPDATE sessions SET updated_at = clock_timestamp() UPDATE sessions SET updated_at = clock_timestamp()
WHERE id = ${sessionId} 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 }); 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; // v1.8 mobile-tabs: cancellation is a user-initiated stop, treat as idle;
// genuine errors flip the dot red. // genuine errors flip the dot red. v1.8.2: error path also carries a
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: isAbort ? 'idle' : 'error', at: new Date().toISOString() }); // machine-readable `reason` so the UI can render specifics inline.
if (isAbort) { if (isAbort) {
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() });
ctx.publish(sessionId, { ctx.publish(sessionId, {
type: 'message_complete', type: 'message_complete',
message_id: assistantMessageId, message_id: assistantMessageId,
@@ -504,12 +596,19 @@ async function handleAbortOrError(
}); });
ctx.log.info({ sessionId, chatId, assistantMessageId }, 'inference cancelled'); ctx.log.info({ sessionId, chatId, assistantMessageId }, 'inference cancelled');
} else { } 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, { ctx.publish(sessionId, {
type: 'error', type: 'error',
message_id: assistantMessageId, message_id: assistantMessageId,
chat_id: chatId, chat_id: chatId,
error: errMsg, error: errMsg,
reason: 'llm_provider_error',
}); });
ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed'); ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed');
} }
@@ -523,7 +622,7 @@ async function executeToolPhase(
session: Session, session: Session,
projectRoot: string projectRoot: string
): Promise<void> { ): Promise<void> {
const { sessionId, chatId, assistantMessageId, depth, signal } = args; const { sessionId, chatId, assistantMessageId, toolsUsed, signal } = args;
const { content, toolCalls, promptTokens, completionTokens, nCtx } = result; const { content, toolCalls, promptTokens, completionTokens, nCtx } = result;
const [updated] = await ctx.sql< const [updated] = await ctx.sql<
@@ -607,7 +706,10 @@ async function executeToolPhase(
sessionId, sessionId,
chatId, chatId,
assistantMessageId: nextAssistant!.id, 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, signal,
}); });
} }
@@ -671,25 +773,7 @@ async function runAssistantTurn(
ctx: InferenceContext, ctx: InferenceContext,
args: TurnArgs, args: TurnArgs,
): Promise<void> { ): Promise<void> {
const { sessionId, chatId, assistantMessageId, depth } = args; const { sessionId, chatId } = 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 loaded = await loadContext(ctx.sql, sessionId, chatId); const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (!loaded) { if (!loaded) {
@@ -704,6 +788,17 @@ async function runAssistantTurn(
const agent = session.agent_id const agent = session.agent_id
? await getAgentById(project.path, session.agent_id) ? await getAgentById(project.path, session.agent_id)
: null; : 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 messages = buildMessagesPayload(session, project, history, agent);
const state: StreamPhaseState = { accumulated: '', startedAt: null }; const state: StreamPhaseState = { accumulated: '', startedAt: null };
@@ -730,7 +825,264 @@ export async function runInference(
assistantMessageId: string, assistantMessageId: string,
signal?: AbortSignal signal?: AbortSignal
): Promise<void> { ): 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 = const COMPACT_SYSTEM_PROMPT =

View File

@@ -0,0 +1,321 @@
import { promises as fs } from 'node:fs';
import { join, isAbsolute, basename } from 'node:path';
import { pathGuard, PathScopeError } from './path_guard.js';
// Batch 9.6: read-only skill library. Folders under /data/skills/<group>/<skill>/
// contain a SKILL.md with YAML frontmatter (name + description) and a markdown
// body. Three tools expose the library: skill_find (search), skill_use (load
// body), skill_resource (read a support file inside the folder).
//
// Layout is intentionally uniform — scan /data/skills/*/*/SKILL.md at fixed
// depth 3. Group folders (depth 1) hold LICENSE + ATTRIBUTION.md + skill
// subfolders and are NOT themselves skills. Support files inside skill
// folders are reachable via skill_resource, never auto-parsed.
//
// Cache model mirrors agents.ts: walk on first access, TTL re-walk to pick up
// new skills, per-entry mtime check on body access so a hot-edited SKILL.md
// is re-read without a restart. No watcher.
const SKILLS_ROOT = '/data/skills';
const MAX_RESOURCE_BYTES = 5 * 1024 * 1024;
const LIST_CACHE_TTL_MS = 60_000;
export interface Skill {
name: string;
description: string;
path: string;
mtime: number;
}
interface CachedSkill extends Skill {
body: string;
}
const cache = new Map<string, CachedSkill>();
let lastWalkedAt = 0;
// ---- Frontmatter parser ----------------------------------------------------
// Minimal `---\n...\n---` extractor. Only `name` and `description` keys are
// honored; other frontmatter keys are silently ignored for forward-compat
// with the anthropics/skills upstream spec.
interface Frontmatter {
name?: string;
description?: string;
}
function stripQuotes(s: string): string {
if (s.length >= 2 && (s[0] === '"' || s[0] === "'") && s[0] === s[s.length - 1]) {
return s.slice(1, -1);
}
return s;
}
function parseFrontmatter(yaml: string): Frontmatter {
const fm: Frontmatter = {};
for (const raw of yaml.split('\n')) {
const line = raw.trim();
if (line.length === 0) continue;
const colon = line.indexOf(':');
if (colon < 0) continue;
const key = line.slice(0, colon).trim();
const val = stripQuotes(line.slice(colon + 1).trim());
if (key === 'name') fm.name = val;
else if (key === 'description') fm.description = val;
}
return fm;
}
interface ParsedSkillFile {
name: string;
description: string;
body: string;
}
function parseSkillFile(content: string): ParsedSkillFile {
const lines = content.split('\n');
let openIdx = -1;
for (let i = 0; i < lines.length; i++) {
const t = lines[i]!.trim();
if (t === '') continue;
if (t === '---') openIdx = i;
break;
}
if (openIdx < 0) throw new Error('missing opening --- fence');
let closeIdx = -1;
for (let i = openIdx + 1; i < lines.length; i++) {
if (lines[i]!.trim() === '---') { closeIdx = i; break; }
}
if (closeIdx < 0) throw new Error('missing closing --- fence');
const yamlText = lines.slice(openIdx + 1, closeIdx).join('\n');
const body = lines.slice(closeIdx + 1).join('\n');
const fm = parseFrontmatter(yamlText);
if (!fm.name) throw new Error('frontmatter missing name');
if (!fm.description) throw new Error('frontmatter missing description');
return { name: fm.name, description: fm.description, body };
}
// ---- Tree walk -------------------------------------------------------------
// Fixed depth-3 scan: /data/skills/<group>/<skill>/SKILL.md. Two layers of
// readdir, no recursion. Group folders without SKILL.md are skipped silently;
// LICENSE / ATTRIBUTION.md / other non-SKILL.md files are ignored entirely.
// Returns all parseable skills as-found — dedup + collision logging happens
// in ensureCache where the sort order is established.
async function walkSkills(root: string): Promise<CachedSkill[]> {
const found: CachedSkill[] = [];
let groups;
try {
groups = await fs.readdir(root, { withFileTypes: true });
} catch {
return found;
}
for (const group of groups) {
if (!group.isDirectory() || group.name.startsWith('.')) continue;
const groupPath = join(root, group.name);
let entries;
try {
entries = await fs.readdir(groupPath, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
const skillFolder = join(groupPath, entry.name);
const skillFile = join(skillFolder, 'SKILL.md');
let stat;
try {
stat = await fs.stat(skillFile);
} catch {
continue; // folder without SKILL.md — silent skip
}
if (!stat.isFile()) continue;
try {
const content = await fs.readFile(skillFile, 'utf8');
const parsed = parseSkillFile(content);
found.push({
name: parsed.name,
description: parsed.description,
path: skillFolder,
mtime: stat.mtimeMs,
body: parsed.body,
});
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
console.warn(`skills: failed to parse ${skillFile}${reason}`);
}
}
}
return found;
}
// ---- Cache ----------------------------------------------------------------
async function ensureCache(): Promise<void> {
const now = Date.now();
if (cache.size > 0 && now - lastWalkedAt < LIST_CACHE_TTL_MS) return;
let stat;
try {
stat = await fs.stat(SKILLS_ROOT);
} catch {
cache.clear();
lastWalkedAt = now;
return;
}
if (!stat.isDirectory()) {
cache.clear();
lastWalkedAt = now;
return;
}
const found = await walkSkills(SKILLS_ROOT);
// Sort by name asc, then path asc — gives alphabetically-first-wins on
// collision and stable, deterministic ordering for /api/skills + skill_find.
found.sort((a, b) => {
const n = a.name.localeCompare(b.name);
return n !== 0 ? n : a.path.localeCompare(b.path);
});
cache.clear();
const winnerPath = new Map<string, string>();
for (const skill of found) {
const prev = winnerPath.get(skill.name);
if (prev) {
console.warn(
`skills: name collision "${skill.name}" — kept ${prev}, skipped ${skill.path}`,
);
continue;
}
winnerPath.set(skill.name, skill.path);
cache.set(skill.name, skill);
}
lastWalkedAt = now;
}
// ---- Public API -----------------------------------------------------------
export async function listSkills(): Promise<Skill[]> {
await ensureCache();
return Array.from(cache.values()).map((s) => ({
name: s.name,
description: s.description,
path: s.path,
mtime: s.mtime,
}));
}
export interface SkillSummary {
name: string;
description: string;
}
export async function findSkills(query: string): Promise<SkillSummary[]> {
await ensureCache();
const all = Array.from(cache.values());
const q = (query ?? '').trim().toLowerCase();
if (q === '' || q === '*') {
return all.map((s) => ({ name: s.name, description: s.description }));
}
// name match weighted 2x description match. No fancy ranking — substring
// scoring is enough for ≤20 skills.
const scored = all
.map((s) => {
let score = 0;
if (s.name.toLowerCase().includes(q)) score += 2;
if (s.description.toLowerCase().includes(q)) score += 1;
return { s, score };
})
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 5);
return scored.map(({ s }) => ({ name: s.name, description: s.description }));
}
// Returns the SKILL.md body with frontmatter stripped, or null if the skill
// is unknown. Single-entry mtime refresh: a hot edit shows up on next call.
export async function getSkillBody(name: string): Promise<string | null> {
await ensureCache();
const cached = cache.get(name);
if (!cached) return null;
let stat;
try {
stat = await fs.stat(join(cached.path, 'SKILL.md'));
} catch {
cache.delete(name);
return null;
}
if (stat.mtimeMs === cached.mtime) return cached.body;
try {
const raw = await fs.readFile(join(cached.path, 'SKILL.md'), 'utf8');
const parsed = parseSkillFile(raw);
if (parsed.name !== name) {
// Skill renamed itself; drop the stale entry. Next listSkills() walks.
cache.delete(name);
return null;
}
cached.body = parsed.body;
cached.description = parsed.description;
cached.mtime = stat.mtimeMs;
return cached.body;
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
console.warn(`skills: re-parse failed for ${name}${reason}`);
cache.delete(name);
return null;
}
}
export type SkillResourceErrorCode = 'unknown_skill' | 'unknown_resource' | 'path_escape';
export type SkillResourceResult =
| { ok: true; content: string }
| { ok: false; code: SkillResourceErrorCode; message: string };
export async function getSkillResource(
name: string,
relativePath: string,
): Promise<SkillResourceResult> {
await ensureCache();
const cached = cache.get(name);
if (!cached) {
return { ok: false, code: 'unknown_skill', message: `unknown skill: ${name}` };
}
if (typeof relativePath !== 'string' || relativePath.trim() === '') {
return { ok: false, code: 'unknown_resource', message: 'path is required' };
}
// Syntactic pre-check — catches the common "../../etc/passwd" attempt
// before realpath dereferences any symlinks.
if (isAbsolute(relativePath) || relativePath.split(/[\\/]/).some((seg) => seg === '..')) {
return { ok: false, code: 'path_escape', message: `path escapes skill folder: ${relativePath}` };
}
// SKILL.md is the manifest — skill_use is the right tool to read it.
if (basename(relativePath) === 'SKILL.md') {
return { ok: false, code: 'unknown_resource', message: 'use skill_use to read SKILL.md' };
}
let real: string;
try {
real = await pathGuard(cached.path, relativePath);
} catch (err) {
if (err instanceof PathScopeError) {
const code: SkillResourceErrorCode = err.message.includes('escapes')
? 'path_escape'
: 'unknown_resource';
return { ok: false, code, message: err.message };
}
throw err;
}
const stat = await fs.stat(real);
if (!stat.isFile()) {
return { ok: false, code: 'unknown_resource', message: 'not a file' };
}
if (stat.size > MAX_RESOURCE_BYTES) {
return {
ok: false,
code: 'unknown_resource',
message: `file too large (${stat.size} bytes, max ${MAX_RESOURCE_BYTES})`,
};
}
const content = await fs.readFile(real, 'utf8');
return { ok: true, content };
}

View File

@@ -4,6 +4,7 @@ import { z } from 'zod';
import { pathGuard, PathScopeError } from './path_guard.js'; import { pathGuard, PathScopeError } from './path_guard.js';
import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js'; import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js';
import { getGitMeta } from './git_meta.js'; import { getGitMeta } from './git_meta.js';
import { findSkills, getSkillBody, getSkillResource } from './skills.js';
const MAX_FILE_BYTES = 5 * 1024 * 1024; const MAX_FILE_BYTES = 5 * 1024 * 1024;
const DEFAULT_VIEW_LINES = 200; const DEFAULT_VIEW_LINES = 200;
@@ -300,14 +301,138 @@ export const gitStatus: ToolDef<GitStatusInputT> = {
}, },
}; };
// Batch 9.6: skill_find, skill_use, skill_resource. Lazy-loaded markdown
// playbooks at /data/skills/. Three tools rather than one to keep each call
// cheap — the model lists, then loads, then optionally pulls support files.
const SkillFindInput = z.object({
query: z.string().optional(),
});
type SkillFindInputT = z.infer<typeof SkillFindInput>;
export const skillFind: ToolDef<SkillFindInputT> = {
name: 'skill_find',
description:
'Find skills (markdown playbooks under /data/skills) by name or description. Returns up to 5 matches. Empty query or "*" returns all available skills. Call this first to discover what skills are available.',
inputSchema: SkillFindInput,
jsonSchema: {
type: 'function',
function: {
name: 'skill_find',
description:
'Find skills by name or description. Returns up to 5 matches. Empty or "*" returns all.',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'substring matched against skill name and description' },
},
additionalProperties: false,
},
},
},
async execute(input) {
return await findSkills(input.query ?? '');
},
};
const SkillUseInput = z.object({
name: z.string().min(1),
});
type SkillUseInputT = z.infer<typeof SkillUseInput>;
export const skillUse: ToolDef<SkillUseInputT> = {
name: 'skill_use',
description:
"Load the full body of a skill's SKILL.md by name. Returns the markdown playbook to follow. Discover names via skill_find. Errors: unknown_skill.",
inputSchema: SkillUseInput,
jsonSchema: {
type: 'function',
function: {
name: 'skill_use',
description: "Load the full body of a skill's SKILL.md by name.",
parameters: {
type: 'object',
properties: {
name: { type: 'string', description: 'skill name from skill_find' },
},
required: ['name'],
additionalProperties: false,
},
},
},
async execute(input) {
const body = await getSkillBody(input.name);
if (body === null) {
return { error: 'unknown_skill', message: `unknown skill: ${input.name}` };
}
return { body };
},
};
const SkillResourceInput = z.object({
name: z.string().min(1),
path: z.string().min(1),
});
type SkillResourceInputT = z.infer<typeof SkillResourceInput>;
export const skillResource: ToolDef<SkillResourceInputT> = {
name: 'skill_resource',
description:
"Read a support file inside a skill's folder (e.g. references/root-cause-tracing.md). Path is relative to the skill folder. Use skill_use to read SKILL.md itself. Errors: unknown_skill, unknown_resource, path_escape.",
inputSchema: SkillResourceInput,
jsonSchema: {
type: 'function',
function: {
name: 'skill_resource',
description: "Read a support file inside a skill's folder. Path is relative to the skill folder.",
parameters: {
type: 'object',
properties: {
name: { type: 'string', description: 'skill name' },
path: { type: 'string', description: 'relative path under the skill folder' },
},
required: ['name', 'path'],
additionalProperties: false,
},
},
},
async execute(input) {
const result = await getSkillResource(input.name, input.path);
if (!result.ok) {
return { error: result.code, message: result.message };
}
return { content: result.content };
},
};
export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [ export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
viewFile as ToolDef<unknown>, viewFile as ToolDef<unknown>,
listDir as ToolDef<unknown>, listDir as ToolDef<unknown>,
grep as ToolDef<unknown>, grep as ToolDef<unknown>,
findFiles as ToolDef<unknown>, findFiles as ToolDef<unknown>,
gitStatus as ToolDef<unknown>, gitStatus as ToolDef<unknown>,
skillFind as ToolDef<unknown>,
skillUse as ToolDef<unknown>,
skillResource 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.
// Batch 9.6: skill_* added; all still read-only.
export const READ_ONLY_TOOL_NAMES = [
'view_file',
'list_dir',
'grep',
'find_files',
'git_status',
'skill_find',
'skill_use',
'skill_resource',
] as const;
export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries( export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
ALL_TOOLS.map((t) => [t.name, t]) ALL_TOOLS.map((t) => [t.name, t])
); );

View File

@@ -10,6 +10,12 @@ export interface Project {
last_session_id: string | null; last_session_id: string | null;
status: ProjectStatus; status: ProjectStatus;
gitea_remote: string | null; 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 { export interface AvailableProject {
@@ -29,6 +35,10 @@ export interface Session {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
agent_id: string | null; 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 // 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 tools: string[]; // whitelist of tool names; empty = no tools allowed
model: string | null; // null means "session.model wins" model: string | null; // null means "session.model wins"
source: AgentSource; 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 // One entry per malformed `## Name` block. Per-block errors don't fail the
@@ -100,6 +114,31 @@ export interface ToolResult {
error?: string; 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 { export interface Message {
id: string; id: string;
session_id: string; session_id: string;
@@ -117,6 +156,9 @@ export interface Message {
started_at: string | null; started_at: string | null;
finished_at: string | null; finished_at: string | null;
created_at: string; created_at: string;
// v1.8.2: per-message metadata. See MessageMetadata for the discriminated
// shapes currently in use.
metadata: MessageMetadata | null;
} }
export interface ModelInfo { export interface ModelInfo {
@@ -257,11 +299,14 @@ export interface ProjectUpdatedFrame {
} }
// v1.8 mobile-tabs: server can't know about client-side panes, so status // 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. // 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 { export interface ChatStatusFrame {
type: 'chat_status'; type: 'chat_status';
chat_id: string; chat_id: string;
status: 'working' | 'idle' | 'error'; status: 'working' | 'idle' | 'error';
at: string; at: string;
reason?: ErrorReason;
} }
export type UserStreamFrame = export type UserStreamFrame =
| ProjectCreatedFrame | ProjectCreatedFrame

View File

@@ -4,8 +4,31 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BooCode</title> <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> </head>
<body class="bg-neutral-950 text-neutral-100"> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>

View File

@@ -6,8 +6,10 @@ import { RightRail } from '@/components/RightRail';
import { Home } from '@/pages/Home'; import { Home } from '@/pages/Home';
import { Project } from '@/pages/Project'; import { Project } from '@/pages/Project';
import { Session } from '@/pages/Session'; import { Session } from '@/pages/Session';
import { Settings } from '@/pages/Settings';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import { useUserEvents } from '@/hooks/useUserEvents'; import { useUserEvents } from '@/hooks/useUserEvents';
import { useTheme } from '@/lib/theme';
import { SidebarDrawerProvider, useSidebarDrawer } from '@/hooks/useSidebarDrawer'; import { SidebarDrawerProvider, useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { RightRailDrawerProvider, useRightRailDrawer } from '@/hooks/useRightRailDrawer'; import { RightRailDrawerProvider, useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport'; import { useViewport } from '@/hooks/useViewport';
@@ -61,9 +63,13 @@ function MobileRightRailBackdrop() {
} }
function AppShell() { 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(); useUserEvents();
return ( return (
<div className="dark h-screen flex bg-background text-foreground"> <div className="h-screen flex bg-background text-foreground">
<ProjectSidebar /> <ProjectSidebar />
<MobileBackdrop /> <MobileBackdrop />
<main className="flex-1 flex flex-col min-w-0"> <main className="flex-1 flex flex-col min-w-0">
@@ -71,6 +77,7 @@ function AppShell() {
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/project/:id" element={<Project />} /> <Route path="/project/:id" element={<Project />} />
<Route path="/session/:id" element={<Session />} /> <Route path="/session/:id" element={<Session />} />
<Route path="/settings" element={<Settings />} />
</Routes> </Routes>
</main> </main>
<MobileRightRailBackdrop /> <MobileRightRailBackdrop />

View File

@@ -10,6 +10,7 @@ import type {
ViewFileResult, ViewFileResult,
AgentsResponse, AgentsResponse,
GitMeta, GitMeta,
Skill,
} from './types'; } from './types';
export class ApiError extends Error { export class ApiError extends Error {
@@ -51,15 +52,29 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify(body), 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}`, { request<Project>(`/api/projects/${id}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
get: (id: string) => request<Project>(`/api/projects/${id}`),
archive: (id: string) => archive: (id: string) =>
request<void>(`/api/projects/${id}/archive`, { method: 'POST' }), request<void>(`/api/projects/${id}/archive`, { method: 'POST' }),
unarchive: (id: string) => unarchive: (id: string) =>
request<Project>(`/api/projects/${id}/unarchive`, { method: 'POST' }), 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: { create: (body: {
name: string; name: string;
commit_message?: string; commit_message?: string;
@@ -106,7 +121,7 @@ export const api = {
get: (id: string) => request<Session>(`/api/sessions/${id}`), get: (id: string) => request<Session>(`/api/sessions/${id}`),
update: ( update: (
id: string, 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}`, { request<Session>(`/api/sessions/${id}`, {
method: 'PATCH', method: 'PATCH',
@@ -118,6 +133,15 @@ export const api = {
request<void>(`/api/sessions/${id}/archive`, { method: 'POST' }), request<void>(`/api/sessions/${id}/archive`, { method: 'POST' }),
unarchive: (id: string) => unarchive: (id: string) =>
request<Session>(`/api/sessions/${id}/unarchive`, { method: 'POST' }), 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: { chats: {
@@ -152,11 +176,32 @@ export const api = {
`/api/chats/${chatId}/force_send`, `/api/chats/${chatId}/force_send`,
{ method: 'POST', body: JSON.stringify({ content }) } { 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 }) => fork: (chatId: string, body: { messageId: string; name?: string }) =>
request<Chat>(`/api/chats/${chatId}/fork`, { request<Chat>(`/api/chats/${chatId}/fork`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ message_id: body.messageId, name: body.name }), body: JSON.stringify({ message_id: body.messageId, name: body.name }),
}), }),
// Batch 9.6: slash-command invocation. Server loads the skill body
// authoritatively (client doesn't get to forge file contents), persists
// a synthetic skill_use tool_use + tool_result + user message + streaming
// assistant, and enqueues inference. Returns all 4 new message IDs.
skillInvoke: (chatId: string, skillName: string, userMessage: string | null) =>
request<{
synth_assistant_id: string;
tool_message_id: string;
user_message_id: string;
assistant_message_id: string;
}>(`/api/chats/${chatId}/skill_invoke`, {
method: 'POST',
body: JSON.stringify({ skill_name: skillName, user_message: userMessage }),
}),
}, },
messages: { messages: {
@@ -188,6 +233,10 @@ export const api = {
request<AgentsResponse>(`/api/projects/${projectId}/agents`), request<AgentsResponse>(`/api/projects/${projectId}/agents`),
}, },
skills: {
list: () => request<{ skills: Skill[] }>('/api/skills'),
},
settings: { settings: {
get: () => request<Record<string, unknown>>('/api/settings'), get: () => request<Record<string, unknown>>('/api/settings'),
patch: (body: Record<string, unknown>) => patch: (body: Record<string, unknown>) =>

View File

@@ -9,6 +9,10 @@ export interface Project {
last_session_id: string | null; last_session_id: string | null;
status: ProjectStatus; status: ProjectStatus;
gitea_remote: string | null; 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 { export interface AvailableProject {
@@ -28,6 +32,8 @@ export interface Session {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
agent_id: string | null; 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 // v1.8.1: 'global' = /data/AGENTS.md (always-on), 'project' = per-project
@@ -44,6 +50,10 @@ export interface Agent {
tools: string[]; tools: string[];
model: string | null; model: string | null;
source: AgentSource; 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 { export interface AgentParseError {
@@ -89,6 +99,32 @@ export interface ToolResult {
error?: string; 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 { export interface Message {
id: string; id: string;
session_id: string; session_id: string;
@@ -106,6 +142,9 @@ export interface Message {
started_at: string | null; started_at: string | null;
finished_at: string | null; finished_at: string | null;
created_at: string; created_at: string;
// v1.8.2: per-message metadata; see MessageMetadata. null for the vast
// majority of messages.
metadata: MessageMetadata | null;
} }
export interface ModelInfo { export interface ModelInfo {
@@ -192,7 +231,20 @@ export interface GitMeta {
behind: number; behind: number;
} }
export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty'; // Batch 9.6: skill catalog row. Returned by GET /api/skills and consumed by
// the slash-command dropdown. `path` and `mtime` are exposed for debug surface
// (/api/skills) but the dropdown only renders name + description.
export interface Skill {
name: string;
description: string;
path: string;
mtime: number;
}
// 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 toggleSettingsPane().
export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty' | 'settings';
export interface WorkspacePane { export interface WorkspacePane {
id: string; id: string;
@@ -225,7 +277,13 @@ export type WsFrame =
ctx_max?: number | null; ctx_max?: number | null;
started_at?: string | null; started_at?: string | null;
finished_at?: string | null; finished_at?: string | null;
// 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: 'messages_deleted'; message_ids: string[]; chat_id?: string }
| { type: 'chat_renamed'; chat_id: string; name: 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 { useCallback, useEffect, useMemo, 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 { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { import {
flattenToMessage, flattenToMessage,
inferLanguage, inferLanguage,
@@ -16,8 +22,10 @@ import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
import { FileMentionPopover } from '@/components/FileMentionPopover'; import { FileMentionPopover } from '@/components/FileMentionPopover';
import { DropOverlay } from '@/components/DropOverlay'; import { DropOverlay } from '@/components/DropOverlay';
import { AgentPicker } from '@/components/AgentPicker'; import { AgentPicker } from '@/components/AgentPicker';
import { SkillSlashCommand } from '@/components/SkillSlashCommand';
import { api } from '@/api/client'; import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
import { useSkills } from '@/hooks/useSkills';
import { useViewport } from '@/hooks/useViewport'; import { useViewport } from '@/hooks/useViewport';
const MAX_ATTACHMENTS = 10; const MAX_ATTACHMENTS = 10;
@@ -29,11 +37,23 @@ interface Props {
// When omitted, the toolbar row is hidden entirely. // When omitted, the toolbar row is hidden entirely.
agentId?: string | null; agentId?: string | null;
onAgentChange?: (agentId: string | null) => void | Promise<void>; 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>; onSend: (content: string) => void | Promise<void>;
onForceSend?: (content: string) => void | Promise<void>; onForceSend?: (content: string) => void | Promise<void>;
// Batch 9.6: slash-command dispatch. When the input parses to a known skill,
// ChatInput calls this with the skill name + the post-name args (possibly
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
// disables slash-command dispatch (input is sent as literal text).
onSlashCommand?: (skillName: string, userMessage: 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, onSlashCommand }: Props) {
const { isMobile } = useViewport(); const { isMobile } = useViewport();
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -48,6 +68,19 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend,
atIdx: number; atIdx: number;
anchorRect: { top: number; left: number }; anchorRect: { top: number; left: number };
} | null>(null); } | null>(null);
// Batch 9.6: slash-command dropdown. Opens when `/` is the first char of
// the input and stays open while the input is `/<word>` with no whitespace.
// Disabled entirely when the caller doesn't pass onSlashCommand.
const [slashState, setSlashState] = useState<{
query: string;
anchorRect: { top: number; left: number };
} | null>(null);
const { skills } = useSkills();
const skillsLookup = useMemo(() => {
const m = new Map<string, true>();
for (const s of skills) m.set(s.name, true);
return m;
}, [skills]);
const [fileIndex, setFileIndex] = useState<string[] | null>(null); const [fileIndex, setFileIndex] = useState<string[] | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -82,6 +115,31 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend,
const text = value.trim(); const text = value.trim();
if (!text && attachments.length === 0) return; if (!text && attachments.length === 0) return;
if (disabled || busy) return; if (disabled || busy) return;
// Batch 9.6: slash-command dispatch. Only when no attachments and the
// input parses to a known skill. Falls through to onSend for unknown
// slash names (literal text) or when slash dispatch isn't wired.
if (onSlashCommand && attachments.length === 0 && text.startsWith('/')) {
const match = text.match(/^\/(\S+)\s*([\s\S]*)$/);
if (match && skillsLookup.has(match[1]!)) {
const skillName = match[1]!;
const args = (match[2] ?? '').trim();
setBusy(true);
try {
await onSlashCommand(skillName, args);
setValue('');
setAttachments([]);
setSlashState(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
} finally {
setBusy(false);
}
return;
}
// Unknown skill name — fall through and send as literal text.
}
setBusy(true); setBusy(true);
try { try {
const body = flattenToMessage(attachments, text); const body = flattenToMessage(attachments, text);
@@ -95,6 +153,19 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend,
} }
} }
function handleSlashSelect(skillName: string) {
const next = `/${skillName} `;
setValue(next);
setSlashState(null);
requestAnimationFrame(() => {
const ta = textareaRef.current;
if (ta) {
ta.selectionStart = ta.selectionEnd = next.length;
ta.focus();
}
});
}
function getCaretCoords(textarea: HTMLTextAreaElement): { top: number; left: number } { function getCaretCoords(textarea: HTMLTextAreaElement): { top: number; left: number } {
const mirror = document.createElement('div'); const mirror = document.createElement('div');
const style = window.getComputedStyle(textarea); const style = window.getComputedStyle(textarea);
@@ -145,6 +216,23 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend,
const ta = e.target; const ta = e.target;
const pos = ta.selectionStart; const pos = ta.selectionStart;
// Batch 9.6: slash-command trigger. Active while the input is a single
// slash-prefixed token with no whitespace (i.e. user is still typing the
// skill name). Hand off to args mode the moment a space appears or the
// slash leaves position 0.
if (onSlashCommand && /^\/[^\s]*$/.test(newValue)) {
const query = newValue.slice(1);
if (!slashState) {
const rect = ta.getBoundingClientRect();
setSlashState({ query, anchorRect: { top: rect.top, left: rect.left } });
} else if (slashState.query !== query) {
setSlashState({ ...slashState, query });
}
if (mentionState?.open) setMentionState(null);
return;
}
if (slashState) setSlashState(null);
// Check for @ trigger // Check for @ trigger
if (pos > 0 && newValue[pos - 1] === '@') { if (pos > 0 && newValue[pos - 1] === '@') {
const charBefore = pos >= 2 ? newValue[pos - 2] : null; const charBefore = pos >= 2 ? newValue[pos - 2] : null;
@@ -361,6 +449,9 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend,
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) { function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (mentionState?.open) return; if (mentionState?.open) return;
// SkillSlashCommand owns Arrow/Enter/Tab/Esc via a document listener; let
// it consume them so the textarea doesn't also submit on Enter.
if (slashState) return;
// IME safety: never act on Enter while an IME composition is in flight // IME safety: never act on Enter while an IME composition is in flight
// (CJK input methods commit composition via Enter). Without this, the // (CJK input methods commit composition via Enter). Without this, the
// first Enter of a Japanese/Chinese/Korean composition would submit // first Enter of a Japanese/Chinese/Korean composition would submit
@@ -425,16 +516,51 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend,
))} ))}
</div> </div>
)} )}
{/* Batch 9 toolbar — agent picker. Sits above the input row so it {/* Batch 9 toolbar — agent picker. v1.9 adds the icon-only + menu next
doesn't compete with the send button for vertical alignment. to it for quick toggles (currently: Web search). When omitted at the
When Batch 7 lands, ModelPicker and the + button join this row. */} callsite the row stays collapsed so nothing else has to change. */}
{onAgentChange && ( {(onAgentChange || sessionId) && (
<div className="px-4 pt-2 flex items-center gap-1.5"> <div className="px-4 pt-2 flex items-center gap-1.5">
<AgentPicker {onAgentChange && (
projectId={projectId} <AgentPicker
value={agentId ?? null} projectId={projectId}
onChange={onAgentChange} 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>
)} )}
<div className="px-4 py-3 flex items-end gap-2"> <div className="px-4 py-3 flex items-end gap-2">
@@ -476,6 +602,15 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend,
onClose={closeMention} onClose={closeMention}
/> />
)} )}
{slashState && (
<SkillSlashCommand
query={slashState.query}
skills={skills}
anchorRect={slashState.anchorRect}
onSelect={handleSlashSelect}
onClose={() => setSlashState(null)}
/>
)}
</div> </div>
); );
} }

View File

@@ -4,10 +4,10 @@ import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2 } from 'lucide-react'; import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2 } from 'lucide-react';
import { toast } from 'sonner'; 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 { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
import { ToolCallCard } from './ToolCallCard'; import { CapHitSentinel } from './CapHitSentinel';
import { CodeBlock } from './CodeBlock'; import { CodeBlock } from './CodeBlock';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -19,6 +19,15 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } 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 `/` // 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 // 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 // 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 { interface Props {
message: Message; message: Message;
sessionChats?: Chat[]; 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 }) { 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') { if (message.kind === 'compact') {
return <CompactCard message={message} sessionChats={sessionChats} />; return <CompactCard message={message} sessionChats={sessionChats} />;
} }
if (message.role === 'tool') { // v1.8.2: cap-hit sentinels render as a distinct system bubble with a
return <ToolCallCard message={message} />; // 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') { if (message.role === 'user') {
return ( return (
<div className="group flex flex-col items-end gap-1"> <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 isStreaming = message.status === 'streaming';
const failed = message.status === 'failed'; const failed = message.status === 'failed';
const hasContent = message.content.length > 0; 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 ( return (
<div className="group flex flex-col gap-2"> <div className="group flex flex-col gap-2">
{message.tool_calls?.map((tc) => ( {(hasContent || isStreaming) && (
<ToolCallCard key={tc.id} toolCall={tc} />
))}
{(hasContent || (!hasToolCalls && isStreaming)) && (
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0"> <div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
{hasContent ? <MarkdownBody content={message.content} /> : null} {hasContent ? <MarkdownBody content={message.content} /> : null}
{isStreaming && ( {isStreaming && (
@@ -503,12 +537,18 @@ export function MessageBubble({ message, sessionChats }: Props) {
</div> </div>
)} )}
{failed && ( {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 && <StatsLine message={message} />}
{!isStreaming && (hasContent || hasToolCalls) && ( {!isStreaming && hasContent && <ActionRow message={message} />}
<ActionRow message={message} />
)}
</div> </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 type { Chat, Message } from '@/api/types';
import { MessageBubble } from './MessageBubble'; import { MessageBubble } from './MessageBubble';
import { ToolCallGroup } from './ToolCallGroup';
import { ToolCallLine, type ToolRun } from './ToolCallLine';
interface Props { interface Props {
messages: Message[]; messages: Message[];
sessionChats?: Chat[]; 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) { export function MessageList({ messages, sessionChats }: Props) {
const endRef = useRef<HTMLDivElement>(null); const endRef = useRef<HTMLDivElement>(null);
const renderItems = useMemo(() => stampCapHits(group(flatten(messages))), [messages]);
useEffect(() => { useEffect(() => {
endRef.current?.scrollIntoView({ block: 'end' }); endRef.current?.scrollIntoView({ block: 'end' });
}, [messages]); }, [messages]);
@@ -25,9 +138,22 @@ export function MessageList({ messages, sessionChats }: Props) {
return ( return (
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4"> <div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
{messages.map((m) => ( {renderItems.map((item) => {
<MessageBubble key={m.id} message={m} sessionChats={sessionChats} /> 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 ref={endRef} />
</div> </div>
</div> </div>

View File

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

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Check, ChevronDown } from 'lucide-react'; import { Check, ChevronDown, Cpu } from 'lucide-react';
import { api } from '@/api/client'; import { api } from '@/api/client';
import type { ModelInfo } from '@/api/types'; import type { ModelInfo } from '@/api/types';
import { import {
@@ -8,26 +8,94 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { BottomSheet } from '@/components/BottomSheet';
import { useViewport } from '@/hooks/useViewport';
interface Props { interface Props {
value: string; value: string;
onChange: (model: string) => void | Promise<void>; 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) { export function ModelPicker({ value, onChange }: Props) {
const { isMobile } = useViewport();
const [models, setModels] = useState<ModelInfo[] | null>(null); const [models, setModels] = useState<ModelInfo[] | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (!open || models !== null) return; if (!open || models !== null) return;
api.models() api
.models()
.then(setModels) .then(setModels)
.catch((err) => .catch((err) =>
setError(err instanceof Error ? err.message : 'failed to load models') setError(err instanceof Error ? err.message : 'failed to load models'),
); );
}, [open, 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 ( return (
<DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -49,7 +117,7 @@ export function ModelPicker({ value, onChange }: Props) {
{models?.map((m) => ( {models?.map((m) => (
<DropdownMenuItem <DropdownMenuItem
key={m.id} key={m.id}
onSelect={() => void onChange(m.id)} onSelect={() => handlePick(m.id)}
className="font-mono text-xs" className="font-mono text-xs"
> >
<Check <Check

View File

@@ -1,8 +1,9 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom'; 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 { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { sessionEvents } from '@/hooks/sessionEvents';
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@@ -198,7 +199,7 @@ export function ProjectSidebar() {
const rowCls = (active: boolean) => const rowCls = (active: boolean) =>
active ? 'bg-sidebar-accent text-sidebar-accent-foreground' : 'hover:bg-sidebar-accent/60'; 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 { isMobile } = useViewport();
const pull = usePullToRefresh(() => retry(), { enabled: isMobile }); const pull = usePullToRefresh(() => retry(), { enabled: isMobile });
@@ -412,6 +413,30 @@ export function ProjectSidebar() {
})} })}
</nav> </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={() => {}} /> <AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={() => {}} />
<Dialog open={archiveProjectConfirm !== null} onOpenChange={(open) => { if (!open) setArchiveProjectConfirm(null); }}> <Dialog open={archiveProjectConfirm !== null} onOpenChange={(open) => { if (!open) setArchiveProjectConfirm(null); }}>

View File

@@ -3,7 +3,6 @@ import { Archive, MessageSquare, Send, ChevronDown, ChevronRight, RotateCcw, Tra
import type { Chat } from '@/api/types'; import type { Chat } from '@/api/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@@ -165,7 +164,6 @@ export function SessionLandingPage({
const [renameValue, setRenameValue] = useState(''); const [renameValue, setRenameValue] = useState('');
const [archiveConfirm, setArchiveConfirm] = useState<Chat | null>(null); const [archiveConfirm, setArchiveConfirm] = useState<Chat | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<Chat | null>(null); const [deleteConfirm, setDeleteConfirm] = useState<Chat | null>(null);
const [deleteInput, setDeleteInput] = useState('');
const openChats = chats const openChats = chats
.filter((c) => c.status === 'open') .filter((c) => c.status === 'open')
@@ -193,9 +191,6 @@ export function SessionLandingPage({
setRenamingId(null); setRenamingId(null);
} }
const deleteExpected = deleteConfirm?.name ?? '';
const deleteEnabled = deleteConfirm !== null && deleteInput === deleteExpected && deleteExpected.length > 0;
// TODO: Landing page chat counts are a snapshot at mount. New messages in // TODO: Landing page chat counts are a snapshot at mount. New messages in
// visible chats won't update the per-row stats until next mount/navigation. // visible chats won't update the per-row stats until next mount/navigation.
return ( return (
@@ -217,7 +212,7 @@ export function SessionLandingPage({
onCancelRename={() => setRenamingId(null)} onCancelRename={() => setRenamingId(null)}
onContextStartRename={() => startRename(chat)} onContextStartRename={() => startRename(chat)}
onContextArchive={() => setArchiveConfirm(chat)} onContextArchive={() => setArchiveConfirm(chat)}
onContextDelete={() => { setDeleteConfirm(chat); setDeleteInput(''); }} onContextDelete={() => setDeleteConfirm(chat)}
showContextMenu showContextMenu
actions={ actions={
<> <>
@@ -242,7 +237,6 @@ export function SessionLandingPage({
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setDeleteConfirm(chat); setDeleteConfirm(chat);
setDeleteInput('');
}} }}
> >
<Trash2 size={14} /> <Trash2 size={14} />
@@ -352,36 +346,25 @@ export function SessionLandingPage({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) { setDeleteConfirm(null); setDeleteInput(''); } }}> <Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) setDeleteConfirm(null); }}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Delete chat?</DialogTitle> <DialogTitle>Delete chat?</DialogTitle>
<DialogDescription> <DialogDescription>
Type the chat name to confirm: Permanently delete{' '}
{' '} <span className="font-mono font-medium text-foreground">{deleteConfirm?.name || '(unnamed)'}</span>
<span className="font-mono font-medium text-foreground">{deleteExpected || '(unnamed — cannot type-confirm)'}</span> {' '}and all its messages. This cannot be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<Input
value={deleteInput}
onChange={(e) => setDeleteInput(e.target.value)}
placeholder={deleteExpected}
disabled={!deleteExpected}
/>
<div className="text-xs text-muted-foreground">
This will permanently delete this chat and all its messages. This cannot be undone.
</div>
<div className="flex gap-2 justify-end pt-2"> <div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => { setDeleteConfirm(null); setDeleteInput(''); }}> <Button variant="outline" onClick={() => setDeleteConfirm(null)}>
Cancel Cancel
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
disabled={!deleteEnabled}
onClick={() => { onClick={() => {
if (deleteConfirm && deleteEnabled) void onDeleteChat(deleteConfirm.id); if (deleteConfirm) void onDeleteChat(deleteConfirm.id);
setDeleteConfirm(null); setDeleteConfirm(null);
setDeleteInput('');
}} }}
> >
Delete Delete

View File

@@ -0,0 +1,137 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import type { Skill } from '@/api/types';
interface Props {
query: string;
skills: Skill[];
anchorRect: { top: number; left: number };
onSelect: (skillName: string) => void;
onClose: () => void;
}
// Batch 9.6: slash-command dropdown. Models FileMentionPopover's pattern —
// fixed-positioned popover, keyboard nav, click-outside-to-close. shadcn
// `Command` (cmdk) isn't installed in this project; per the addendum we use
// a plain div + Tailwind instead of pulling a new primitive autonomously.
// Case-insensitive prefix match on `name` only. Description is display-only
// in v1 (substring search across description is deferred to a polish batch).
function filterByPrefix(skills: Skill[], query: string): Skill[] {
const q = query.toLowerCase();
const filtered = q
? skills.filter((s) => s.name.toLowerCase().startsWith(q))
: skills;
// Stable alphabetical ordering matches the server's cache order (skills.ts
// sorts on name asc) but we re-sort here so a stale client cache doesn't
// surprise the user.
return [...filtered].sort((a, b) => a.name.localeCompare(b.name));
}
export function SkillSlashCommand({ query, skills, anchorRect, onSelect, onClose }: Props) {
const [highlightIndex, setHighlightIndex] = useState(0);
const popoverRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => filterByPrefix(skills, query), [skills, query]);
useEffect(() => { setHighlightIndex(0); }, [query]);
// Arrow / Enter / Tab / Escape. Bound on document so keystrokes from the
// textarea reach the popover even though focus stays in the textarea.
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
} else if (e.key === 'Enter' || e.key === 'Tab') {
if (filtered.length === 0) return;
e.preventDefault();
const target = filtered[highlightIndex] ?? filtered[0];
if (target) onSelect(target.name);
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [filtered, highlightIndex, onSelect, onClose]);
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener('mousedown', handleMouseDown);
return () => document.removeEventListener('mousedown', handleMouseDown);
}, [onClose]);
useEffect(() => {
const el = popoverRef.current?.querySelector('[data-highlighted="true"]');
if (el) el.scrollIntoView({ block: 'nearest' });
}, [highlightIndex]);
// Anchor sits above the input — translate(-100%) on Y so the dropdown
// expands upward from the anchor point rather than over the textarea.
const style = {
top: anchorRect.top,
left: anchorRect.left,
transform: 'translateY(-100%)',
} as const;
if (filtered.length === 0) {
return (
<div
ref={popoverRef}
className="fixed z-50 bg-popover border border-border rounded-md shadow min-w-[320px] p-2"
style={style}
>
<div className="text-xs text-muted-foreground px-2 py-1">
{query ? `No skill starts with "/${query}"` : 'No skills available'}
</div>
</div>
);
}
return (
<div
ref={popoverRef}
className="fixed z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto"
style={style}
>
{filtered.map((skill, i) => (
<button
key={skill.name}
type="button"
data-highlighted={i === highlightIndex}
className={cn(
'w-full text-left px-2.5 py-2 cursor-pointer block',
i === highlightIndex && 'bg-muted',
)}
onMouseEnter={() => setHighlightIndex(i)}
onMouseDown={(e) => {
// mousedown not click — click runs after blur/focus shuffles which
// can race with the textarea's onBlur close path.
e.preventDefault();
onSelect(skill.name);
}}
>
<div className="font-mono text-xs font-bold text-foreground">/{skill.name}</div>
<div
className="text-xs text-muted-foreground overflow-hidden"
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{skill.description}
</div>
</button>
))}
</div>
);
}

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 { 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 { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
import type { UseSessionChatsResult } from '@/hooks/useSessionChats'; import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
import { useViewport } from '@/hooks/useViewport'; import { useViewport } from '@/hooks/useViewport';
import { ChatPane } from '@/components/panes/ChatPane'; import { ChatPane } from '@/components/panes/ChatPane';
import { SettingsPane } from '@/components/panes/SettingsPane';
import { ChatTabBar } from '@/components/ChatTabBar'; import { ChatTabBar } from '@/components/ChatTabBar';
import { SessionLandingPage } from '@/components/SessionLandingPage'; import { SessionLandingPage } from '@/components/SessionLandingPage';
import { import {
@@ -24,6 +26,9 @@ interface Props {
// (MobileTabSwitcher) can share state with the pane grid. // (MobileTabSwitcher) can share state with the pane grid.
panesHook: UseWorkspacePanesResult; panesHook: UseWorkspacePanesResult;
chatsHook: UseSessionChatsResult; chatsHook: UseSessionChatsResult;
// v1.9: passed through to SettingsPane when one is mounted in the grid.
session: Session;
project: Project | null;
} }
export function Workspace({ export function Workspace({
@@ -33,6 +38,8 @@ export function Workspace({
onAgentChange, onAgentChange,
panesHook, panesHook,
chatsHook, chatsHook,
session,
project,
}: Props) { }: Props) {
const { const {
panes, panes,
@@ -67,6 +74,41 @@ export function Workspace({
const { isMobile } = useViewport(); 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');
// Esc semantics: maximized → restore; otherwise → close settings pane (only
// when it's the active pane). Bail when the user is typing in a field or
// inside an open dialog so we don't eat their cancel keystroke.
useEffect(() => {
if (settingsIdx < 0) return;
function onKey(e: KeyboardEvent) {
if (e.key !== 'Escape') return;
const t = e.target;
if (t instanceof HTMLElement) {
if (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable) return;
if (t.closest('[role="dialog"]')) return;
}
if (maximized) {
setMaximized(false);
} else if (activePaneIdx === settingsIdx) {
removePane(settingsIdx);
}
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [maximized, settingsIdx, activePaneIdx, removePane]);
// 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[] { function chatsForPane(pane: WorkspacePane): Chat[] {
return pane.chatIds return pane.chatIds
.map((id) => chats.find((c) => c.id === id)) .map((id) => chats.find((c) => c.id === id))
@@ -81,10 +123,12 @@ export function Workspace({
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="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( className={cn(
'flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted', '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} /> <PanelRight size={14} />
@@ -114,12 +158,24 @@ export function Workspace({
style={ style={
isMobile isMobile
? undefined ? 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) => { {panes.map((pane, idx) => {
const visible = !isMobile || idx === activePaneIdx; const isSettings = pane.kind === 'settings';
if (!visible) return null; // 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 ( return (
<div <div
key={pane.id} key={pane.id}
@@ -131,19 +187,19 @@ export function Workspace({
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10' 'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
)} )}
onClick={() => setActivePaneIdx(idx)} onClick={() => setActivePaneIdx(idx)}
onDragOver={!isMobile && panes.length > 1 ? handlePaneDragOver(idx) : undefined} onDragOver={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
onDragLeave={!isMobile && panes.length > 1 ? handlePaneDragLeave : undefined} onDragLeave={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragLeave : undefined}
onDrop={!isMobile && panes.length > 1 ? handlePaneDrop(idx) : undefined} onDrop={!isMobile && !isSettings && panes.length > 1 ? handlePaneDrop(idx) : undefined}
> >
<div <div
draggable={!isMobile && panes.length > 1} draggable={!isMobile && !isSettings && panes.length > 1}
onDragStart={!isMobile && panes.length > 1 ? handlePaneDragStart(idx) : undefined} onDragStart={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={!isMobile && panes.length > 1 ? handlePaneDragEnd : undefined} onDragEnd={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragEnd : undefined}
> >
{/* Hidden on mobile per v1.8: chat-within-pane navigation {/* Hidden on mobile per v1.8; settings panes own their own
is not exposed on small screens; users switch panes via section nav / maximize toggle so they skip ChatTabBar
the header pill instead. */} entirely. */}
{!isMobile && ( {!isMobile && !isSettings && (
<ChatTabBar <ChatTabBar
pane={pane} pane={pane}
tabs={chatsForPane(pane)} tabs={chatsForPane(pane)}
@@ -161,7 +217,16 @@ export function Workspace({
</div> </div>
<div className="flex-1 min-h-0 overflow-hidden"> <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)}
onClose={() => removePane(idx)}
isMobile={isMobile}
/>
) : pane.kind === 'chat' && pane.chatId ? (
<ChatPane <ChatPane
sessionId={sessionId} sessionId={sessionId}
chatId={pane.chatId} chatId={pane.chatId}
@@ -169,6 +234,7 @@ export function Workspace({
agentId={agentId} agentId={agentId}
onAgentChange={onAgentChange} onAgentChange={onAgentChange}
sessionChats={chats} sessionChats={chats}
webSearchEnabled={session.web_search_enabled}
/> />
) : ( ) : (
<SessionLandingPage <SessionLandingPage

View File

@@ -22,9 +22,13 @@ interface Props {
agentId?: string | null; agentId?: string | null;
onAgentChange?: (agentId: string | null) => void | Promise<void>; onAgentChange?: (agentId: string | null) => void | Promise<void>;
sessionChats?: import('@/api/types').Chat[]; 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 stream = useSessionStream(sessionId);
const lastErrorRef = useRef<string | null>(null); const lastErrorRef = useRef<string | null>(null);
const [queue, setQueue] = useState<string[]>([]); const [queue, setQueue] = useState<string[]>([]);
@@ -92,6 +96,18 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
} }
}, [chatId]); }, [chatId]);
// Batch 9.6: slash-command dispatch. Sent regardless of streaming state —
// matches the existing /compact precedent (which also fires immediately).
// Empty args go to the server as null; the server fills in a default user
// message ("Apply this skill.") so the model has something to act on.
const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => {
try {
await api.chats.skillInvoke(chatId, skillName, userMessage.length > 0 ? userMessage : null);
} catch (err) {
toast.error(err instanceof Error ? err.message : `/${skillName} failed`);
}
}, [chatId]);
function removeQueued(idx: number) { function removeQueued(idx: number) {
setQueue((prev) => prev.filter((_, i) => i !== idx)); setQueue((prev) => prev.filter((_, i) => i !== idx));
} }
@@ -173,10 +189,13 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
<ChatInput <ChatInput
disabled={false} disabled={false}
projectId={projectId} projectId={projectId}
sessionId={sessionId}
agentId={agentId} agentId={agentId}
onAgentChange={onAgentChange} onAgentChange={onAgentChange}
webSearchEnabled={webSearchEnabled}
onSend={handleSend} onSend={handleSend}
onForceSend={streaming ? handleForceSend : undefined} onForceSend={streaming ? handleForceSend : undefined}
onSlashCommand={handleSlashCommand}
/> />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,530 @@
import { useEffect, useState } from 'react';
import { Archive, Maximize2, Minimize2, X } 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;
onClose: () => 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, onClose, 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>
)}
<button
type="button"
onClick={onClose}
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Close settings"
title="Close (Esc)"
>
<X 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 // across hooks (e.g. AI rename arriving via WS in the session view needs to
// also refresh the sidebar's session list). // 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'; import type { Attachment } from '@/lib/attachments';
export interface SessionRenamedEvent { export interface SessionRenamedEvent {
@@ -62,6 +62,14 @@ export interface OpenChatInActivePaneEvent {
chat_id: string; chat_id: string;
} }
// Client-side event fired by the sidebar Settings button when a session is
// currently mounted. Session.tsx subscribes and calls
// panesHook.toggleSettingsPane() (open on first click, close on second).
// Sidebar handles the no-session case by navigating to /settings directly.
export interface OpenSettingsPaneEvent {
type: 'open_settings_pane';
}
export interface SessionArchivedEvent { export interface SessionArchivedEvent {
type: 'session_archived'; type: 'session_archived';
session_id: string; session_id: string;
@@ -118,11 +126,14 @@ export interface ProjectUpdatedEvent {
// v1.8 mobile-tabs: broadcast on user channel from inference.ts so any device // 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 // subscribed sees a chat working/idle/error. Frontend stores per-chat; panes
// derive their dot from pane.activeChatId. // 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 { export interface ChatStatusEvent {
type: 'chat_status'; type: 'chat_status';
chat_id: string; chat_id: string;
status: 'working' | 'idle' | 'error'; status: 'working' | 'idle' | 'error';
at: string; at: string;
reason?: ErrorReason;
} }
export type SessionEvent = export type SessionEvent =
@@ -136,6 +147,7 @@ export type SessionEvent =
| OpenFileInBrowserEvent | OpenFileInBrowserEvent
| AttachChatFileEvent | AttachChatFileEvent
| OpenChatInActivePaneEvent | OpenChatInActivePaneEvent
| OpenSettingsPaneEvent
| SessionArchivedEvent | SessionArchivedEvent
| ChatCreatedEvent | ChatCreatedEvent
| ChatUpdatedEvent | ChatUpdatedEvent

View File

@@ -29,7 +29,9 @@ function applyFrame(state: State, frame: WsFrame): State {
kind: 'message', kind: 'message',
tool_calls: null, tool_calls: null,
tool_results: 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, last_seq: 0,
tokens_used: null, tokens_used: null,
ctx_used: null, ctx_used: null,
@@ -37,6 +39,7 @@ function applyFrame(state: State, frame: WsFrame): State {
started_at: null, started_at: null,
finished_at: null, finished_at: null,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
metadata: null,
}; };
return { ...state, messages: [...state.messages, newMsg] }; return { ...state, messages: [...state.messages, newMsg] };
} }
@@ -96,6 +99,7 @@ function applyFrame(state: State, frame: WsFrame): State {
started_at: null, started_at: null,
finished_at: null, finished_at: null,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
metadata: null,
}; };
return { ...state, messages: [...state.messages, newMsg] }; 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.ctx_max !== undefined ? { ctx_max: frame.ctx_max } : {}),
...(frame.started_at !== undefined ? { started_at: frame.started_at } : {}), ...(frame.started_at !== undefined ? { started_at: frame.started_at } : {}),
...(frame.finished_at !== undefined ? { finished_at: frame.finished_at } : {}), ...(frame.finished_at !== undefined ? { finished_at: frame.finished_at } : {}),
// 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 : m
); );
@@ -133,9 +141,22 @@ function applyFrame(state: State, frame: WsFrame): State {
return state; return state;
} }
case 'error': { 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 const next = frame.message_id
? state.messages.map((m) => ? 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; : state.messages;
return { ...state, messages: next, error: frame.error }; 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': case 'open_chat_in_active_pane':
// Consumed by Workspace; sidebar has no business with pane state. // Consumed by Workspace; sidebar has no business with pane state.
return prev; return prev;
case 'open_settings_pane':
// Consumed by Session.tsx (calls toggleSettingsPane on its panesHook).
// Sidebar data is untouched.
return prev;
case 'session_archived': { case 'session_archived': {
let changed = false; let changed = false;
const projects = prev.projects.map((p) => { const projects = prev.projects.map((p) => {

View File

@@ -0,0 +1,43 @@
import { useEffect, useState } from 'react';
import { api } from '@/api/client';
import type { Skill } from '@/api/types';
// Batch 9.6: shared in-memory cache for the slash-command dropdown. One fetch
// per process; subsequent mounts of useSkills() return the cached list and
// don't re-hit /api/skills. Matches the useSidebar / useChatStatus module-
// singleton pattern so the dropdown stays cheap even with many ChatInputs
// mounted at once.
let cachedSkills: Skill[] | null = null;
let inflight: Promise<Skill[]> | null = null;
const subscribers = new Set<(s: Skill[]) => void>();
async function loadSkills(): Promise<Skill[]> {
if (inflight) return inflight;
inflight = api.skills
.list()
.then((r) => {
cachedSkills = r.skills;
for (const sub of subscribers) {
try { sub(cachedSkills); } catch { /* swallow */ }
}
return cachedSkills;
})
.finally(() => { inflight = null; });
return inflight;
}
export function useSkills(): { skills: Skill[]; loaded: boolean } {
const [skills, setSkills] = useState<Skill[]>(cachedSkills ?? []);
const [loaded, setLoaded] = useState<boolean>(cachedSkills !== null);
useEffect(() => {
subscribers.add(setSkills);
if (cachedSkills === null) {
void loadSkills().then(() => setLoaded(true)).catch(() => setLoaded(true));
}
return () => { subscribers.delete(setSkills); };
}, []);
return { skills, loaded };
}

View File

@@ -19,6 +19,26 @@ function chatPane(chatId: string): WorkspacePane {
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 }; 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 { function loadPanes(sessionId: string): WorkspacePane[] | null {
try { try {
const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`); const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`);
@@ -33,7 +53,10 @@ function loadPanes(sessionId: string): WorkspacePane[] | null {
function savePanes(sessionId: string, panes: WorkspacePane[]): void { function savePanes(sessionId: string, panes: WorkspacePane[]): void {
try { try {
localStorage.setItem(`${STORAGE_KEY}.${sessionId}`, JSON.stringify(panes)); localStorage.setItem(
`${STORAGE_KEY}.${sessionId}`,
JSON.stringify(persistablePanes(panes)),
);
} catch { /* quota or disabled */ } } catch { /* quota or disabled */ }
} }
@@ -50,6 +73,10 @@ export interface UseWorkspacePanesResult {
closeAllTabs: (paneIdx: number) => void; closeAllTabs: (paneIdx: number) => void;
showLandingPage: (paneIdx: number) => void; showLandingPage: (paneIdx: number) => void;
addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => void; addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => void;
// Open-on-first-click, close-on-second-click. Singleton — settings panes
// don't count toward MAX_PANES. Closing the only remaining pane (edge case)
// falls back to an empty pane to preserve the "always one pane" invariant.
toggleSettingsPane: () => void;
removePane: (idx: number) => void; removePane: (idx: number) => void;
removeChatFromPanes: (chatId: string) => void; removeChatFromPanes: (chatId: string) => void;
initializeFirstChatIfEmpty: (chatId: string) => void; initializeFirstChatIfEmpty: (chatId: string) => void;
@@ -216,7 +243,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
return; return;
} }
setPanes((prev) => { 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`); toast.error(`Maximum ${MAX_PANES} panes`);
return prev; return prev;
} }
@@ -226,9 +254,35 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
}); });
}, []); }, []);
const toggleSettingsPane = useCallback(() => {
setPanes((prev) => {
const existingIdx = prev.findIndex((p) => p.kind === 'settings');
if (existingIdx < 0) {
const next = [...prev, settingsPane()];
setActivePaneIdx(next.length - 1);
return next;
}
if (prev.length <= 1) {
setActivePaneIdx(0);
return [emptyPane()];
}
const next = prev.filter((_, i) => i !== existingIdx);
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
return next;
});
}, []);
const removePane = useCallback((idx: number) => { const removePane = useCallback((idx: number) => {
setPanes((prev) => { setPanes((prev) => {
if (prev.length <= 1) return prev; if (prev.length <= 1) {
// Settings is the only kind that can be the last pane and still need
// closing (X / Esc / sidebar toggle). Fall back to empty.
if (prev[idx]?.kind === 'settings') {
setActivePaneIdx(0);
return [emptyPane()];
}
return prev;
}
const next = prev.filter((_, i) => i !== idx); const next = prev.filter((_, i) => i !== idx);
setActivePaneIdx((ai) => Math.min(ai, next.length - 1)); setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
return next; return next;
@@ -325,6 +379,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
closeAllTabs, closeAllTabs,
showLandingPage, showLandingPage,
addSplitPane, addSplitPane,
toggleSettingsPane,
removePane, removePane,
removeChatFromPanes, removeChatFromPanes,
initializeFirstChatIfEmpty, initializeFirstChatIfEmpty,

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, last_session_id: null,
status: 'archived' as const, status: 'archived' as const,
gitea_remote: fromSidebar.gitea_remote, 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, ...prev,
]; ];

View File

@@ -116,9 +116,31 @@ function SessionInner({ sessionId }: { sessionId: string }) {
event.session_id === sessionId event.session_id === sessionId
) { ) {
navigate(`/project/${event.project_id}`); 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;
}
// Sidebar Settings button broadcasts this when a session is mounted;
// toggleSettingsPane opens on first click, closes on second.
if (event.type === 'open_settings_pane') {
panesHook.toggleSettingsPane();
} }
}); });
}, [sessionId, editingName, navigate]); }, [sessionId, editingName, navigate, project, panesHook]);
// v1.8: URL ?pane= sync (mobile only). Lifted from Workspace.tsx so // v1.8: URL ?pane= sync (mobile only). Lifted from Workspace.tsx so
// MobileTabSwitcher's onSwitchPane can push the same URL state and the // MobileTabSwitcher's onSwitchPane can push the same URL state and the
@@ -211,15 +233,13 @@ function SessionInner({ sessionId }: { sessionId: string }) {
</div> </div>
{session && ( {session && (
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1 shrink-0"> <ModelPicker
<ModelPicker value={session.model}
value={session.model} onChange={async (model) => {
onChange={async (model) => { const updated = await api.sessions.update(session.id, { model });
const updated = await api.sessions.update(session.id, { model }); setSession(updated);
setSession(updated); }}
}} />
/>
</div>
)} )}
<button <button
@@ -337,6 +357,8 @@ function SessionInner({ sessionId }: { sessionId: string }) {
}} }}
panesHook={panesHook} panesHook={panesHook}
chatsHook={chatsHook} chatsHook={chatsHook}
session={session}
project={project}
/> />
)} )}
</div> </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/inter";
@import "@fontsource-variable/jetbrains-mono"; @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 *)); @custom-variant dark (&:is(.dark *));
:root { :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

@@ -1,337 +1,201 @@
# BooCode v1.x — Roadmap # BooCode — Roadmap
Last updated: 2026-05-16 Last updated: 2026-05-17
## Overview ## Overview
BooCode is a standalone code-chat tool at `/opt/boocode/`. Read-only by design — pick a project, chat with a local LLM that has file-inspection tools, get streaming responses over WebSocket. BooCode is a standalone code-chat tool at `/opt/boocode/`. Read-only by design in v1.x — pick a project, chat with a local LLM that has file-inspection tools, get streaming responses over WebSocket.
Live at `https://code.indifferentketchup.com` (Caddy → Authelia → Tailscale → `100.114.205.53:9500`). Live at `https://code.indifferentketchup.com` (Caddy → Authelia → Tailscale → `100.114.205.53:9500`).
**Architectural commitments:** **Architectural commitments:**
- No embeddings. The model uses file-view tools (`view_file`, `list_dir`, `grep`, `find_files`) + sidecar analyzers (codecontext, codesight). Walked away from the RAG pipeline May 2026. - No embeddings. File-view tools + sidecar analyzers replace RAG.
- Read-only in v1.x. Write tools land in BooCoder (separate container, post-v1.x). - Read-only in v1.x. Write tools land in BooCoder (separate container, post-v1.x).
- One Postgres (`boocode_db`), one frontend SPA, container-per-service for new capabilities. - One Postgres (`boocode_db`), one frontend SPA, container-per-service for new capabilities.
External code lifted from / referenced in: see `boocode_code_review.md` for full inventory. ## Current state
----- - **main:** v1.8.1 (`b09d0ff` was last known tip prior to v1.8.2).
- **Just merged / committed to main:** v1.8.2 — tool-loop fixes (read-only loop cap raised, "tool loop depth exceeded" error surfaced with continue button, `max_tool_calls` AGENTS.md frontmatter, `messages.metadata` column).
- **In flight RIGHT NOW:** **v1.x-themes** branch — Claude Code implementing 18-theme system. See "Active work" below.
## Active work
### v1.x-themes — Theme system (in flight)
**Spec source:** locked in this session. Anchors below derived from `/mnt/user-data/uploads/boocode-theme-previews.html` (16 themes extracted) + spec §3 family rules for the two missing (`fuchsia-noir`, `midnight-sapphire`).
**18 themes, grouped:**
| Family | IDs |
|---|---|
| Neutral dark | obsidian (default), gunmetal |
| Brown / warm | espresso, volcanic-brown |
| Orange / amber | copper, gold |
| Red | oxblood, crimson |
| Purple | elderflower, plum |
| Pink / magenta | steel-pink, fuchsia-noir |
| Green | matrix, sage |
| Blue | cobalt, midnight-sapphire |
| Light-only | ivory, chalk |
**Dark anchors (bg, card, border, muted-fg, accent):**
```
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
ivory #fdfcf8 #f5f2e8 #e8e4d8 #8a8478 #3a3328 (light-only)
chalk #fafaf7 #f0f0ec #e5e5e0 #75756e #2a2a28 (light-only)
```
**Light-variant derivation (for the 16 dark themes):**
- Lightest anchor → background
- Accent darkens ~15% (HSL L 15pp)
- Foreground = near-black tinted toward family hue
- Surfaces / borders scale up symmetrically
**Fallback:** `ivory` or `chalk` + dark mode → `obsidian` dark.
**Token map (shadcn nova set):**
```
background ← anchor 1
card / popover ← anchor 2
border / muted ← anchor 3
muted-foreground ← anchor 4
primary / accent ← anchor 5
foreground ← derived: anchor-5 hue, ~92% L, ~25% S
--destructive ← red family, unchanged across themes
--ring ← per-theme accent
--radius ← 0.5rem locked
fonts ← Inter + JetBrains Mono locked
```
**Wiring locked:**
- Schema: `settings.theme_id TEXT NOT NULL DEFAULT 'obsidian'`, `settings.theme_mode TEXT NOT NULL DEFAULT 'dark' CHECK IN ('dark','light','system')`
- API: GET `/api/settings` extended, PATCH whitelists 18 theme ids → 400 otherwise
- CSS: `apps/web/src/styles/themes/*.css` (18 + `_tokens.css`), imported from `globals.css` (NOT `index.css`)
- `.theme-<id>` + `.theme-<id>.dark` composed on `<html>`
- `apps/web/src/lib/theme.ts` (new): `THEMES` const, `applyTheme(id, mode)`, `useTheme()` hook. matchMedia subscribed only when `mode === 'system'`
- `apps/web/src/App.tsx`: `useTheme()` at top
- Settings page: card grid, mode toggle (radio: Dark/Light/System). No header dropdown.
- shadcn primitives: `card`, `radio-group` installed via `pnpm dlx shadcn@latest add`. `button`, `label` already present.
- FOUC mitigation: localStorage cache + inline `<script>` in `index.html` sets `<html>` class before React hydrates
**Out of scope (v1):**
- Custom user palettes (no color picker)
- Per-project / per-session themes
- Shiki syntax-highlighting themes
- Header quick-switcher
**Verify after Claude Code hands back:**
- `fuchsia-noir` and `midnight-sapphire` visual check — derived, not from preview. Swap hexes if they read wrong.
- Light variants of the 16 dark themes — algorithmic. Spot-check 3-4 across families (warm/cool/dark/saturated).
- FOUC on hard reload, theme-switch persistence, system-mode matchMedia teardown.
## Batch summary ## Batch summary
|Batch |Theme |Status |Branch / Notes | | Version | Theme | Status |
|------------------------------------------|-----------------------------------------------------------------------------------|-----------|---------------------------------------| |---|---|---|
|1 |Markdown, Copy + Regen, tok/s + ctx, AI naming |✅ Done |`v1.1-batch1` merged | | v1.0 | Initial scaffold, read-only tools, WS streaming | ✅ Merged |
|2 |Sidebar restructure |✅ Done |`v1.1-batch2` merged | | v1.1-batch1 | Markdown, Copy + Regen, tok/s + ctx, AI naming | ✅ Merged |
|3 |Pane system, FileBrowserPane + Shiki, cross-tab |✅ Done |`v1.1-batch3` merged | | v1.1-batch2 | Sidebar restructure | ✅ Merged |
|3.5 |Chip infrastructure, `@file`, line-select |✅ Done |merged | | v1.1-batch3 | Pane system, FileBrowserPane + Shiki, cross-tab | ✅ Merged |
|4 (v1.2) |Chats inside sessions, right-rail, `/compact`, archive, force-send |✅ Done |merged | | v1.1-batch3.5 | Chip infra, `@file`, line-select | ✅ Merged |
|4.14.4 |Project archive, sidebar context, Gitea API, bootstrap |✅ Done |merged | | v1.2 | Chats inside sessions, right-rail, `/compact`, archive, force-send | ✅ Merged |
|v1.5 cleanup |resolveProjectPath, BOOTSTRAP_ROOT, vitest pin |✅ Done |merged | | v1.2-project-ux | Project archive, sidebar context, Gitea API, bootstrap | ✅ Merged |
|v1.6 mobile |Drawer, single-pane, long-press, IME-safe, pull-to-refresh, swipe-close |✅ Done |merged | | v1.3 | Tab-close + chat-archive | ✅ Merged |
|v1.6.1 |RightRail mobile wrapper fix |✅ Done |merged | | v1.4 | Fork message, delete message, header polish (was original Batch 5) | ✅ Merged |
|Tool-loop bump |MAX_TOOL_LOOP_DEPTH 5→15 |✅ Done |merged | | v1.5 | resolveProjectPath, BOOTSTRAP_ROOT, vitest pin | ✅ Merged |
|v1.6.2 |Workspace + Session+Project headers + ChatTabBar new-chat + RightRail mobile drawer|🔄 In flight|`v1.6.2-mobile-ui-fixes` | | v1.5.1 | Bootstrap hotfix (git in container, SSH keypair, known_hosts) | ✅ Merged (`4a9f207`) |
|**v1.8 mobile tabs** |**Bottom-sheet pane switcher + cross-tab `pane_status` WS sync + StatusDot on tabs**|**Next up**|`v1.8-mobile-tabs`; hand-rolled sheet | | v1.6 | Mobile pass: drawer, single-pane, long-press, IME-safe, pull-to-refresh, swipe-close | ✅ Merged |
|9 (REORDERED, DECOUPLED) |Agents (Tier 2): `AGENTS.md`, per-agent temp/tools, picker in ChatInput toolbar |✅ Implemented, uncommitted|six builtins; on `main` awaiting commit| | v1.6.1 | RightRail mobile wrapper fix | ✅ Merged |
|5 |Fork message, delete message, header polish |Planned | | | Tool-loop bump | MAX_TOOL_LOOP_DEPTH 5→15 | ✅ Merged |
|6 |Drag-drop file + paste-as-attachment |Planned |thin extension of 3.5 chips | | v1.6.2 | Workspace + Session+Project headers, ChatTabBar new-chat, RightRail mobile drawer | ✅ Merged |
|7 |Settings drawer: system prompt, web search toggle, agent entry |Planned |adds SettingsDrawer agent entry (Batch 9 deferred half) | | v1.7 | Drag-drop file + paste-as-attachment (was Batch 6) | ✅ Merged |
|8 |Web search backend: SearXNG `web_search` + `web_fetch` |Planned | | | v1.8 | Settings drawer + `git_status` added to ALL_TOOL_NAMES (was Batch 7) | ✅ Merged |
|10 |BooTerm: separate container, xterm.js + node-pty + tmux |Planned | | | v1.8.1 | WS reconnect toast tuning (silent/gray/red thresholds), pane status indicators | ✅ Merged |
|11 — Architect: codebase map |codecontext sidecar + MCP tool wiring |Planned |from nmakod/codecontext | | v1.8.2 | Tool-loop fixes: read-only cap raised, "depth exceeded" error + continue, `max_tool_calls` frontmatter, `messages.metadata` | ✅ Merged |
|11b — Architect: repo health |call graph, circular deps, dead code |Planned |from spirituslab/codesight | | **v1.x-themes** | **18 themes, settings page, dark/light/system, FOUC mitigation** | **🔄 Claude Code in flight** |
|12 — Tool approval + plan/act mode |Read-only invariant, per-tool gating |Planned |from cline | | v1.8.3 | Tool call UI compaction: collapse-by-default, group consecutive same-tool, result preview cap | Planned (small, frontend-only) |
|13 — Append-only event log |Replace messages-table semantics |Planned |from OpenHands V1 | | v1.9 | Settings pane (system prompt per project + session, web search toggle, `+` button) | Planned (spec locked, was on branch `v1.9-settings-pane`) |
|14 — BooCoder: pending changes |Sandboxed edit queue, atomic apply |Post-v1.x |from plandex | | v1.10 | Web search backend: SearXNG `web_search` + `web_fetch` | Planned |
|15 — BooCoder runtime isolation |Per-session Docker sandbox |Post-v1.x |from OpenHands | | v1.11 | Agents Tier 2: `AGENTS.md`, per-agent temp/tools whitelist, AgentPicker in ChatInput | Planned |
|16 — Multi-provider LLM |Optional litellm-style abstraction |Optional |from pi-ai | | v1.12 | BooTerm: separate container, xterm.js + node-pty + tmux | Planned |
|17 — Workflow graphs |Multi-agent coordination |Far future |from microsoft/agent-framework concepts| | v1.13 | Architect: codecontext sidecar (MCP, tree-sitter, no embeddings) | Planned |
| v1.13b | Architect: repo health (call graph, circular deps, dead code) | Planned |
**Old Batch 12 (codebase indexer w/ Harrier embeddings) — REMOVED.** Replaced by Batch 11/11b sidecar approach. See `boocode_code_review.md` decisions log. | v1.14 | Tool approval + plan/act mode (cline-style) | Planned |
| Post-v1.x | Append-only event log (OpenHands V1) | Planned |
**Batch 9 reordered ahead of 58, 10.** Picker mounts in `ChatInput.tsx` toolbar only. SettingsDrawer agent entry rolled into Batch 7 when it lands. No UI dependency on Batches 5/6/7, so it can ship anytime after v1.6.2. | Post-v1.x | BooCoder pending-changes (plandex) | Planned |
| Post-v1.x | BooCoder runtime isolation (per-session Docker sandbox) | Planned |
----- | Optional | Multi-provider LLM abstraction (pi-ai) | Skip unless need surfaces |
| Far future | Workflow graphs (microsoft/agent-framework concepts) | v2.x topic |
## Batch details (planned / new)
## Flagged follow-ups (not in a batch yet)
### Batch 9 — Agents (Tier 2, DECOUPLED)
- Agents in `/data/AGENTS.md` don't list `git_status` in their `tools:` blocks. Out of scope until pre-BooCoder cleanup pass.
**Spec:** `boocode_batch9.md` with the deltas below. - v1.9 dispatch had item (g): verify `useUserEvents` broadcasts `project_updated` on PATCH `/projects/:id`. Add if missing.
- v1.8.2 follow-up: confirm `messages.metadata` migration ran clean in prod DB after deploy.
**Status:** Next up after v1.6.2 merges. Decoupled from Batch 7.
**Deltas from `boocode_batch9.md`:**
1. Builtin defaults in `agents.ts` OMIT the `model` field. Resolution order makes `session.model` win when `agent.model` is null. Spec line 30 example is misleading — do not hardcode any model in builtins.
2. Builtin defaults are the six agents shipped in `/opt/boocode/AGENTS.md`: **Code Reviewer, Debugger, Refactorer, Architect, Security Auditor, Prompt Builder.** If project root `AGENTS.md` exists, only its agents show. If absent, show the six builtins.
3. AgentPicker mounts in `ChatInput.tsx` toolbar between ModelPicker and the `+` button. **No `SettingsDrawer.tsx` or `Header.tsx` changes in this batch.**
4. SettingsDrawer agent entry + Header active-agent badge moved to Batch 7.
**Files to create:**
- `apps/server/src/services/agents.ts` — parser, six builtin defaults, mtime-keyed cache.
- `apps/server/src/routes/agents.ts``GET /api/projects/:id/agents`.
- `apps/web/src/components/AgentPicker.tsx` — dropdown, matches ModelPicker pattern.
**Files to modify:**
- `apps/server/src/schema.sql``ALTER TABLE sessions ADD COLUMN IF NOT EXISTS agent_id TEXT;`
- `apps/server/src/services/inference.ts` — resolution order: `effective_system_prompt`, `effective_model`, `effective_temperature`, `effective_tools` from session + agent + project. Filter tools array against agent whitelist before sending to llama-swap.
- `apps/server/src/routes/sessions.ts` — PATCH accepts `agent_id`.
- `apps/server/src/types/api.ts` — Agent type, extend Session with `agent_id`.
- `apps/web/src/api/client.ts`, `apps/web/src/api/types.ts` — Agent type, `api.agents.list(projectId)`.
- `apps/web/src/components/ChatInput.tsx` — mount AgentPicker.
**Testing plan (manual, before locking temps):**
- Drop `/opt/boocode/AGENTS.md` (six agents, no `model` field on any).
- For each of the 7 keeper models, switch session model and run the same target prompt against each agent. Log tok/s, instruction-following quality.
- Adjust per-agent temperature in `AGENTS.md` based on results.
- A/B candidates: qwen3.6-35b-a3b-mxfp4 (daily), qwopus3.6-35b-a3b-q4 (reasoning), qwopus3.5-27b-q4, qwen3.6-27b-ud-q4-xl, nemotron-3-nano-30b, gemma-4-26b-a4b-mxfp4, qwen3-coder-30b-apex.
**Dependencies:** v1.6.2 merged.
-----
### Batch 11 — Architect: codebase map (REVISED)
**Inspiration / lift:** `nmakod/codecontext` (MIT, Go binary).
**What it gives BooCode:** an architect-grade codebase overview without embeddings. Codecontext parses the repo with tree-sitter, extracts symbols, builds import/dependency relationships, and exposes the result via an MCP server with 8 tools. The model gets a structural map of any codebase on demand.
**Why this replaces the original Batch 11 (aider PageRank port):** codecontext is a finished binary in our stack language (Go), with watch mode, incremental updates, framework detection, and git-co-change-based semantic neighborhoods (no embeddings). The aider port would be reimplementing what codecontext already ships.
**Scope:**
- Add `codecontext` sidecar container to `docker-compose.yml`. Mount the project root read-only. One sidecar per BooCode instance — projects are addressed by absolute path.
- Wire each codecontext MCP tool into BooCodes `inference/tools.ts` as a native tool the model can call:
- `repo_overview(project_id)` → codecontext `get_codebase_overview`
- `repo_file_analysis(project_id, path)``get_file_analysis`
- `repo_symbol_info(project_id, symbol)``get_symbol_info`
- `repo_search_symbols(project_id, query)``search_symbols`
- `repo_dependencies(project_id, path)``get_dependencies`
- `repo_semantic_neighborhoods(project_id, path)``get_semantic_neighborhoods` (git co-change)
- `repo_framework_analysis(project_id)``get_framework_analysis`
- `path_guard.ts` extension: incorporate `continuedev/continue` `DEFAULT_SECURITY_IGNORE_FILETYPES` so codecontext cant surface `.env`, `.pem`, keys, etc.
- Fallback grammars: drop `Aider-AI/aider`s `aider/queries/tree-sitter-*.scm` files for any language codecontext doesnt cover. Use them via an in-process tree-sitter wrapper *only if* a project needs an unsupported language. Defer wrapper build until thats an actual gap.
**Where it goes:** new `apps/server/src/architect/` directory. No new tables — codecontext maintains its own state on disk. New env: `CODECONTEXT_URL=http://codecontext:8765` (MCP endpoint).
**Decisions to make at recon time:**
- Bundle the binary directly in the BooCode Dockerfile, or run codecontext as its own service? Sidecar is cleaner. Bundle is one less container.
- How does the model discover codecontext tools — register them statically in the tools registry, or proxy MCP `tools/list` at startup?
**Dependencies:** none. Can ship before Batches 510.
-----
### Batch 11b — Architect: repo health (NEW)
**Inspiration / lift:** `spirituslab/codesight` (MIT-ish, TS/Node).
**What it gives BooCode:** complement to Batch 11. Where codecontext answers “what is this codebase,” repo health answers “whats wrong with this codebase.” Call graph, circular dependency detection, dead code flagging.
**Scope:**
- Port codesights `analyze.mjs` analyzer core into `apps/server/src/architect/repo_health.ts`. Drop the VS Code extension shell. Keep:
- Symbol extraction (already overlaps codecontext — call codecontext where possible, only redo whats needed for graph edges).
- Call graph builder (function-to-function edges).
- Circular dependency detector.
- Dead code detector (exported symbols never imported or called).
- New tool: `repo_health(project_id)` returning `{ circular_dependencies: [...], dead_code: [...] }`. Output respects codesights documented false-positive caveats (customElements.define, framework entry points, dynamic imports) — surface those in the tool description so the model doesnt trust dead-code flags blindly.
- Cache results in `boocode_db` keyed by `(project_id, file_hashes)`. Invalidate on file change via file-index hash check.
**Decisions:**
- Build it in-process (Node) vs spawn a CLI? In-process is simpler. Spawn matches codecontext sidecar pattern but adds latency.
**Dependencies:** Batch 11 merged (so we can reuse codecontexts parse output where possible). Can be deferred until after Batches 510.
-----
### Batch 12 — Tool approval gating + plan/act mode
**Inspiration / lift:** `cline/cline` (Apache-2.0).
**What it gives BooCode:** per-session control over which tools the model can call. Lays the groundwork for BooCoder by building the gating mechanism before there are any write tools to gate.
**Scope:**
- New column `sessions.tool_approval_mode TEXT` — values: `read_only` (v1.x default), `plan`, `act_auto`, `act_approve`.
- New column `sessions.approved_tools JSONB` — per-session whitelist for `act_approve` mode.
- Tool registry refactor: tools tagged `read_only` or `write`. In `read_only` mode (v1.x), write tools never appear in the models tools array. In `plan` mode, same — write tools hidden, model produces a plan only. `act_*` modes unlock writes (post-v1.x).
- UI: mode picker in SettingsDrawer (Batch 7 dependency). Inline indicator in chat header.
**Dependencies:** Batch 7 (SettingsDrawer).
-----
### Batch 13 — Append-only event log
**Inspiration / lift:** `OpenHands/OpenHands` V1 (MIT).
**What it gives BooCode:** replaces the ad-hoc `messages` table semantics with a typed event stream. Unlocks rewind, time-travel, and clean handoff semantics for multi-agent flows.
**Scope:**
- New `session_events` table: `(id, session_id, ts, kind, payload JSONB, parent_id)`. Event kinds: `user_message`, `assistant_message`, `tool_call`, `tool_result`, `pane_action`, `mode_change`, `system`.
- Existing `messages` table becomes a derived view over `session_events` for backward compatibility, then deprecated over a release.
- Inference loop emits events instead of mutating message rows.
- Frontend `useSessionStream` reducer rewritten to consume events.
**Migration is non-trivial.** Plan in a dedicated batch with explicit cutover window.
**Dependencies:** Batches 5 (fork/delete) and 7 (settings) merged. Must not be in flight with other backend work.
-----
### Batch 14 — BooCoder: pending changes
**Inspiration / lift:** `plandex-ai/plandex` (MIT).
**What it gives BooCode:** safe write tools. Edits queue in a virtual layer; nothing touches the filesystem until explicit `/apply`.
**Scope:**
- New container `boocoder` at `100.114.205.53:9502`. Owns write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`).
- New table `pending_changes (id, session_id, file_path, diff TEXT, status, created_at)`. Status: `pending`, `applied`, `rejected`.
- Tools execute against the pending-changes layer, not the filesystem. `apply_pending` is the only path that touches disk. `rewind` rolls back a `pending`-id back to disk state.
- BooCode chat container stays read-only (`/opt:/opt:ro`). BooCoder mounts `/opt/repos:/opt/repos:rw` and uses git worktree pattern from paseo for isolation.
- Frontend: new pane kind `pending_diff` shows the queued diff inline with Approve/Reject per chunk.
**Dependencies:** Batches 12 (gating) + 13 (events). Dont start until both are live.
-----
### Batch 15 — BooCoder runtime isolation
**Inspiration / lift:** `OpenHands/OpenHands` (MIT).
**What it gives BooCode:** per-session Docker sandbox for BooCoder writes. Closes the `/opt:ro` mount risk identified in v1.x open items.
**Scope:**
- Per-session container spawned by BooCoder on first write. Container has only the projects path mounted, not `/opt`.
- Container lifecycle: spawn on first write call, idle-timeout after 30 min, recreate on resume.
- Action execution server pattern: HTTP API inside the container, BooCoder calls in. Standard OpenHands runtime contract.
**Dependencies:** Batch 14.
-----
### Batch 16 — Multi-provider LLM abstraction
**Inspiration / lift:** `earendil-works/pi` `pi-ai` (MIT).
**What it gives BooCode:** optional non-llama-swap inference paths (Anthropic, OpenAI, Mistral direct). Currently we have one provider (llama-swap) and the existing `streamCompletion` is hardcoded to OpenAI-compatible at that endpoint.
**Scope:**
- Provider abstraction: `interface LLMProvider { stream(req): AsyncIterator<Frame> }`.
- Built-in: llama-swap (current), Anthropic, OpenAI (Codex-style).
- Per-session `provider_id` column.
**Status:** **Optional. Skip unless a concrete need surfaces.** llama-swap covers daily driver work.
-----
### Batch 17 — Workflow graphs
**Inspiration / lift:** `microsoft/agent-framework` (MIT) — concepts only.
**What it gives BooCode:** multi-agent coordination. Architect → Coder → Reviewer → Verifier handoffs orchestrated by a YAML-defined workflow.
**Status:** **Far future.** Read agent-frameworks `docs/decisions/` ADRs. Dont port code — Azure/.NET-heavy.
**Dependencies:** Batches 12 (modes), 13 (events). Realistically a v2.x topic.
-----
## Order of operations ## Order of operations
Two tracks. Pick one to drive next. 1. **v1.x-themes** finishes (Claude Code in flight). Audit + smoke test. Merge.
2. **v1.8.3** — tool call UI compaction. Small frontend batch, addresses current pain.
3. **v1.9** — settings pane. Branch already named `v1.9-settings-pane`. Spec locked.
4. **v1.10** — web search backend.
5. **v1.11** — agents.
6. **v1.12** — BooTerm.
**Track A — Finish v1.x mobile + polish then agents:** Track B (architect, no UI dep, can run parallel anytime): v1.13 → v1.13b → v1.14.
- v1.6.2 ships (in flight)
- **Batch 9 (agents)** — decoupled, can land next; no UI dependency on 5/6/7
- Batches 5, 6, 7, 8 in order. Each is small, frontend-heavy, no architecture risk. Batch 7 absorbs SettingsDrawer agent entry.
**Track B — Begin architect capabilities in parallel:**
- Batch 11 (codecontext sidecar) — biggest single capability jump. Frontend stays the same; new tools appear to the model.
- Batch 11b (repo health) — follow-up.
- Batch 12 (gating) — sets up everything post-v1.x.
Recommendation: ship v1.6.2, then **Batch 9 (agents)** next so the test bed exists before Track A continues. Then Track A through Batch 7. Batch 11 can run in parallel with Batches 810 since 11 has no UI dependency.
-----
## Architecture target state ## Architecture target state
### Containers | Container | Port | Mount | Purpose | Status |
|---|---|---|---|---|
| `boocode` | `100.114.205.53:9500` | `/opt:/opt:ro` | Chat + read-only tools + SPA | Live |
| `boocode_db` | `127.0.0.1:5500` | `boocode_pgdata` volume | Postgres 16-alpine | Live |
| `codecontext` | `100.114.205.53:8765` (internal) | project root :ro | MCP server for architect tools | v1.13 |
| `booterm` | `100.114.205.53:9501` | `/opt/repos:/opt/repos:rw` | Terminals (tmux + node-pty) | v1.12 |
| `boocoder` | `100.114.205.53:9502` | per-session sandbox | Write tools | Post-v1.x |
|Container |Port |Mount |Purpose |Status | ## Schema additions ahead
|-------------|--------------------------------|--------------------------|------------------------------|--------|
|`boocode` |`100.114.205.53:9500` |`/opt:/opt:ro` |Chat + read-only tools + SPA |Live |
|`boocode_db` |`127.0.0.1:5500` |`boocode_pgdata` volume |Postgres 16-alpine |Live |
|`codecontext`|`100.114.205.53:8765` (internal)|project root :ro |MCP server for architect tools|Batch 11|
|`booterm` |`100.114.205.53:9501` |`/opt/repos:/opt/repos:rw`|Terminals (tmux + node-pty) |Batch 10|
|`boocoder` |`100.114.205.53:9502` |per-session sandbox |Write tools |Batch 14|
### Schema additions - v1.x-themes (current): `settings.theme_id`, `settings.theme_mode`
- v1.9: `projects.default_system_prompt`, `projects.default_web_search_enabled`, `sessions.web_search_enabled`
**Batch 9:** `sessions.agent_id TEXT` (nullable; references AGENTS.md by slug). - v1.11: `sessions.agent_id`
**Batch 11:** none (codecontext stateless on disk). - v1.13b: `repo_health_cache (project_id, file_hashes_sig, payload JSONB, created_at)`
**Batch 11b:** `repo_health_cache (project_id, file_hashes_sig, payload JSONB, created_at)`. - v1.14: `sessions.tool_approval_mode`, `sessions.approved_tools`
**Batch 12:** `sessions.tool_approval_mode`, `sessions.approved_tools`. - Post-v1.x: `session_events`; deprecate `messages` long-tail
**Batch 13:** `session_events`; deprecate `messages` long-tail. - Post-v1.x: `pending_changes`
**Batch 14:** `pending_changes`.
-----
## Lift sources (summary)
Full inventory in `boocode_code_review.md`. Headline items:
|Source |Used for |Where |
|--------------------------------------|----------------------------------------|---------------------|
|nmakod/codecontext (MIT, Go) |Architect: codebase map sidecar |Batch 11 |
|spirituslab/codesight (MIT-ish, TS) |Architect: repo health analyzer |Batch 11b |
|Aider-AI/aider (Apache-2.0) |Fallback `.scm` grammars (60+ languages)|Batch 11 (fallback) |
|continuedev/continue (Apache-2.0) |DEFAULT_SECURITY_IGNORE_FILETYPES |Batch 11 prep |
|cline/cline (Apache-2.0) |Plan/Act mode pattern |Batch 12 |
|plandex-ai/plandex (MIT) |Pending-changes data model |Batch 14 |
|OpenHands/OpenHands (MIT) |Event log + sandbox runtime |Batches 13, 15 |
|aimasteracc/tree-sitter-analyzer (MIT)|Outline-first response patterns |Reference |
|earendil-works/pi (MIT) |Multi-provider LLM |Batch 16 (optional) |
|rshah515/claude-code-subagents (MIT) |Reference for builtin agent prompts |Batch 9 (six builtins)|
|microsoft/agent-framework (MIT) |Workflow concepts |Batch 17 (far future)|
-----
## Decisions log ## Decisions log
- **Embeddings dropped from BooCode.** Replaced RAG with file-view tools + sidecar analyzers. - Embeddings dropped from BooCode. File-view tools + sidecar analyzers replace RAG.
- **Original Batch 11 (aider PageRank port) replaced** by codecontext sidecar approach. - Old Batch 11 (aider PageRank port) replaced by codecontext sidecar (v1.13).
- **Original Batch 12 (codebase indexer w/ Harrier) removed** entirely. No embedding infrastructure in BooCode v1.x. - Old Batch 12 (Harrier indexer) → removed entirely.
- **Globstar parked** — not an architect tool, future verify-before-commit candidate only. - Batch 9 reordered ahead of 58, decoupled from Batch 7 (2026-05-16). Subsequently superseded — settings pane (v1.9) and themes (v1.x-themes) jumped ahead. Agents now slated as v1.11.
- **codeprysm rejected** — embedding-based; node/edge taxonomy noted as reference if we ever build our own graph. - Theme work split into its own version (v1.x-themes) rather than blocked behind v1.9 (2026-05-17). Branched off main after v1.8.2 committed.
- **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.
-----
## Workflow ## Workflow
Each batch: Each batch:
1. Verify previous merged.
1. Verify previous batch merged. 2. Dispatch via Paseo to Claude Code at `/opt/boocode` (or OpenCode for smaller batches).
2. Dispatch via Paseo to Claude Code at `/opt/boocode`. 3. Recon → blocking questions → implement → hand back.
3. Claude Code recon → blocking questions → implement → hand back.
4. Compliance review in separate Claude chat. 4. Compliance review in separate Claude chat.
5. Deploy: `docker compose up --build -d`. 5. Deploy: `docker compose up --build -d`.
6. Smoke test. 6. Smoke test.

View File

@@ -9,15 +9,11 @@ services:
environment: environment:
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
volumes: volumes:
# Read-only mount for legacy/existing project add-existing flow. - /opt:/opt
- /opt:/opt:ro
# Writable mount only for the create-new-project bootstrap target.
# Host must `mkdir -p /opt/projects` before container start.
- /opt/projects:/opt/projects:rw - /opt/projects:/opt/projects:rw
- ./secrets/boocode_gitea:/root/.ssh/id_ed25519:ro - ./secrets/boocode_gitea:/root/.ssh/id_ed25519:ro
# v1.8.1: global agents file. Host seeds it once before deploy: - ./data:/data
# cp /opt/boocode/AGENTS.md /opt/boocode/data/AGENTS.md - /opt/skills:/data/skills
- ./data:/data:ro
depends_on: depends_on:
- boocode_db - boocode_db
networks: networks:

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