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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user