Compare commits

...

14 Commits

Author SHA1 Message Date
d85b17081e v1.9.7: ask_user_input elicitation tool 2026-05-18 02:15:18 +00:00
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
64 changed files with 4907 additions and 423 deletions

View File

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

View File

@@ -3,7 +3,6 @@
## Code Reviewer
---
temperature: 0.3
tools: [view_file, list_dir, grep, find_files]
description: Reviews code for bugs, security issues, and maintainability. Read-only.
---
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
---
temperature: 0.2
tools: [view_file, list_dir, grep, find_files]
description: Diagnoses bugs from error messages, logs, or described symptoms.
---
You diagnose bugs. Form a hypothesis, prove it with evidence from the code.
@@ -62,7 +60,6 @@ Output:
## Refactorer
---
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.
---
You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code.
@@ -95,7 +92,6 @@ Output:
## Architect
---
temperature: 0.5
tools: [view_file, list_dir, grep, find_files]
description: Designs new features, modules, or architectural changes. Outputs a build plan.
---
You design. You produce build plans, not code.
@@ -128,7 +124,6 @@ Output:
## Security Auditor
---
temperature: 0.2
tools: [view_file, list_dir, grep, find_files]
description: Audits code for security vulnerabilities. Read-only.
---
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
---
temperature: 0.4
tools: [view_file, list_dir, grep, find_files]
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.

View File

@@ -15,8 +15,10 @@ import { registerSidebarRoutes } from './routes/sidebar.js';
import { registerWebSocket } from './routes/ws.js';
import { registerModelRoutes } from './routes/models.js';
import { registerAgentRoutes } from './routes/agents.js';
import { registerSkillsRoutes } from './routes/skills.js';
import { createInferenceRunner } from './services/inference.js';
import { createBroker } from './services/broker.js';
import { listSkills } from './services/skills.js';
async function main() {
const config = loadConfig();
@@ -62,6 +64,15 @@ async function main() {
registerSidebarRoutes(app, sql);
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(
{
sql,
@@ -112,6 +123,36 @@ async function main() {
chat_id: chatId,
});
},
publishSessionFrame: (sessionId, frame) => {
broker.publish(sessionId, frame);
},
});
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);

View File

@@ -123,6 +123,53 @@ export function registerChatRoutes(
}
);
// v1.9: bulk-archive every open chat in a session. Mirrors the single
// /chats/:id/archive shape — N chat_archived frames published, useSidebar
// reducer handles each via the existing case.
app.post<{ Params: { id: string } }>(
'/api/sessions/:id/chats/archive-all',
async (req, reply) => {
const session = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`;
if (session.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
const rows = await sql<{ id: string }[]>`
UPDATE chats
SET status = 'archived', updated_at = clock_timestamp()
WHERE session_id = ${req.params.id} AND status = 'open'
RETURNING id
`;
const ids = rows.map((r) => r.id);
for (const id of ids) {
broker.publishUser('default', {
type: 'chat_archived',
chat_id: id,
session_id: req.params.id,
});
}
return { archived: ids.length, ids };
}
);
// v1.9: count helper for the confirm dialog.
app.get<{ Params: { id: string } }>(
'/api/sessions/:id/chats/open-count',
async (req, reply) => {
const session = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`;
if (session.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
const rows = await sql<{ count: number }[]>`
SELECT COUNT(*)::int AS count
FROM chats
WHERE session_id = ${req.params.id} AND status = 'open'
`;
return { count: rows[0]?.count ?? 0 };
}
);
app.post<{ Params: { id: string } }>(
'/api/chats/:id/archive',
async (req, reply) => {

View File

@@ -1,7 +1,7 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Chat, Message, Session } from '../types/api.js';
import type { Chat, Message, Session, ToolCall } from '../types/api.js';
const SendBody = z.object({
content: z.string().min(1).max(64_000),
@@ -14,6 +14,39 @@ const ContinueBody = z.object({
sentinel_message_id: z.string().uuid(),
});
// Batch 9.7: ask_user_input answer submission. Defensive shape — the question
// content is echoed back for traceability but the server does NOT trust it
// (the source of truth is the assistant message's tool_calls.args.questions).
const AnswerUserInputBody = z.object({
tool_call_id: z.string().min(1),
answers: z
.array(
z.object({
question: z.string(),
selected_options: z.array(z.string()),
free_text: z.string().nullable(),
}),
)
.min(1)
.max(3),
});
// Same shape the model declared via the tool's zod input. Re-derived here so
// the route can validate args without depending on services/tools.ts (which
// would pull in fs/path_guard for nothing).
const AskUserInputArgs = z.object({
questions: z
.array(
z.object({
question: z.string(),
type: z.enum(['single_select', 'multi_select']),
options: z.array(z.string()).min(1),
}),
)
.min(1)
.max(3),
});
interface MessageHandlers {
enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
enqueueCompact: (sessionId: string, chatId: string, compactMessageId: string, user: string) => void;
@@ -24,6 +57,13 @@ interface MessageHandlers {
content: string
) => void;
publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void;
// Batch 9.7: lets the answer endpoint emit the tool_result frame that the
// pause path intentionally skipped. Matches SkillInvokeHandlers in
// routes/skills.ts so index.ts can pass the same broker.publish adapter.
publishSessionFrame: (
sessionId: string,
frame: Record<string, unknown> & { type: string }
) => void;
cancelInference: (sessionId: string, chatId: string) => Promise<boolean>;
hasActiveInference: (chatId: string) => boolean;
}
@@ -389,4 +429,169 @@ export function registerMessageRoutes(
return result;
}
);
// Batch 9.7: resume an ask_user_input pause. Validates the body matches the
// question shape the model declared, UPDATEs the pending tool row's
// tool_results to the AnswerSet, publishes the deferred tool_result frame,
// and enqueues the next assistant turn. Error codes per spec:
// 400 invalid_body / mismatched_answer_shape
// 404 chat_not_found / unknown_tool_call_id
// 409 tool_call_already_answered
app.post<{ Params: { id: string } }>(
'/api/chats/:id/answer_user_input',
async (req, reply) => {
const parsed = AnswerUserInputBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid_body', details: parsed.error.flatten() };
}
const { tool_call_id, answers } = parsed.data;
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;
// Find the assistant message that emitted this tool_call. Scoped by
// chat_id + role to avoid cross-chat lookups; ordered by created_at DESC
// because the most recent issuance wins when an LLM reuses call IDs
// across turns (the older, already-answered one is a different row with
// populated tool_results downstream).
const callerRows = await sql<{ id: string; tool_calls: ToolCall[] | null }[]>`
SELECT id, tool_calls FROM messages
WHERE chat_id = ${chat.id}
AND role = 'assistant'
AND tool_calls IS NOT NULL
ORDER BY created_at DESC
`;
let foundCall: ToolCall | null = null;
for (const row of callerRows) {
const match = row.tool_calls?.find((tc) => tc.id === tool_call_id);
if (match) {
foundCall = match;
break;
}
}
if (!foundCall) {
reply.code(404);
return { error: 'unknown_tool_call_id' };
}
if (foundCall.name !== 'ask_user_input') {
reply.code(400);
return { error: 'tool_call_not_ask_user_input' };
}
// Validate the args themselves — the LLM could have emitted bad JSON.
const argsParsed = AskUserInputArgs.safeParse(foundCall.args);
if (!argsParsed.success) {
reply.code(400);
return { error: 'mismatched_answer_shape', detail: 'tool_call args invalid' };
}
const questions = argsParsed.data.questions;
if (answers.length !== questions.length) {
reply.code(400);
return {
error: 'mismatched_answer_shape',
detail: `expected ${questions.length} answer(s), got ${answers.length}`,
};
}
for (let i = 0; i < questions.length; i++) {
const q = questions[i]!;
const a = answers[i]!;
for (const sel of a.selected_options) {
if (!q.options.includes(sel)) {
reply.code(400);
return {
error: 'mismatched_answer_shape',
detail: `answer ${i + 1} contains option not in question: ${sel}`,
};
}
}
if (q.type === 'single_select' && a.selected_options.length > 1) {
reply.code(400);
return {
error: 'mismatched_answer_shape',
detail: `answer ${i + 1} has multiple selections on single_select`,
};
}
const hasOpt = a.selected_options.length > 0;
const hasText = a.free_text !== null && a.free_text.trim().length > 0;
if (!hasOpt && !hasText) {
reply.code(400);
return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} is empty` };
}
}
// Find the pending tool row. ORDER BY created_at DESC + LIMIT 1 picks
// the most recent row with this tool_call_id; the already-answered
// check below guards against UPDATE-ing a stale answer.
const toolRows = await sql<{
id: string;
tool_results: { tool_call_id: string; output: unknown } | null;
}[]>`
SELECT id, tool_results FROM messages
WHERE chat_id = ${chat.id}
AND role = 'tool'
AND tool_results->>'tool_call_id' = ${tool_call_id}
ORDER BY created_at DESC
LIMIT 1
`;
const toolRow = toolRows[0];
if (!toolRow) {
reply.code(404);
return { error: 'unknown_tool_call_id', detail: 'tool message not found' };
}
if (toolRow.tool_results && toolRow.tool_results.output !== null) {
reply.code(409);
return { error: 'tool_call_already_answered' };
}
const answerSet = { answers };
const newToolResults = {
tool_call_id,
output: answerSet,
truncated: false,
};
const result = await sql.begin(async (tx) => {
await tx`
UPDATE messages
SET tool_results = ${tx.json(newToolResults as never)}
WHERE id = ${toolRow.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 {
tool_message_id: toolRow.id,
assistant_message_id: assistantMsg!.id,
};
});
// Publish the deferred tool_result frame. useSessionStream's reducer
// updates the matching tool_run.result so AskUserInputCard flips into
// its read-only "answered" mode without a refetch.
handlers.publishSessionFrame(sessionId, {
type: 'tool_result',
tool_message_id: result.tool_message_id,
tool_call_id,
chat_id: chat.id,
output: answerSet,
truncated: false,
});
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
reply.code(202);
return result;
},
);
}

View File

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

View File

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

View File

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

View File

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

@@ -165,3 +165,17 @@ ALTER TABLE sessions ADD COLUMN IF NOT EXISTS agent_id TEXT;
-- 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

@@ -22,6 +22,7 @@ function makeSession(overrides: Partial<Session> = {}): Session {
created_at: new Date(0).toISOString(),
updated_at: new Date(0).toISOString(),
agent_id: null,
web_search_enabled: null,
...overrides,
};
}
@@ -35,6 +36,8 @@ function makeProject(overrides: Partial<Project> = {}): Project {
last_session_id: null,
status: 'open',
gitea_remote: null,
default_system_prompt: '',
default_web_search_enabled: false,
...overrides,
};
}

View File

@@ -11,7 +11,17 @@ const GLOBAL_AGENTS_PATH = '/data/AGENTS.md';
const CACHE_TTL_MS = 60_000;
// 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.
// Batch 9.7: ask_user_input added — same opt-in semantics. Agents with an
// explicit tools list that omits it cannot trigger the interactive picker.
const ALL_TOOL_NAMES = [
'view_file', 'list_dir', 'grep', 'find_files', 'git_status',
'skill_find', 'skill_use', 'skill_resource',
'ask_user_input',
] as const;
const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
const DEFAULT_TEMPERATURE = 0.7;

View File

@@ -149,9 +149,11 @@ export interface InferenceContext {
publishUser: (frame: UserStreamFrame) => void;
}
// Resolution order: base prompt < agent.system_prompt < session.system_prompt.
// Agent prompts layer on top of the base; session prompt is the most specific
// override and stacks last so callers can append per-session instructions.
// Resolution order: base prompt < agent.system_prompt < user prompt, where
// user prompt = session.system_prompt if non-empty, else project's
// default_system_prompt if non-empty, else nothing. Empty/whitespace-only
// counts as "no override" for both layers (v1.9 inherit semantics — keeps
// the column non-nullable so the existing key/value store stays put).
export function buildSystemPrompt(
project: Project,
session: Session,
@@ -161,8 +163,11 @@ export function buildSystemPrompt(
if (agent && agent.system_prompt.trim().length > 0) {
out += '\n\n' + agent.system_prompt.trim();
}
if (session.system_prompt && session.system_prompt.trim().length > 0) {
out += '\n\n' + session.system_prompt.trim();
const sessionPrompt = session.system_prompt?.trim() ?? '';
const projectPrompt = project.default_system_prompt?.trim() ?? '';
const userPrompt = sessionPrompt || projectPrompt;
if (userPrompt.length > 0) {
out += '\n\n' + userPrompt;
}
return out;
}
@@ -240,14 +245,16 @@ async function loadContext(
chatId: string
): Promise<{ session: Session; project: Project; history: Message[] } | null> {
const sessionRows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at,
agent_id, web_search_enabled
FROM sessions WHERE id = ${sessionId}
`;
if (sessionRows.length === 0) return null;
const session = sessionRows[0]!;
const projectRows = await sql<Project[]>`
SELECT id, name, path, added_at, last_session_id
SELECT id, name, path, added_at, last_session_id, status, gitea_remote,
default_system_prompt, default_web_search_enabled
FROM projects WHERE id = ${session.project_id}
`;
if (projectRows.length === 0) return null;
@@ -658,6 +665,12 @@ async function executeToolPhase(
model: session.model,
});
// Batch 9.7: ask_user_input pauses the loop. The tool row is still inserted
// (the answer endpoint needs a target row to UPDATE), but tool_results is
// pre-stamped with output=null as a "pending" sentinel and no tool_result
// frame goes out — the card renders from the tool_call frame alone. Mixed
// batches still execute the other tools normally.
let pausingForUserInput = false;
await Promise.all(
toolCalls.map(async (tc) => {
const [toolRow] = await ctx.sql<{ id: string }[]>`
@@ -666,6 +679,16 @@ async function executeToolPhase(
RETURNING id
`;
const toolMessageId = toolRow!.id;
if (tc.name === 'ask_user_input') {
pausingForUserInput = true;
const sentinel = { tool_call_id: tc.id, output: null, truncated: false };
await ctx.sql`
UPDATE messages
SET tool_results = ${ctx.sql.json(sentinel as never)}
WHERE id = ${toolMessageId}
`;
return;
}
const tres = await executeToolCall(projectRoot, tc);
const stored = {
tool_call_id: tc.id,
@@ -690,6 +713,23 @@ async function executeToolPhase(
})
);
if (pausingForUserInput) {
// Drop the dot back to idle — the card is the actionable surface now.
// The next inference turn fires from POST /api/chats/:id/answer_user_input
// once the user submits their answers.
ctx.publishUser({
type: 'chat_status',
chat_id: chatId,
status: 'idle',
at: new Date().toISOString(),
});
ctx.log.info(
{ sessionId, chatId, assistantMessageId },
'inference paused awaiting user input',
);
return;
}
const [nextAssistant] = await ctx.sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())

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 { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js';
import { getGitMeta } from './git_meta.js';
import { findSkills, getSkillBody, getSkillResource } from './skills.js';
const MAX_FILE_BYTES = 5 * 1024 * 1024;
const DEFAULT_VIEW_LINES = 200;
@@ -300,12 +301,195 @@ 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 };
},
};
// Batch 9.7: ask_user_input. Interactive elicitation. The model emits a tool
// call with 1-3 structured questions; the inference loop PAUSES (does not
// execute the tool server-side, does not recurse) and waits for the frontend
// to POST /api/chats/:id/answer_user_input with the user's selections. See
// routes/messages.ts for the resume path and services/inference.ts for the
// pause branch in executeToolPhase.
const AskUserInputInput = z.object({
questions: z
.array(
z.object({
question: z.string().min(1).max(200),
type: z.enum(['single_select', 'multi_select']),
options: z.array(z.string().min(1).max(80)).min(2).max(6),
}),
)
.min(1)
.max(3),
});
type AskUserInputInputT = z.infer<typeof AskUserInputInput>;
export const askUserInput: ToolDef<AskUserInputInputT> = {
name: 'ask_user_input',
description:
"Ask the user 1-3 structured questions through an inline picker UI. Use when you genuinely need a choice the user must make (e.g. scope, options, preferences) before continuing. Each question has 2-6 options and accepts free-text answers in addition. The tool call pauses the conversation until the user submits — the next assistant turn sees their answers as the tool result. Do not use for trivial yes/no clarifications you could infer; prefer it over multi-paragraph speculation about what the user might want.",
inputSchema: AskUserInputInput,
jsonSchema: {
type: 'function',
function: {
name: 'ask_user_input',
description:
'Ask the user 1-3 structured questions through an inline picker. Pauses the conversation until the user answers; the next turn sees their selections.',
parameters: {
type: 'object',
properties: {
questions: {
type: 'array',
minItems: 1,
maxItems: 3,
items: {
type: 'object',
properties: {
question: { type: 'string', description: '<=200 chars, shown to the user' },
type: {
type: 'string',
enum: ['single_select', 'multi_select'],
description: 'single_select = at most one option; multi_select = any subset',
},
options: {
type: 'array',
minItems: 2,
maxItems: 6,
items: { type: 'string' },
description: '2-6 strings, each <=80 chars; free-text input is always available alongside',
},
},
required: ['question', 'type', 'options'],
additionalProperties: false,
},
},
},
required: ['questions'],
additionalProperties: false,
},
},
},
// Server-side no-op. The "execution" of ask_user_input is the user's
// response, captured client-side and posted to /api/chats/:id/answer_user_input.
// The inference loop detects this tool by name and pauses before reaching
// executeToolCall — this fallback only runs if something bypasses that
// branch, in which case the pending sentinel matches the pause-path shape.
async execute(input) {
return { _pending: true, questions: input.questions };
},
};
export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
viewFile as ToolDef<unknown>,
listDir as ToolDef<unknown>,
grep as ToolDef<unknown>,
findFiles as ToolDef<unknown>,
gitStatus as ToolDef<unknown>,
skillFind as ToolDef<unknown>,
skillUse as ToolDef<unknown>,
skillResource as ToolDef<unknown>,
askUserInput as ToolDef<unknown>,
];
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
@@ -313,12 +497,19 @@ export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
// 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.
// Batch 9.7: ask_user_input added — it pauses execution but doesn't mutate
// project state, so it belongs in the read-only set for budget purposes.
export const READ_ONLY_TOOL_NAMES = [
'view_file',
'list_dir',
'grep',
'find_files',
'git_status',
'skill_find',
'skill_use',
'skill_resource',
'ask_user_input',
] as const;
export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(

View File

@@ -10,6 +10,12 @@ export interface Project {
last_session_id: string | null;
status: ProjectStatus;
gitea_remote: string | null;
// v1.9: per-project defaults inherited by new sessions. Empty string on
// default_system_prompt means "no override" — the model gets the base
// BooCode system prompt only. default_web_search_enabled is the inherited
// value for sessions where web_search_enabled is null.
default_system_prompt: string;
default_web_search_enabled: boolean;
}
export interface AvailableProject {
@@ -29,6 +35,10 @@ export interface Session {
created_at: string;
updated_at: string;
agent_id: string | null;
// v1.9: per-session override for web_search. null = inherit from
// project.default_web_search_enabled. Plumbed but inert in v1.9 — the
// actual web_search tool ships in Batch 8.
web_search_enabled: boolean | null;
}
// v1.8.1: agents come from two sources. 'global' = /data/AGENTS.md (always

View File

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

View File

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

View File

@@ -10,6 +10,8 @@ import type {
ViewFileResult,
AgentsResponse,
GitMeta,
Skill,
AskUserAnswer,
} from './types';
export class ApiError extends Error {
@@ -51,15 +53,29 @@ export const api = {
method: 'POST',
body: JSON.stringify(body),
}),
update: (id: string, body: { name: string }) =>
update: (
id: string,
body: Partial<Pick<Project, 'name' | 'default_system_prompt' | 'default_web_search_enabled'>>,
) =>
request<Project>(`/api/projects/${id}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
get: (id: string) => request<Project>(`/api/projects/${id}`),
archive: (id: string) =>
request<void>(`/api/projects/${id}/archive`, { method: 'POST' }),
unarchive: (id: string) =>
request<Project>(`/api/projects/${id}/unarchive`, { method: 'POST' }),
// v1.9: bulk-archive every open session in this project. Server publishes
// one session_archived frame per affected id, so the sidebar reducer
// updates incrementally rather than waiting for a refetch.
archiveAllSessions: (id: string) =>
request<{ archived: number; ids: string[] }>(
`/api/projects/${id}/sessions/archive-all`,
{ method: 'POST' },
),
openSessionsCount: (id: string) =>
request<{ count: number }>(`/api/projects/${id}/sessions/open-count`),
create: (body: {
name: string;
commit_message?: string;
@@ -106,7 +122,7 @@ export const api = {
get: (id: string) => request<Session>(`/api/sessions/${id}`),
update: (
id: string,
body: Partial<Pick<Session, 'name' | 'model' | 'system_prompt' | 'agent_id'>>
body: Partial<Pick<Session, 'name' | 'model' | 'system_prompt' | 'agent_id' | 'web_search_enabled'>>
) =>
request<Session>(`/api/sessions/${id}`, {
method: 'PATCH',
@@ -118,6 +134,15 @@ export const api = {
request<void>(`/api/sessions/${id}/archive`, { method: 'POST' }),
unarchive: (id: string) =>
request<Session>(`/api/sessions/${id}/unarchive`, { method: 'POST' }),
// v1.9: bulk-archive every open chat in this session. Same pattern as
// archiveAllSessions — server publishes one chat_archived per id.
archiveAllChats: (id: string) =>
request<{ archived: number; ids: string[] }>(
`/api/sessions/${id}/chats/archive-all`,
{ method: 'POST' },
),
openChatsCount: (id: string) =>
request<{ count: number }>(`/api/sessions/${id}/chats/open-count`),
},
chats: {
@@ -164,6 +189,31 @@ export const api = {
method: 'POST',
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 }),
}),
// Batch 9.7: submit answers for a paused ask_user_input call. Server
// validates against the question shape, UPDATEs the pending tool row,
// publishes the deferred tool_result frame, and enqueues the next turn.
answerUserInput: (chatId: string, toolCallId: string, answers: AskUserAnswer[]) =>
request<{ tool_message_id: string; assistant_message_id: string }>(
`/api/chats/${chatId}/answer_user_input`,
{
method: 'POST',
body: JSON.stringify({ tool_call_id: toolCallId, answers }),
},
),
},
messages: {
@@ -195,6 +245,10 @@ export const api = {
request<AgentsResponse>(`/api/projects/${projectId}/agents`),
},
skills: {
list: () => request<{ skills: Skill[] }>('/api/skills'),
},
settings: {
get: () => request<Record<string, unknown>>('/api/settings'),
patch: (body: Record<string, unknown>) =>

View File

@@ -9,6 +9,10 @@ export interface Project {
last_session_id: string | null;
status: ProjectStatus;
gitea_remote: string | null;
// v1.9: per-project defaults. Empty string on default_system_prompt means
// "no override" — inference falls through to the base system prompt.
default_system_prompt: string;
default_web_search_enabled: boolean;
}
export interface AvailableProject {
@@ -28,6 +32,8 @@ export interface Session {
created_at: string;
updated_at: string;
agent_id: string | null;
// v1.9: null = inherit from project.default_web_search_enabled.
web_search_enabled: boolean | null;
}
// v1.8.1: 'global' = /data/AGENTS.md (always-on), 'project' = per-project
@@ -225,7 +231,41 @@ export interface GitMeta {
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;
}
// Batch 9.7: ask_user_input shapes. The tool_call.args is { questions: AskUserQuestion[] }
// (1-3 entries); the eventual tool_result.output is { answers: AskUserAnswer[] } in the
// same order. AskUserInputCard renders questions and POSTs answers.
export type AskUserQuestionType = 'single_select' | 'multi_select';
export interface AskUserQuestion {
question: string;
type: AskUserQuestionType;
options: string[];
}
export interface AskUserAnswer {
question: string;
selected_options: string[];
free_text: string | null;
}
export interface AskUserAnswerSet {
answers: AskUserAnswer[];
}
// 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 {
id: string;

View File

@@ -0,0 +1,324 @@
import { useMemo, useState } from 'react';
import { Check } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Button } from '@/components/ui/button';
import type {
AskUserAnswer,
AskUserAnswerSet,
AskUserQuestion,
ToolCall,
ToolResult,
} from '@/api/types';
// Batch 9.7. Inline interactive picker. Renders inside MessageList in place of
// the standard ToolCallLine when the assistant emits an ask_user_input tool
// call. While the tool result is null (server pre-stamps a sentinel with
// output=null), shows the form; once the WS tool_result frame arrives with a
// real AnswerSet, flips to read-only review mode.
interface Props {
toolCall: ToolCall;
toolResult: ToolResult | null;
chatId: string;
}
function parseQuestions(raw: unknown): AskUserQuestion[] {
if (!raw || typeof raw !== 'object' || !('questions' in raw)) return [];
const arr = (raw as { questions: unknown }).questions;
if (!Array.isArray(arr)) return [];
const out: AskUserQuestion[] = [];
for (const item of arr) {
if (!item || typeof item !== 'object') continue;
const q = item as { question?: unknown; type?: unknown; options?: unknown };
if (typeof q.question !== 'string') continue;
if (q.type !== 'single_select' && q.type !== 'multi_select') continue;
if (!Array.isArray(q.options)) continue;
const opts = q.options.filter((o): o is string => typeof o === 'string');
if (opts.length < 2) continue;
out.push({ question: q.question, type: q.type, options: opts });
}
return out;
}
function parseAnswerSet(raw: unknown): AskUserAnswerSet | null {
if (!raw || typeof raw !== 'object' || !('answers' in raw)) return null;
const arr = (raw as { answers: unknown }).answers;
if (!Array.isArray(arr)) return null;
const answers: AskUserAnswer[] = [];
for (const item of arr) {
if (!item || typeof item !== 'object') continue;
const a = item as { question?: unknown; selected_options?: unknown; free_text?: unknown };
if (typeof a.question !== 'string') continue;
if (!Array.isArray(a.selected_options)) continue;
if (a.free_text !== null && typeof a.free_text !== 'string') continue;
const sel = a.selected_options.filter((s): s is string => typeof s === 'string');
answers.push({
question: a.question,
selected_options: sel,
free_text: (a.free_text as string | null) ?? null,
});
}
return { answers };
}
export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) {
const questions = useMemo(() => parseQuestions(toolCall.args), [toolCall.args]);
if (questions.length === 0) {
return (
<div className="rounded border border-destructive/40 bg-destructive/10 text-xs px-3 py-2 text-destructive">
ask_user_input: malformed tool args
</div>
);
}
// Tool result with a non-null output means the answer is already submitted.
// The pending sentinel uses output=null, so this branch only triggers after
// the real WS tool_result frame lands.
const answered = toolResult && toolResult.output !== null;
if (answered) {
const answerSet = parseAnswerSet(toolResult!.output);
return <AnsweredView questions={questions} answers={answerSet} />;
}
return (
<PendingView questions={questions} toolCallId={toolCall.id} chatId={chatId} />
);
}
function PendingView({
questions,
toolCallId,
chatId,
}: {
questions: AskUserQuestion[];
toolCallId: string;
chatId: string;
}) {
// Per-question selections + free text. Selections are option arrays so the
// multi_select case is uniform; single_select just constrains to length 1.
const [selections, setSelections] = useState<string[][]>(() => questions.map(() => []));
const [freeTexts, setFreeTexts] = useState<string[]>(() => questions.map(() => ''));
const [submitting, setSubmitting] = useState(false);
const singleQuestion = questions.length === 1;
const anyFreeText = freeTexts.some((t) => t.trim().length > 0);
// Submit button shows when:
// - more than one question (always batched), OR
// - one question and the user has typed free text (committing it needs an
// explicit Submit so an accidental Tab/click doesn't lose it).
// For one question with no free text, clicking an option submits inline.
const showSubmitButton = !singleQuestion || anyFreeText;
// Every question must have at least one of (option, free text).
const allComplete = questions.every((_, i) => {
return selections[i]!.length > 0 || freeTexts[i]!.trim().length > 0;
});
function buildAnswers(): AskUserAnswer[] {
return questions.map((q, i) => {
const freeText = freeTexts[i]!.trim();
return {
question: q.question,
selected_options: selections[i]!,
free_text: freeText.length > 0 ? freeText : null,
};
});
}
async function submit(answers: AskUserAnswer[]) {
if (submitting) return;
setSubmitting(true);
try {
await api.chats.answerUserInput(chatId, toolCallId, answers);
// Card stays mounted; the incoming WS tool_result frame will flip it
// into AnsweredView via the parent prop change.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'submit failed');
setSubmitting(false);
}
}
function pickSingle(qIdx: number, option: string) {
setSelections((prev) => prev.map((arr, i) => (i === qIdx ? [option] : arr)));
// Immediate submit for the single-question single-select shortcut. Only
// fires when no free text exists anywhere — once the user typed, the
// Submit button takes over so the typed text isn't silently dropped.
if (singleQuestion && !anyFreeText) {
const answers: AskUserAnswer[] = [
{
question: questions[0]!.question,
selected_options: [option],
free_text: null,
},
];
void submit(answers);
}
}
function toggleMulti(qIdx: number, option: string) {
setSelections((prev) =>
prev.map((arr, i) => {
if (i !== qIdx) return arr;
return arr.includes(option) ? arr.filter((o) => o !== option) : [...arr, option];
}),
);
}
function setFreeText(qIdx: number, value: string) {
setFreeTexts((prev) => prev.map((t, i) => (i === qIdx ? value : t)));
}
return (
<div className="rounded-lg border bg-muted/20 text-sm">
<div className="px-4 py-3 space-y-4">
{questions.map((q, i) => (
<div key={i} className="space-y-2">
{questions.length > 1 && (
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Question {i + 1}
</div>
)}
<div className="font-medium leading-snug">{q.question}</div>
{q.type === 'single_select' ? (
<RadioGroup
value={selections[i]![0] ?? ''}
onValueChange={(v) => pickSingle(i, v)}
disabled={submitting}
className="gap-1.5"
>
{q.options.map((opt, j) => {
const id = `q${i}-opt${j}`;
return (
<label
key={j}
htmlFor={id}
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
>
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
<span>{opt}</span>
</label>
);
})}
</RadioGroup>
) : (
<div className="grid gap-1.5">
{q.options.map((opt, j) => {
const id = `q${i}-opt${j}`;
const checked = selections[i]!.includes(opt);
return (
<label
key={j}
htmlFor={id}
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
>
<input
id={id}
type="checkbox"
checked={checked}
disabled={submitting}
onChange={() => toggleMulti(i, opt)}
className="mt-1 size-3.5 rounded border-input accent-primary"
/>
<span>{opt}</span>
</label>
);
})}
</div>
)}
<div className="pt-1 space-y-1">
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Or type a custom answer
</div>
<input
type="text"
value={freeTexts[i]}
disabled={submitting}
placeholder="Free text…"
onChange={(e) => setFreeText(i, e.target.value)}
className="w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-60"
/>
</div>
</div>
))}
</div>
{showSubmitButton && (
<div className="flex justify-end gap-2 border-t px-4 py-2">
<Button
type="button"
size="sm"
disabled={!allComplete || submitting}
onClick={() => void submit(buildAnswers())}
>
{submitting ? 'Submitting…' : 'Submit'}
</Button>
</div>
)}
</div>
);
}
function AnsweredView({
questions,
answers,
}: {
questions: AskUserQuestion[];
answers: AskUserAnswerSet | null;
}) {
if (!answers) {
return (
<div className="rounded-lg border bg-muted/20 text-xs px-4 py-3 text-muted-foreground">
ask_user_input: answers unavailable
</div>
);
}
return (
<div className="rounded-lg border bg-muted/10 text-sm">
<div className="px-4 py-3 space-y-3">
{questions.map((q, i) => {
const a = answers.answers[i];
if (!a) return null;
return (
<div key={i} className="space-y-1.5">
{questions.length > 1 && (
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Question {i + 1}
</div>
)}
<div className="font-medium leading-snug">{q.question}</div>
<div className="space-y-0.5">
{q.options.map((opt, j) => {
const selected = a.selected_options.includes(opt);
return (
<div
key={j}
className={
selected
? 'flex items-start gap-2 text-sm leading-snug text-foreground'
: 'flex items-start gap-2 text-sm leading-snug text-muted-foreground/60 line-through'
}
>
<span className="mt-0.5 size-3.5 shrink-0 inline-flex items-center justify-center">
{selected && <Check className="size-3 text-primary" />}
</span>
<span>{opt}</span>
</div>
);
})}
</div>
{a.free_text && (
<div className="rounded bg-background border px-2 py-1 text-xs font-mono whitespace-pre-wrap">
{a.free_text}
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,8 +1,14 @@
import { useCallback, useEffect, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
import { Send } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
import { Check, Plus, Send } from 'lucide-react';
import { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
flattenToMessage,
inferLanguage,
@@ -16,8 +22,10 @@ import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
import { FileMentionPopover } from '@/components/FileMentionPopover';
import { DropOverlay } from '@/components/DropOverlay';
import { AgentPicker } from '@/components/AgentPicker';
import { SkillSlashCommand } from '@/components/SkillSlashCommand';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { useSkills } from '@/hooks/useSkills';
import { useViewport } from '@/hooks/useViewport';
const MAX_ATTACHMENTS = 10;
@@ -29,11 +37,23 @@ interface Props {
// When omitted, the toolbar row is hidden entirely.
agentId?: string | null;
onAgentChange?: (agentId: string | null) => void | Promise<void>;
// v1.9: when sessionId + webSearchEnabled are both provided, the + menu
// renders next to the AgentPicker with a single "Web search" toggle item.
// The check reflects the *stored* session value (not the effective one):
// null counts as unchecked. Clicking PATCHes session.web_search_enabled
// with the inverted boolean (null → true, true → false, false → true).
sessionId?: string;
webSearchEnabled?: boolean | null;
onSend: (content: string) => void | Promise<void>;
onForceSend?: (content: string) => void | Promise<void>;
// 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 [value, setValue] = useState('');
const [busy, setBusy] = useState(false);
@@ -48,6 +68,19 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend,
atIdx: number;
anchorRect: { top: number; left: number };
} | 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 textareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -82,6 +115,31 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend,
const text = value.trim();
if (!text && attachments.length === 0) 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);
try {
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 } {
const mirror = document.createElement('div');
const style = window.getComputedStyle(textarea);
@@ -145,6 +216,23 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend,
const ta = e.target;
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
if (pos > 0 && newValue[pos - 1] === '@') {
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>) {
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
// (CJK input methods commit composition via Enter). Without this, the
// first Enter of a Japanese/Chinese/Korean composition would submit
@@ -425,16 +516,51 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend,
))}
</div>
)}
{/* Batch 9 toolbar — agent picker. Sits above the input row so it
doesn't compete with the send button for vertical alignment.
When Batch 7 lands, ModelPicker and the + button join this row. */}
{onAgentChange && (
{/* Batch 9 toolbar — agent picker. v1.9 adds the icon-only + menu next
to it for quick toggles (currently: Web search). When omitted at the
callsite the row stays collapsed so nothing else has to change. */}
{(onAgentChange || sessionId) && (
<div className="px-4 pt-2 flex items-center gap-1.5">
<AgentPicker
projectId={projectId}
value={agentId ?? null}
onChange={onAgentChange}
/>
{onAgentChange && (
<AgentPicker
projectId={projectId}
value={agentId ?? null}
onChange={onAgentChange}
/>
)}
{sessionId && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Quick toggles"
title="Quick toggles"
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onSelect={async () => {
// v1.9: tri-state collapses to two on the wire when toggled
// here. null (inherit) treated as off; click flips to true.
// To restore "inherit" the user opens SettingsPane.
const next = webSearchEnabled === true ? false : true;
try {
await api.sessions.update(sessionId, { web_search_enabled: next });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to toggle web search');
}
}}
className="text-xs"
>
<Check className={`size-3 ${webSearchEnabled === true ? 'opacity-100' : 'opacity-0'}`} />
Web search
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
<div className="px-4 py-3 flex items-end gap-2">
@@ -476,6 +602,15 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend,
onClose={closeMention}
/>
)}
{slashState && (
<SkillSlashCommand
query={slashState.query}
skills={skills}
anchorRect={slashState.anchorRect}
onSelect={handleSlashSelect}
onClose={() => setSlashState(null)}
/>
)}
</div>
);
}

View File

@@ -3,6 +3,7 @@ import type { Chat, Message } from '@/api/types';
import { MessageBubble } from './MessageBubble';
import { ToolCallGroup } from './ToolCallGroup';
import { ToolCallLine, type ToolRun } from './ToolCallLine';
import { AskUserInputCard } from './AskUserInputCard';
interface Props {
messages: Message[];
@@ -12,9 +13,11 @@ interface Props {
// 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.
// Batch 9.7: tool_run carries chat_id so AskUserInputCard can post the
// answer without threading the chat id through MessageList's parent.
type RenderItem =
| { kind: 'message'; message: Message; capHitInfo?: { position: number; isLatest: boolean } }
| { kind: 'tool_run'; run: ToolRun; key: string }
| { kind: 'tool_run'; run: ToolRun; key: string; chatId: string }
| { kind: 'tool_group'; runs: ToolRun[]; key: string };
const GROUP_THRESHOLD = 3;
@@ -50,7 +53,7 @@ function flatten(messages: Message[]): RenderItem[] {
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 });
items.push({ kind: 'tool_run', run, key: tc.id, chatId: m.chat_id });
}
continue;
}
@@ -63,6 +66,9 @@ function flatten(messages: Message[]): RenderItem[] {
// 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.
// Batch 9.7: ask_user_input never groups — each pause has its own card so
// grouping would render them as collapsed ToolCallLines which can't surface
// the interactive form.
function group(items: RenderItem[]): RenderItem[] {
const out: RenderItem[] = [];
let i = 0;
@@ -74,6 +80,11 @@ function group(items: RenderItem[]): RenderItem[] {
continue;
}
const name = item.run.call.name;
if (name === 'ask_user_input') {
out.push(item);
i += 1;
continue;
}
let j = i + 1;
while (
j < items.length &&
@@ -82,7 +93,12 @@ function group(items: RenderItem[]): RenderItem[] {
) {
j += 1;
}
const run = items.slice(i, j) as Array<{ kind: 'tool_run'; run: ToolRun; key: string }>;
const run = items.slice(i, j) as Array<{
kind: 'tool_run';
run: ToolRun;
key: string;
chatId: string;
}>;
if (run.length >= GROUP_THRESHOLD) {
out.push({
kind: 'tool_group',
@@ -150,6 +166,16 @@ export function MessageList({ messages, sessionChats }: Props) {
);
}
if (item.kind === 'tool_run') {
if (item.run.call.name === 'ask_user_input') {
return (
<AskUserInputCard
key={item.key}
toolCall={item.run.call}
toolResult={item.run.result}
chatId={item.chatId}
/>
);
}
return <ToolCallLine key={item.key} run={item.run} />;
}
return <ToolCallGroup key={item.key} runs={item.runs} />;

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,6 @@ import { Archive, MessageSquare, Send, ChevronDown, ChevronRight, RotateCcw, Tra
import type { Chat } from '@/api/types';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import {
ContextMenu,
ContextMenuContent,
@@ -165,7 +164,6 @@ export function SessionLandingPage({
const [renameValue, setRenameValue] = useState('');
const [archiveConfirm, setArchiveConfirm] = useState<Chat | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<Chat | null>(null);
const [deleteInput, setDeleteInput] = useState('');
const openChats = chats
.filter((c) => c.status === 'open')
@@ -193,9 +191,6 @@ export function SessionLandingPage({
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
// visible chats won't update the per-row stats until next mount/navigation.
return (
@@ -217,7 +212,7 @@ export function SessionLandingPage({
onCancelRename={() => setRenamingId(null)}
onContextStartRename={() => startRename(chat)}
onContextArchive={() => setArchiveConfirm(chat)}
onContextDelete={() => { setDeleteConfirm(chat); setDeleteInput(''); }}
onContextDelete={() => setDeleteConfirm(chat)}
showContextMenu
actions={
<>
@@ -242,7 +237,6 @@ export function SessionLandingPage({
onClick={(e) => {
e.stopPropagation();
setDeleteConfirm(chat);
setDeleteInput('');
}}
>
<Trash2 size={14} />
@@ -352,36 +346,25 @@ export function SessionLandingPage({
</DialogContent>
</Dialog>
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) { setDeleteConfirm(null); setDeleteInput(''); } }}>
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) setDeleteConfirm(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete chat?</DialogTitle>
<DialogDescription>
Type the chat name to confirm:
{' '}
<span className="font-mono font-medium text-foreground">{deleteExpected || '(unnamed — cannot type-confirm)'}</span>
Permanently delete{' '}
<span className="font-mono font-medium text-foreground">{deleteConfirm?.name || '(unnamed)'}</span>
{' '}and all its messages. This cannot be undone.
</DialogDescription>
</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">
<Button variant="outline" onClick={() => { setDeleteConfirm(null); setDeleteInput(''); }}>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={!deleteEnabled}
onClick={() => {
if (deleteConfirm && deleteEnabled) void onDeleteChat(deleteConfirm.id);
if (deleteConfirm) void onDeleteChat(deleteConfirm.id);
setDeleteConfirm(null);
setDeleteInput('');
}}
>
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,9 +1,11 @@
import { useEffect, useState } from 'react';
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
import type { Chat, WorkspacePane } from '@/api/types';
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
import { useViewport } from '@/hooks/useViewport';
import { ChatPane } from '@/components/panes/ChatPane';
import { SettingsPane } from '@/components/panes/SettingsPane';
import { ChatTabBar } from '@/components/ChatTabBar';
import { SessionLandingPage } from '@/components/SessionLandingPage';
import {
@@ -24,6 +26,9 @@ interface Props {
// (MobileTabSwitcher) can share state with the pane grid.
panesHook: UseWorkspacePanesResult;
chatsHook: UseSessionChatsResult;
// v1.9: passed through to SettingsPane when one is mounted in the grid.
session: Session;
project: Project | null;
}
export function Workspace({
@@ -33,6 +38,8 @@ export function Workspace({
onAgentChange,
panesHook,
chatsHook,
session,
project,
}: Props) {
const {
panes,
@@ -67,6 +74,41 @@ export function Workspace({
const { isMobile } = useViewport();
// v1.9: workspace-level maximize state for the settings pane. CSS-only:
// sibling panes get display:none, the maximized pane fills the grid cell.
// ESC listener only mounted while maximized. Mobile is always full-width
// for a single pane so maximize doesn't apply.
const [maximized, setMaximized] = useState(false);
const settingsIdx = panes.findIndex((p) => p.kind === 'settings');
// 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[] {
return pane.chatIds
.map((id) => chats.find((c) => c.id === id))
@@ -81,10 +123,12 @@ export function Workspace({
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={panes.length >= MAX_PANES}
// v1.9: settings panes excluded from the MAX cap (decision c).
disabled={panes.filter((p) => p.kind !== 'settings').length >= MAX_PANES}
className={cn(
'flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted',
panes.length >= MAX_PANES && 'opacity-40 cursor-not-allowed hover:bg-transparent'
panes.filter((p) => p.kind !== 'settings').length >= MAX_PANES &&
'opacity-40 cursor-not-allowed hover:bg-transparent'
)}
>
<PanelRight size={14} />
@@ -114,12 +158,24 @@ export function Workspace({
style={
isMobile
? undefined
: { gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))` }
: maximized && settingsIdx >= 0
? { gridTemplateColumns: 'minmax(0, 1fr)' }
: { gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))` }
}
>
{panes.map((pane, idx) => {
const visible = !isMobile || idx === activePaneIdx;
if (!visible) return null;
const isSettings = pane.kind === 'settings';
// v1.9: when maximized, hide every pane except the settings one.
// display:none keeps the React tree mounted so streams / drafts
// survive the toggle without re-mount cost.
const hiddenForMaximize = !isMobile && maximized && idx !== settingsIdx;
const visible = (!isMobile || idx === activePaneIdx) && !hiddenForMaximize;
if (!visible) {
if (hiddenForMaximize) {
return <div key={pane.id} className="hidden" />;
}
return null;
}
return (
<div
key={pane.id}
@@ -131,19 +187,19 @@ export function Workspace({
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
)}
onClick={() => setActivePaneIdx(idx)}
onDragOver={!isMobile && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
onDragLeave={!isMobile && panes.length > 1 ? handlePaneDragLeave : undefined}
onDrop={!isMobile && panes.length > 1 ? handlePaneDrop(idx) : undefined}
onDragOver={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
onDragLeave={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragLeave : undefined}
onDrop={!isMobile && !isSettings && panes.length > 1 ? handlePaneDrop(idx) : undefined}
>
<div
draggable={!isMobile && panes.length > 1}
onDragStart={!isMobile && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={!isMobile && panes.length > 1 ? handlePaneDragEnd : undefined}
draggable={!isMobile && !isSettings && panes.length > 1}
onDragStart={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragEnd : undefined}
>
{/* Hidden on mobile per v1.8: chat-within-pane navigation
is not exposed on small screens; users switch panes via
the header pill instead. */}
{!isMobile && (
{/* Hidden on mobile per v1.8; settings panes own their own
section nav / maximize toggle so they skip ChatTabBar
entirely. */}
{!isMobile && !isSettings && (
<ChatTabBar
pane={pane}
tabs={chatsForPane(pane)}
@@ -161,7 +217,16 @@ export function Workspace({
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{pane.kind === 'chat' && pane.chatId ? (
{isSettings && project ? (
<SettingsPane
session={session}
project={project}
maximized={maximized}
onToggleMaximize={() => setMaximized((v) => !v)}
onClose={() => removePane(idx)}
isMobile={isMobile}
/>
) : pane.kind === 'chat' && pane.chatId ? (
<ChatPane
sessionId={sessionId}
chatId={pane.chatId}
@@ -169,6 +234,7 @@ export function Workspace({
agentId={agentId}
onAgentChange={onAgentChange}
sessionChats={chats}
webSearchEnabled={session.web_search_enabled}
/>
) : (
<SessionLandingPage

View File

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

@@ -62,6 +62,14 @@ export interface OpenChatInActivePaneEvent {
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 {
type: 'session_archived';
session_id: string;
@@ -139,6 +147,7 @@ export type SessionEvent =
| OpenFileInBrowserEvent
| AttachChatFileEvent
| OpenChatInActivePaneEvent
| OpenSettingsPaneEvent
| SessionArchivedEvent
| ChatCreatedEvent
| ChatUpdatedEvent

View File

@@ -151,6 +151,10 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
case 'open_chat_in_active_pane':
// Consumed by Workspace; sidebar has no business with pane state.
return prev;
case 'open_settings_pane':
// Consumed by Session.tsx (calls toggleSettingsPane on its panesHook).
// Sidebar data is untouched.
return prev;
case 'session_archived': {
let changed = false;
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 };
}
// v1.9: settings pane factory. No chats, no state beyond identity — the
// SettingsPane component renders Session/Project sections from the
// surrounding session/project.
function settingsPane(): WorkspacePane {
return { id: generateId(), kind: 'settings', chatIds: [], activeChatIdx: -1 };
}
// v1.9: settings panes are ephemeral. Filter them out before persisting so a
// page reload always returns to a clean workspace; the user re-opens via the
// sidebar Settings button when needed.
function persistablePanes(panes: WorkspacePane[]): WorkspacePane[] {
return panes.filter((p) => p.kind !== 'settings');
}
// v1.9: per recon decision (c), settings panes don't count toward MAX_PANES.
// Helper used at every pane-insertion site so the rule lives in one place.
function nonSettingsCount(panes: WorkspacePane[]): number {
return panes.reduce((n, p) => n + (p.kind === 'settings' ? 0 : 1), 0);
}
function loadPanes(sessionId: string): WorkspacePane[] | null {
try {
const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`);
@@ -33,7 +53,10 @@ function loadPanes(sessionId: string): WorkspacePane[] | null {
function savePanes(sessionId: string, panes: WorkspacePane[]): void {
try {
localStorage.setItem(`${STORAGE_KEY}.${sessionId}`, JSON.stringify(panes));
localStorage.setItem(
`${STORAGE_KEY}.${sessionId}`,
JSON.stringify(persistablePanes(panes)),
);
} catch { /* quota or disabled */ }
}
@@ -50,6 +73,10 @@ export interface UseWorkspacePanesResult {
closeAllTabs: (paneIdx: number) => void;
showLandingPage: (paneIdx: number) => void;
addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => void;
// 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;
removeChatFromPanes: (chatId: string) => void;
initializeFirstChatIfEmpty: (chatId: string) => void;
@@ -216,7 +243,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
return;
}
setPanes((prev) => {
if (prev.length >= MAX_PANES) {
// v1.9: settings panes are excluded from the MAX cap (decision c).
if (nonSettingsCount(prev) >= MAX_PANES) {
toast.error(`Maximum ${MAX_PANES} panes`);
return prev;
}
@@ -226,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) => {
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);
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
return next;
@@ -325,6 +379,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
closeAllTabs,
showLandingPage,
addSplitPane,
toggleSettingsPane,
removePane,
removeChatFromPanes,
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,
status: 'archived' as const,
gitea_remote: fromSidebar.gitea_remote,
// v1.9: synthesized stub for an archived project that only the
// sidebar cache has — defaults match the schema NOT NULL DEFAULT
// values. The full row gets re-fetched on unarchive.
default_system_prompt: '',
default_web_search_enabled: false,
},
...prev,
];

View File

@@ -116,9 +116,31 @@ function SessionInner({ sessionId }: { sessionId: string }) {
event.session_id === sessionId
) {
navigate(`/project/${event.project_id}`);
return;
}
// v1.9: any session_updated for this session triggers a full refetch so
// SettingsPane (mounted in a workspace pane) picks up system_prompt /
// web_search_enabled / model edits made from another tab.
if (event.type === 'session_updated' && event.session_id === sessionId) {
void api.sessions.get(sessionId).then((s) => {
setSession(s);
setName((prev) => (editingName ? prev : s.name));
}).catch(() => {});
return;
}
// v1.9: project_updated → refetch project so the Project section in
// SettingsPane reflects the new defaults.
if (event.type === 'project_updated' && project && event.project_id === project.id) {
void api.projects.get(project.id).then(setProject).catch(() => {});
return;
}
// 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
// MobileTabSwitcher's onSwitchPane can push the same URL state and the
@@ -211,15 +233,13 @@ function SessionInner({ sessionId }: { sessionId: string }) {
</div>
{session && (
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1 shrink-0">
<ModelPicker
value={session.model}
onChange={async (model) => {
const updated = await api.sessions.update(session.id, { model });
setSession(updated);
}}
/>
</div>
<ModelPicker
value={session.model}
onChange={async (model) => {
const updated = await api.sessions.update(session.id, { model });
setSession(updated);
}}
/>
)}
<button
@@ -337,6 +357,8 @@ function SessionInner({ sessionId }: { sessionId: string }) {
}}
panesHook={panesHook}
chatsHook={chatsHook}
session={session}
project={project}
/>
)}
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,341 +1,201 @@
# BooCode v1.x — Roadmap
# BooCode — Roadmap
Last updated: 2026-05-16
Last updated: 2026-05-17
## 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`).
**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).
- 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 |Theme |Status |Branch / Notes |
|------------------------------------------|-----------------------------------------------------------------------------------|-----------|---------------------------------------|
|1 |Markdown, Copy + Regen, tok/s + ctx, AI naming |✅ Done |`v1.1-batch1` merged |
|2 |Sidebar restructure |✅ Done |`v1.1-batch2` merged |
|3 |Pane system, FileBrowserPane + Shiki, cross-tab |✅ Done |`v1.1-batch3` merged |
|3.5 |Chip infrastructure, `@file`, line-select |✅ Done |merged |
|4 (v1.2) |Chats inside sessions, right-rail, `/compact`, archive, force-send |✅ Done |merged |
|4.14.4 |Project archive, sidebar context, Gitea API, bootstrap |✅ Done |merged |
|v1.5 cleanup |resolveProjectPath, BOOTSTRAP_ROOT, vitest pin |✅ Done |merged |
|v1.6 mobile |Drawer, single-pane, long-press, IME-safe, pull-to-refresh, swipe-close |✅ Done |merged |
|v1.6.1 |RightRail mobile wrapper fix |✅ Done |merged |
|Tool-loop bump |MAX_TOOL_LOOP_DEPTH 5→15 |✅ Done |merged |
|v1.6.2 |Workspace + Session+Project headers + ChatTabBar new-chat + RightRail mobile drawer|🔄 In flight|`v1.6.2-mobile-ui-fixes` |
|**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 |
|9 (REORDERED, DECOUPLED) |Agents (Tier 2): `AGENTS.md`, per-agent temp/tools, picker in ChatInput toolbar |✅ Implemented, uncommitted|six builtins; on `main` awaiting commit|
|5 |Fork message, delete message, header polish |Planned | |
|6 |Drag-drop file + paste-as-attachment |Planned |thin extension of 3.5 chips |
|7 |Settings drawer: system prompt, web search toggle, agent entry |Planned |adds SettingsDrawer agent entry (Batch 9 deferred half) |
|8 |Web search backend: SearXNG `web_search` + `web_fetch` |Planned | |
|10 |BooTerm: separate container, xterm.js + node-pty + tmux |Planned | |
|11 — Architect: codebase map |codecontext sidecar + MCP tool wiring |Planned |from nmakod/codecontext |
|11b — Architect: repo health |call graph, circular deps, dead code |Planned |from spirituslab/codesight |
|12 — Tool approval + plan/act mode |Read-only invariant, per-tool gating |Planned |from cline |
|13 — Append-only event log |Replace messages-table semantics |Planned |from OpenHands V1 |
|14 — BooCoder: pending changes |Sandboxed edit queue, atomic apply |Post-v1.x |from plandex |
|15 — BooCoder runtime isolation |Per-session Docker sandbox |Post-v1.x |from OpenHands |
|16 — Multi-provider LLM |Optional litellm-style abstraction |Optional |from pi-ai |
|17 — Workflow graphs |Multi-agent coordination |Far future |from microsoft/agent-framework concepts|
**Old Batch 12 (codebase indexer w/ Harrier embeddings) — REMOVED.** Replaced by Batch 11/11b sidecar approach. See `boocode_code_review.md` decisions log.
**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.
-----
## Batch details (planned / new)
### Batch 9 — Agents (Tier 2, DECOUPLED)
**Spec:** `boocode_batch9.md` with the deltas below.
**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.
-----
| Version | Theme | Status |
|---|---|---|
| v1.0 | Initial scaffold, read-only tools, WS streaming | ✅ Merged |
| v1.1-batch1 | Markdown, Copy + Regen, tok/s + ctx, AI naming | ✅ Merged |
| v1.1-batch2 | Sidebar restructure | ✅ Merged |
| v1.1-batch3 | Pane system, FileBrowserPane + Shiki, cross-tab | ✅ Merged |
| v1.1-batch3.5 | Chip infra, `@file`, line-select | ✅ Merged |
| v1.2 | Chats inside sessions, right-rail, `/compact`, archive, force-send | ✅ Merged |
| v1.2-project-ux | Project archive, sidebar context, Gitea API, bootstrap | ✅ Merged |
| v1.3 | Tab-close + chat-archive | ✅ Merged |
| v1.4 | Fork message, delete message, header polish (was original Batch 5) | ✅ Merged |
| v1.5 | resolveProjectPath, BOOTSTRAP_ROOT, vitest pin | ✅ Merged |
| v1.5.1 | Bootstrap hotfix (git in container, SSH keypair, known_hosts) | ✅ Merged (`4a9f207`) |
| v1.6 | Mobile pass: drawer, single-pane, long-press, IME-safe, pull-to-refresh, swipe-close | ✅ Merged |
| v1.6.1 | RightRail mobile wrapper fix | ✅ Merged |
| Tool-loop bump | MAX_TOOL_LOOP_DEPTH 5→15 | ✅ Merged |
| v1.6.2 | Workspace + Session+Project headers, ChatTabBar new-chat, RightRail mobile drawer | ✅ Merged |
| v1.7 | Drag-drop file + paste-as-attachment (was Batch 6) | ✅ Merged |
| v1.8 | Settings drawer + `git_status` added to ALL_TOOL_NAMES (was Batch 7) | ✅ Merged |
| v1.8.1 | WS reconnect toast tuning (silent/gray/red thresholds), pane status indicators | ✅ Merged |
| v1.8.2 | Tool-loop fixes: read-only cap raised, "depth exceeded" error + continue, `max_tool_calls` frontmatter, `messages.metadata` | ✅ Merged |
| **v1.x-themes** | **18 themes, settings page, dark/light/system, FOUC mitigation** | **🔄 Claude Code in flight** |
| v1.8.3 | Tool call UI compaction: collapse-by-default, group consecutive same-tool, result preview cap | Planned (small, frontend-only) |
| v1.9 | Settings pane (system prompt per project + session, web search toggle, `+` button) | Planned (spec locked, was on branch `v1.9-settings-pane`) |
| v1.10 | Web search backend: SearXNG `web_search` + `web_fetch` | Planned |
| v1.11 | Agents Tier 2: `AGENTS.md`, per-agent temp/tools whitelist, AgentPicker in ChatInput | Planned |
| v1.12 | BooTerm: separate container, xterm.js + node-pty + tmux | Planned |
| v1.13 | Architect: codecontext sidecar (MCP, tree-sitter, no embeddings) | Planned |
| v1.13b | Architect: repo health (call graph, circular deps, dead code) | Planned |
| v1.14 | Tool approval + plan/act mode (cline-style) | Planned |
| Post-v1.x | Append-only event log (OpenHands V1) | Planned |
| 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 |
## Flagged follow-ups (not in a batch yet)
- Agents in `/data/AGENTS.md` don't list `git_status` in their `tools:` blocks. Out of scope until pre-BooCoder cleanup pass.
- 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.
## 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:**
- 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.
-----
Track B (architect, no UI dep, can run parallel anytime): v1.13 → v1.13b → v1.14.
## 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 |
|-------------|--------------------------------|--------------------------|------------------------------|--------|
|`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 ahead
### Schema additions
**Batch 9:** `sessions.agent_id TEXT` (nullable; references AGENTS.md by slug).
**Batch 11:** none (codecontext stateless on disk).
**Batch 11b:** `repo_health_cache (project_id, file_hashes_sig, payload JSONB, created_at)`.
**Batch 12:** `sessions.tool_approval_mode`, `sessions.approved_tools`.
**Batch 13:** `session_events`; deprecate `messages` long-tail.
**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)|
-----
- 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`
- v1.11: `sessions.agent_id`
- v1.13b: `repo_health_cache (project_id, file_hashes_sig, payload JSONB, created_at)`
- v1.14: `sessions.tool_approval_mode`, `sessions.approved_tools`
- Post-v1.x: `session_events`; deprecate `messages` long-tail
- Post-v1.x: `pending_changes`
## Decisions log
- **Embeddings dropped from BooCode.** Replaced RAG with file-view tools + sidecar analyzers.
- **Original Batch 11 (aider PageRank port) replaced** by codecontext sidecar approach.
- **Original Batch 12 (codebase indexer w/ Harrier) removed** entirely. No embedding infrastructure in BooCode v1.x.
- **Globstar parked** — not an architect tool, future verify-before-commit candidate only.
- **codeprysm rejected** — embedding-based; node/edge taxonomy noted as reference if we ever build our own graph.
- **Batch 9 decoupled from Batch 7 (2026-05-16).** AgentPicker mounts in `ChatInput.tsx` toolbar only. SettingsDrawer agent entry and Header active-agent badge moved to Batch 7. Builtin defaults shipped: six agents (Code Reviewer, Debugger, Refactorer, Architect, Security Auditor, Prompt Builder) with no `model` field — session model wins by default.
## Follow-ups (post-ship docs / cleanup)
- **After v1.8.2 ships:** Add explicit `max_tool_calls: 30` to all 6 agents in `/data/AGENTS.md` and `/opt/boocode/AGENTS.md`. Purely for documentation/discoverability — defaults handle behavior identically (all 6 agents use only read-only tools, default is already 30).
-----
- Embeddings dropped from BooCode. File-view tools + sidecar analyzers replace RAG.
- Old Batch 11 (aider PageRank port) replaced by codecontext sidecar (v1.13).
- Old Batch 12 (Harrier indexer) → removed entirely.
- 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.
- 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.
## Workflow
Each batch:
1. Verify previous batch merged.
2. Dispatch via Paseo to Claude Code at `/opt/boocode`.
3. Claude Code recon → blocking questions → implement → hand back.
1. Verify previous merged.
2. Dispatch via Paseo to Claude Code at `/opt/boocode` (or OpenCode for smaller batches).
3. Recon → blocking questions → implement → hand back.
4. Compliance review in separate Claude chat.
5. Deploy: `docker compose up --build -d`.
6. Smoke test.

View File

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