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>
This commit is contained in:
2026-05-17 17:37:29 +00:00
parent 32c1a2b5f6
commit 09aecc4ee9
23 changed files with 1244 additions and 181 deletions

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

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

@@ -171,3 +171,11 @@ ALTER TABLE messages ADD COLUMN IF NOT EXISTS metadata JSONB;
-- 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

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

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