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>
311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import { z } from 'zod';
|
|
import type { Sql } from '../db.js';
|
|
import type { Config } from '../config.js';
|
|
import type { Broker } from '../services/broker.js';
|
|
import type { Session } from '../types/api.js';
|
|
import { getSetting } from './settings.js';
|
|
import { getAgentsForProject } from '../services/agents.js';
|
|
|
|
const CreateBody = z.object({
|
|
name: z.string().min(1).max(200).optional(),
|
|
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(),
|
|
});
|
|
|
|
const PatchBody = z.object({
|
|
name: z.string().min(1).max(200).optional(),
|
|
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> {
|
|
const fromDb = await getSetting<string>(sql, 'default_model');
|
|
if (typeof fromDb === 'string' && fromDb.length > 0) return fromDb;
|
|
return config.DEFAULT_MODEL;
|
|
}
|
|
|
|
// First agent in the project's effective list (file-defined or builtin),
|
|
// or null if somehow none exist.
|
|
async function resolveDefaultAgent(projectPath: string): Promise<string | null> {
|
|
const { agents } = await getAgentsForProject(projectPath);
|
|
return agents[0]?.id ?? null;
|
|
}
|
|
|
|
export function registerSessionRoutes(
|
|
app: FastifyInstance,
|
|
sql: Sql,
|
|
config: Config,
|
|
broker: Broker
|
|
): void {
|
|
app.get<{ Params: { id: string }; Querystring: { status?: string } }>(
|
|
'/api/projects/:id/sessions',
|
|
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 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, web_search_enabled
|
|
FROM sessions
|
|
WHERE project_id = ${req.params.id} AND status = ${status}
|
|
ORDER BY updated_at DESC
|
|
`;
|
|
return rows;
|
|
}
|
|
);
|
|
|
|
app.post<{ Params: { id: string } }>(
|
|
'/api/projects/:id/sessions',
|
|
async (req, reply) => {
|
|
const parsed = CreateBody.safeParse(req.body ?? {});
|
|
if (!parsed.success) {
|
|
reply.code(400);
|
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
|
}
|
|
const project = await sql<{ id: string; path: string }[]>`
|
|
SELECT id, path FROM projects WHERE id = ${req.params.id}
|
|
`;
|
|
if (project.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'project not found' };
|
|
}
|
|
const projectPath = project[0]!.path;
|
|
|
|
let model = parsed.data.model;
|
|
if (!model) {
|
|
const lastUsed = await sql<{ model: string }[]>`
|
|
SELECT model FROM sessions
|
|
WHERE project_id = ${req.params.id}
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
`;
|
|
model = lastUsed[0]?.model ?? (await resolveDefaultModel(sql, config));
|
|
}
|
|
|
|
const name = parsed.data.name ?? 'New session';
|
|
const systemPrompt = parsed.data.system_prompt ?? '';
|
|
// If the client provided agent_id (string or null), use it; otherwise
|
|
// resolve to the project's first agent (file-defined or builtin), or null.
|
|
const agentId =
|
|
parsed.data.agent_id !== undefined
|
|
? parsed.data.agent_id
|
|
: await resolveDefaultAgent(projectPath);
|
|
|
|
const row = await sql.begin(async (tx) => {
|
|
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, web_search_enabled
|
|
`;
|
|
await tx`
|
|
INSERT INTO chats (session_id, name, status)
|
|
VALUES (${session!.id}, NULL, 'open')
|
|
`;
|
|
return session!;
|
|
});
|
|
broker.publishUser('default', {
|
|
type: 'session_created',
|
|
session: row,
|
|
project_id: row.project_id,
|
|
});
|
|
reply.code(201);
|
|
return row;
|
|
}
|
|
);
|
|
|
|
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, web_search_enabled
|
|
FROM sessions WHERE id = ${req.params.id}
|
|
`;
|
|
if (rows.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'session not found' };
|
|
}
|
|
return rows[0];
|
|
});
|
|
|
|
app.patch<{ Params: { id: string } }>(
|
|
'/api/sessions/:id',
|
|
async (req, reply) => {
|
|
const parsed = PatchBody.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
reply.code(400);
|
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
|
}
|
|
const { name, model, system_prompt } = parsed.data;
|
|
// 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;
|
|
// a concurrent rename in that gap would just mean one stale publish, which
|
|
// existing clients dedup by id.
|
|
const before = await sql<{ name: string }[]>`
|
|
SELECT name FROM sessions WHERE id = ${req.params.id}
|
|
`;
|
|
const priorName = before[0]?.name;
|
|
const rows = await sql<Session[]>`
|
|
UPDATE sessions
|
|
SET
|
|
name = COALESCE(${name ?? null}, name),
|
|
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, web_search_enabled
|
|
`;
|
|
if (rows.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'session not found' };
|
|
}
|
|
const session = rows[0]!;
|
|
if (name !== undefined && session.name !== priorName) {
|
|
broker.publishUser('default', {
|
|
type: 'session_renamed',
|
|
session_id: session.id,
|
|
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) => {
|
|
const rows = await sql<{ id: string; project_id: string }[]>`
|
|
UPDATE sessions SET status = 'archived', updated_at = clock_timestamp()
|
|
WHERE id = ${req.params.id} AND status = 'open'
|
|
RETURNING id, project_id
|
|
`;
|
|
if (rows.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'session not found or already archived' };
|
|
}
|
|
broker.publishUser('default', {
|
|
type: 'session_archived',
|
|
session_id: rows[0]!.id,
|
|
project_id: rows[0]!.project_id,
|
|
});
|
|
reply.code(204);
|
|
return null;
|
|
}
|
|
);
|
|
|
|
app.post<{ Params: { id: string } }>(
|
|
'/api/sessions/:id/unarchive',
|
|
async (req, reply) => {
|
|
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, web_search_enabled
|
|
`;
|
|
if (rows.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'session not found or not archived' };
|
|
}
|
|
const session = rows[0]!;
|
|
broker.publishUser('default', {
|
|
type: 'session_created',
|
|
session: session,
|
|
project_id: session.project_id,
|
|
});
|
|
reply.code(200);
|
|
return session;
|
|
}
|
|
);
|
|
|
|
app.delete<{ Params: { id: string } }>(
|
|
'/api/sessions/:id',
|
|
async (req, reply) => {
|
|
const id = req.params.id;
|
|
const deleted = await sql<{ project_id: string }[]>`
|
|
DELETE FROM sessions WHERE id = ${id} RETURNING project_id
|
|
`;
|
|
if (deleted.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'not found' };
|
|
}
|
|
const project_id = deleted[0]!.project_id;
|
|
broker.publishUser('default', { type: 'session_deleted', session_id: id, project_id });
|
|
reply.code(204);
|
|
return null;
|
|
}
|
|
);
|
|
}
|