Files
boocode/apps/server/src/routes/chats.ts
indifferentketchup 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

328 lines
10 KiB
TypeScript

import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Broker } from '../services/broker.js';
import type { Chat, Message } from '../types/api.js';
const CreateBody = z.object({
name: z.string().min(1).max(200).optional(),
});
const PatchBody = z.object({
name: z.string().min(1).max(200),
});
const ForkBody = z.object({
message_id: z.string().uuid(),
name: z.string().min(1).max(200).optional(),
});
export function registerChatRoutes(
app: FastifyInstance,
sql: Sql,
broker: Broker
): void {
app.get<{ Params: { id: string }; Querystring: { status?: string } }>(
'/api/sessions/:id/chats',
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 status = req.query.status === 'archived' ? 'archived' : 'open';
// Enriched list: computed per-chat fields via LATERAL joins.
const rows = await sql<Chat[]>`
SELECT
c.id, c.session_id, c.name, c.status, c.created_at, c.updated_at,
COALESCE(mc.cnt, 0)::int AS message_count,
lp.preview AS last_message_preview,
ec.tokens AS effective_context_tokens
FROM chats c
LEFT JOIN LATERAL (
SELECT COUNT(*) AS cnt FROM messages WHERE chat_id = c.id
) mc ON TRUE
LEFT JOIN LATERAL (
SELECT LEFT(BTRIM(REGEXP_REPLACE(content, E'[\\n\\r]+', ' ', 'g')), 80) AS preview
FROM messages
WHERE chat_id = c.id AND kind = 'message' AND content <> ''
ORDER BY created_at DESC
LIMIT 1
) lp ON TRUE
LEFT JOIN LATERAL (
SELECT ctx_used AS tokens
FROM messages
WHERE chat_id = c.id AND kind = 'message' AND role = 'assistant'
AND status = 'complete' AND ctx_used IS NOT NULL
ORDER BY created_at DESC
LIMIT 1
) ec ON TRUE
WHERE c.session_id = ${req.params.id} AND c.status = ${status}
ORDER BY c.updated_at DESC
`;
return rows;
}
);
app.post<{ Params: { id: string } }>(
'/api/sessions/:id/chats',
async (req, reply) => {
const parsed = CreateBody.safeParse(req.body ?? {});
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
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 [chat] = await sql<Chat[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${req.params.id}, ${parsed.data.name ?? null}, 'open')
RETURNING id, session_id, name, status, created_at, updated_at
`;
broker.publishUser('default', {
type: 'chat_created',
chat: chat!,
session_id: req.params.id,
});
reply.code(201);
return chat;
}
);
app.patch<{ Params: { id: string } }>(
'/api/chats/: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 rows = await sql<Chat[]>`
UPDATE chats
SET name = ${parsed.data.name},
updated_at = clock_timestamp()
WHERE id = ${req.params.id}
RETURNING id, session_id, name, status, created_at, updated_at
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const chat = rows[0]!;
broker.publishUser('default', {
type: 'chat_updated',
chat_id: chat.id,
session_id: chat.session_id,
name: chat.name,
updated_at: chat.updated_at,
});
return chat;
}
);
// 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) => {
const rows = await sql<{ id: string; session_id: string }[]>`
UPDATE chats SET status = 'archived', updated_at = clock_timestamp()
WHERE id = ${req.params.id} AND status = 'open'
RETURNING id, session_id
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'chat not found or already archived' };
}
const row = rows[0]!;
broker.publishUser('default', {
type: 'chat_archived',
chat_id: row.id,
session_id: row.session_id,
});
reply.code(204);
return null;
}
);
app.post<{ Params: { id: string } }>(
'/api/chats/:id/unarchive',
async (req, reply) => {
const rows = await sql<Chat[]>`
UPDATE chats SET status = 'open', updated_at = clock_timestamp()
WHERE id = ${req.params.id} AND status = 'archived'
RETURNING id, session_id, name, status, created_at, updated_at
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'chat not found or not archived' };
}
const chat = rows[0]!;
broker.publishUser('default', { type: 'chat_unarchived', chat });
return chat;
}
);
app.delete<{ Params: { id: string } }>(
'/api/chats/:id',
async (req, reply) => {
const result = await sql<{ id: string; session_id: string }[]>`
DELETE FROM chats WHERE id = ${req.params.id}
RETURNING id, session_id
`;
if (result.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const row = result[0]!;
broker.publishUser('default', {
type: 'chat_deleted',
chat_id: row.id,
session_id: row.session_id,
});
reply.code(204);
return null;
}
);
app.post<{ Params: { id: string } }>(
'/api/chats/:id/fork',
async (req, reply) => {
const parsed = ForkBody.safeParse(req.body ?? {});
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const sourceRows = await sql<Chat[]>`
SELECT id, session_id, name, status, created_at, updated_at
FROM chats WHERE id = ${req.params.id}
`;
if (sourceRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const source = sourceRows[0]!;
const targetRows = await sql<{ created_at: string; status: string }[]>`
SELECT created_at, status FROM messages
WHERE chat_id = ${source.id} AND id = ${parsed.data.message_id}
`;
if (targetRows.length === 0) {
reply.code(404);
return { error: 'message not found in chat' };
}
const target = targetRows[0]!;
if (target.status !== 'complete') {
reply.code(400);
return { error: 'can only fork from completed messages' };
}
const newName = parsed.data.name ?? `${source.name ?? 'Chat'} (fork)`;
const newChat = await sql.begin(async (tx) => {
const [chat] = await tx<Chat[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${source.session_id}, ${newName}, 'open')
RETURNING id, session_id, name, status, created_at, updated_at
`;
await tx`
INSERT INTO messages (
session_id, chat_id, role, content, kind, tool_calls, tool_results,
status, tokens_used, ctx_used, ctx_max, started_at, finished_at,
created_at, metadata
)
SELECT
${source.session_id}, ${chat!.id}, role, content, kind,
tool_calls, tool_results, status,
tokens_used, ctx_used, ctx_max, started_at, finished_at,
clock_timestamp() + (
ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) * INTERVAL '1 microsecond'
),
metadata
FROM messages
WHERE chat_id = ${source.id}
AND created_at <= ${target.created_at}::timestamptz
AND status = 'complete'
`;
return chat!;
});
broker.publishUser('default', {
type: 'chat_created',
chat: newChat,
session_id: source.session_id,
});
reply.code(201);
return newChat;
}
);
app.get<{ Params: { id: string } }>(
'/api/chats/:id/messages',
async (req, reply) => {
const chat = await sql`SELECT id FROM chats WHERE id = ${req.params.id}`;
if (chat.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const rows = await sql<Message[]>`
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata
FROM messages
WHERE chat_id = ${req.params.id}
ORDER BY created_at ASC, id ASC
`;
return rows;
}
);
}