From 09aecc4ee9492af248103a45cc716fffdfe8460e Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sun, 17 May 2026 17:37:29 +0000 Subject: [PATCH] v1.9: settings pane + per-project defaults + bulk archive + themes lift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- apps/server/src/routes/chats.ts | 47 ++ apps/server/src/routes/projects.ts | 55 +- apps/server/src/routes/sessions.ts | 80 ++- apps/server/src/schema.sql | 8 + .../src/services/__tests__/inference.test.ts | 3 + apps/server/src/services/inference.ts | 21 +- apps/server/src/types/api.ts | 10 + apps/web/src/api/client.ts | 27 +- apps/web/src/api/types.ts | 11 +- apps/web/src/components/ChatInput.tsx | 70 ++- apps/web/src/components/MobileTabSwitcher.tsx | 3 + apps/web/src/components/ModelPicker.tsx | 76 ++- apps/web/src/components/ProjectSidebar.tsx | 29 +- apps/web/src/components/ThemePicker.tsx | 122 ++++ apps/web/src/components/Workspace.tsx | 86 ++- apps/web/src/components/panes/ChatPane.tsx | 8 +- .../web/src/components/panes/SettingsPane.tsx | 520 ++++++++++++++++++ apps/web/src/hooks/sessionEvents.ts | 9 + apps/web/src/hooks/useSidebar.ts | 4 + apps/web/src/hooks/useWorkspacePanes.ts | 46 +- apps/web/src/pages/Home.tsx | 5 + apps/web/src/pages/Session.tsx | 43 +- apps/web/src/pages/Settings.tsx | 142 ++--- 23 files changed, 1244 insertions(+), 181 deletions(-) create mode 100644 apps/web/src/components/ThemePicker.tsx create mode 100644 apps/web/src/components/panes/SettingsPane.tsx diff --git a/apps/server/src/routes/chats.ts b/apps/server/src/routes/chats.ts index b03dc54..b845c48 100644 --- a/apps/server/src/routes/chats.ts +++ b/apps/server/src/routes/chats.ts @@ -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) => { diff --git a/apps/server/src/routes/projects.ts b/apps/server/src/routes/projects.ts index 2717748..f3078ae 100644 --- a/apps/server/src/routes/projects.ts +++ b/apps/server/src/routes/projects.ts @@ -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` - 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` 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` + 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` - 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` 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); diff --git a/apps/server/src/routes/sessions.ts b/apps/server/src/routes/sessions.ts index 23e2d12..0fb702c 100644 --- a/apps/server/src/routes/sessions.ts +++ b/apps/server/src/routes/sessions.ts @@ -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 { @@ -50,7 +52,7 @@ export function registerSessionRoutes( } const status = req.query.status === 'archived' ? 'archived' : 'open'; const rows = await sql` - 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` 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` - 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` 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); diff --git a/apps/server/src/schema.sql b/apps/server/src/schema.sql index 98cd8fc..ce31329 100644 --- a/apps/server/src/schema.sql +++ b/apps/server/src/schema.sql @@ -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; diff --git a/apps/server/src/services/__tests__/inference.test.ts b/apps/server/src/services/__tests__/inference.test.ts index 35f0049..5ac9821 100644 --- a/apps/server/src/services/__tests__/inference.test.ts +++ b/apps/server/src/services/__tests__/inference.test.ts @@ -22,6 +22,7 @@ function makeSession(overrides: Partial = {}): 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 { last_session_id: null, status: 'open', gitea_remote: null, + default_system_prompt: '', + default_web_search_enabled: false, ...overrides, }; } diff --git a/apps/server/src/services/inference.ts b/apps/server/src/services/inference.ts index ceb5615..1c24f8e 100644 --- a/apps/server/src/services/inference.ts +++ b/apps/server/src/services/inference.ts @@ -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` - 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` - 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; diff --git a/apps/server/src/types/api.ts b/apps/server/src/types/api.ts index 68d7442..3751c0b 100644 --- a/apps/server/src/types/api.ts +++ b/apps/server/src/types/api.ts @@ -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 diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index c136505..1212de6 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -51,15 +51,29 @@ export const api = { method: 'POST', body: JSON.stringify(body), }), - update: (id: string, body: { name: string }) => + update: ( + id: string, + body: Partial>, + ) => request(`/api/projects/${id}`, { method: 'PATCH', body: JSON.stringify(body), }), + get: (id: string) => request(`/api/projects/${id}`), archive: (id: string) => request(`/api/projects/${id}/archive`, { method: 'POST' }), unarchive: (id: string) => request(`/api/projects/${id}/unarchive`, { method: 'POST' }), + // v1.9: bulk-archive every open session in this project. Server publishes + // one session_archived frame per affected id, so the sidebar reducer + // updates incrementally rather than waiting for a refetch. + archiveAllSessions: (id: string) => + request<{ archived: number; ids: string[] }>( + `/api/projects/${id}/sessions/archive-all`, + { method: 'POST' }, + ), + openSessionsCount: (id: string) => + request<{ count: number }>(`/api/projects/${id}/sessions/open-count`), create: (body: { name: string; commit_message?: string; @@ -106,7 +120,7 @@ export const api = { get: (id: string) => request(`/api/sessions/${id}`), update: ( id: string, - body: Partial> + body: Partial> ) => request(`/api/sessions/${id}`, { method: 'PATCH', @@ -118,6 +132,15 @@ export const api = { request(`/api/sessions/${id}/archive`, { method: 'POST' }), unarchive: (id: string) => request(`/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: { diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 84b610f..adbc2d9 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -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,10 @@ export interface GitMeta { behind: number; } -export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty'; +// v1.9: 'settings' is an ephemeral pane kind — never persisted, always +// singleton per workspace. The pane hook filters it out before writing to +// localStorage and dedupes on insertion via openOrFocusSettingsPane(). +export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty' | 'settings'; export interface WorkspacePane { id: string; diff --git a/apps/web/src/components/ChatInput.tsx b/apps/web/src/components/ChatInput.tsx index 540154b..9c4aa70 100644 --- a/apps/web/src/components/ChatInput.tsx +++ b/apps/web/src/components/ChatInput.tsx @@ -1,8 +1,14 @@ import { useCallback, useEffect, useRef, useState, type DragEvent, type KeyboardEvent } from 'react'; -import { Send } from 'lucide-react'; +import { Check, Plus, Send } from 'lucide-react'; import { toast } from 'sonner'; import { Textarea } from '@/components/ui/textarea'; import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { flattenToMessage, inferLanguage, @@ -29,11 +35,18 @@ interface Props { // When omitted, the toolbar row is hidden entirely. agentId?: string | null; onAgentChange?: (agentId: string | null) => void | Promise; + // 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; onForceSend?: (content: string) => void | Promise; } -export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend, onForceSend }: Props) { +export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend }: Props) { const { isMobile } = useViewport(); const [value, setValue] = useState(''); const [busy, setBusy] = useState(false); @@ -425,16 +438,51 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend, ))} )} - {/* 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) && (
- + {onAgentChange && ( + + )} + {sessionId && ( + + + + + + { + // 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" + > + + Web search + + + + )}
)}
diff --git a/apps/web/src/components/MobileTabSwitcher.tsx b/apps/web/src/components/MobileTabSwitcher.tsx index b1c805d..a77e979 100644 --- a/apps/web/src/components/MobileTabSwitcher.tsx +++ b/apps/web/src/components/MobileTabSwitcher.tsx @@ -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 ; if (kind === 'agent') return ; + if (kind === 'settings') return ; return ; } @@ -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'; } diff --git a/apps/web/src/components/ModelPicker.tsx b/apps/web/src/components/ModelPicker.tsx index e2920d5..7c99b43 100644 --- a/apps/web/src/components/ModelPicker.tsx +++ b/apps/web/src/components/ModelPicker.tsx @@ -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; } +// 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
{error}
; + } + if (models === null) { + return
Loading…
; + } + return ( + <> + {models.map((m) => ( + + ))} + + ); +} + export function ModelPicker({ value, onChange }: Props) { + const { isMobile } = useViewport(); const [models, setModels] = useState(null); const [error, setError] = useState(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 ( + <> + + setOpen(false)} title="Model"> +
+ +
+
+ + ); + } + return ( @@ -49,7 +117,7 @@ export function ModelPicker({ value, onChange }: Props) { {models?.map((m) => ( void onChange(m.id)} + onSelect={() => handlePick(m.id)} className="font-mono text-xs" > 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() { })} + {/* 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. */} +
+ +
+ {}} /> { if (!open) setArchiveProjectConfirm(null); }}> diff --git a/apps/web/src/components/ThemePicker.tsx b/apps/web/src/components/ThemePicker.tsx new file mode 100644 index 0000000..40d7056 --- /dev/null +++ b/apps/web/src/components/ThemePicker.tsx @@ -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 ( +
+
+

Mode

+ void pickMode(v as ThemeMode)} + className="flex flex-wrap gap-4" + > + {MODES.map((m) => ( +
+ + +
+ ))} +
+
+ +
+

Theme

+
+ {THEMES.map((t) => { + const isActive = t.id === currentId; + const isPending = pending?.kind === 'theme' && pending.id === t.id; + const isLightOnly = !t.supportsDark; + return ( + void pickTheme(t.id)} + className={cn( + 'p-3 cursor-pointer transition-colors', + 'hover:bg-accent/10', + isActive && 'ring-2 ring-ring', + isPending && 'opacity-60', + )} + > +
+
+
{t.name}
+
{t.family}
+
+ {isActive && ( + + Selected + + )} +
+
+ {t.anchors.map((hex, i) => ( + + {isLightOnly && ( +
Light only
+ )} + + ); + })} +
+
+
+ ); +} diff --git a/apps/web/src/components/Workspace.tsx b/apps/web/src/components/Workspace.tsx index 67dfddf..52068e1 100644 --- a/apps/web/src/components/Workspace.tsx +++ b/apps/web/src/components/Workspace.tsx @@ -1,9 +1,11 @@ +import { useEffect, useState } from 'react'; import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react'; -import type { Chat, WorkspacePane } from '@/api/types'; +import type { Chat, Project, Session, WorkspacePane } from '@/api/types'; import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes'; import type { UseSessionChatsResult } from '@/hooks/useSessionChats'; import { useViewport } from '@/hooks/useViewport'; import { ChatPane } from '@/components/panes/ChatPane'; +import { SettingsPane } from '@/components/panes/SettingsPane'; import { ChatTabBar } from '@/components/ChatTabBar'; import { SessionLandingPage } from '@/components/SessionLandingPage'; import { @@ -24,6 +26,9 @@ interface Props { // (MobileTabSwitcher) can share state with the pane grid. panesHook: UseWorkspacePanesResult; chatsHook: UseSessionChatsResult; + // v1.9: passed through to SettingsPane when one is mounted in the grid. + session: Session; + project: Project | null; } export function Workspace({ @@ -33,6 +38,8 @@ export function Workspace({ onAgentChange, panesHook, chatsHook, + session, + project, }: Props) { const { panes, @@ -67,6 +74,28 @@ export function Workspace({ const { isMobile } = useViewport(); + // v1.9: workspace-level maximize state for the settings pane. CSS-only: + // sibling panes get display:none, the maximized pane fills the grid cell. + // ESC listener only mounted while maximized. Mobile is always full-width + // for a single pane so maximize doesn't apply. + const [maximized, setMaximized] = useState(false); + const settingsIdx = panes.findIndex((p) => p.kind === 'settings'); + + useEffect(() => { + if (!maximized) return; + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') setMaximized(false); + } + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [maximized]); + + // If the settings pane was closed (no longer in panes) while maximized, + // clear the maximize state so the grid renders normally. + useEffect(() => { + if (maximized && settingsIdx < 0) setMaximized(false); + }, [maximized, settingsIdx]); + function chatsForPane(pane: WorkspacePane): Chat[] { return pane.chatIds .map((id) => chats.find((c) => c.id === id)) @@ -81,10 +110,12 @@ export function Workspace({ + ); +} + +export function SettingsPane({ session, project, maximized, onToggleMaximize, isMobile }: Props) { + const [activeSection, setActiveSection] = useState
('session'); + + return ( +
+
+
+ {(['session', 'project', 'theme'] as const).map((s) => ( + + ))} +
+ {!isMobile && ( + + )} +
+ +
+
+ {activeSection === 'session' && } + {activeSection === 'project' && } + {activeSection === 'theme' && } +
+
+
+ ); +} + +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(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 ( +
+
+ + 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" + /> +
+ +
+ +
+ { + 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'); + } + }} + /> +
+
+ +
+
+ + setWebSearch(v)} + /> +
+
+ setWebSearch(e.target.checked ? null : project.default_web_search_enabled)} + /> + +
+

+ Plumbed for Batch 8 (web_search tool). No effect yet. +

+
+ +
+
+ + +
+