Files
boocode/apps/server/src/routes/sessions.ts
indifferentketchup 59fe6f0522 v1.4-fork-header: fork from message + delete message + header polish + housekeeping
- Fork: POST /api/chats/:id/fork creates a new chat in the same session,
  copies messages up to target (status=complete) with row-offset
  clock_timestamp() for stable ordering. Client emits open_chat_in_active_pane
  event; Workspace opens it in the active pane. No maybeAutoNameChat on forks.
- Delete: DELETE /api/chats/:id/messages/:message_id with 409 if the chat is
  currently streaming. Cascading-forward delete (created_at >= target).
  MessageBubble Trash button + confirm Dialog.
- Header: Projects -> Project -> Session breadcrumb, model badge pill,
  inline session rename, active file path via new useActivePane() hook.
  Server now publishes session_renamed on PATCH /api/sessions/:id;
  client-side dup emit removed from Session.tsx.
- Housekeeping: NOW() -> clock_timestamp() in schema.sql defaults, dead
  PaneTab.tsx and panes/PaneShell.tsx removed, session_panes backfill
  INSERT removed (CREATE TABLE retained), Tailnet trust comment near
  app.listen().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 04:12:01 +00:00

212 lines
6.8 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';
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(),
});
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(),
});
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;
}
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
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`SELECT id FROM projects WHERE id = ${req.params.id}`;
if (project.length === 0) {
reply.code(404);
return { error: 'project not found' };
}
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 ?? '';
const row = await sql.begin(async (tx) => {
const [session] = await tx<Session[]>`
INSERT INTO sessions (project_id, name, model, system_prompt)
VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt})
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at
`;
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
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;
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),
updated_at = clock_timestamp()
WHERE id = ${req.params.id}
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
const session = rows[0]!;
if (name !== undefined) {
broker.publishUser('default', {
type: 'session_renamed',
session_id: session.id,
name: session.name,
});
}
return session;
}
);
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
`;
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;
}
);
}