From 2bc626a40a83210d3e495f3ac7ffe12216ed9fe2 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Fri, 15 May 2026 14:53:21 +0000 Subject: [PATCH] batch3 T2: panes CRUD route + default chat pane on session POST Adds /api/sessions/:id/panes (GET, POST), /api/panes/:id (PATCH, DELETE) with transactional position-shift logic (negate-and-restore pattern to avoid UNIQUE collisions). Max 5 panes per session enforced. Sessions.POST now creates the session and a default Chat pane at position 0 atomically via sql.begin. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/server/src/index.ts | 2 + apps/server/src/routes/panes.ts | 198 +++++++++++++++++++++++++++++ apps/server/src/routes/sessions.ts | 17 ++- 3 files changed, 212 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/routes/panes.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index cc2b673..2aaa1ff 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -13,6 +13,7 @@ import { registerMessageRoutes } from './routes/messages.js'; import { registerSidebarRoutes } from './routes/sidebar.js'; import { registerWebSocket } from './routes/ws.js'; import { registerModelRoutes } from './routes/models.js'; +import { registerPaneRoutes } from './routes/panes.js'; import { createInferenceRunner } from './services/inference.js'; import { createBroker } from './services/broker.js'; @@ -41,6 +42,7 @@ async function main() { registerSettingsRoutes(app, sql); registerModelRoutes(app, config); registerSidebarRoutes(app, sql); + registerPaneRoutes(app, sql); const broker = createBroker(); const inference = createInferenceRunner({ diff --git a/apps/server/src/routes/panes.ts b/apps/server/src/routes/panes.ts new file mode 100644 index 0000000..9bb04ac --- /dev/null +++ b/apps/server/src/routes/panes.ts @@ -0,0 +1,198 @@ +import type { FastifyInstance } from 'fastify'; +import type { Sql } from '../db.js'; +import type { Pane, PaneCreateRequest, PaneUpdateRequest } from '../types/api.js'; + +const VALID_KINDS = new Set(['chat', 'file_browser']); +const MAX_PANES = 5; + +async function movePane( + sql: Sql, + paneId: string, + sid: string, + oldPos: number, + newPos: number +): Promise { + if (oldPos === newPos) return; + await sql.begin(async (tx) => { + // Move target pane to temporary negative slot to avoid collision + await tx`UPDATE session_panes SET position = -1 WHERE id = ${paneId}`; + if (newPos > oldPos) { + await tx`UPDATE session_panes SET position = -position + WHERE session_id = ${sid} AND position > ${oldPos} AND position <= ${newPos}`; + await tx`UPDATE session_panes SET position = -position - 1 + WHERE session_id = ${sid} AND position < 0 AND id != ${paneId}`; + } else { + await tx`UPDATE session_panes SET position = -position - 2 + WHERE session_id = ${sid} AND position >= ${newPos} AND position < ${oldPos}`; + await tx`UPDATE session_panes SET position = -position - 1 + WHERE session_id = ${sid} AND position < -1`; + } + await tx`UPDATE session_panes SET position = ${newPos} WHERE id = ${paneId}`; + }); +} + +export function registerPaneRoutes(app: FastifyInstance, sql: Sql): void { + // GET /api/sessions/:id/panes — list panes ordered by position ASC + app.get<{ Params: { id: string } }>( + '/api/sessions/:id/panes', + async (req, reply) => { + const sessionRows = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`; + if (sessionRows.length === 0) { + reply.code(404); + return { error: 'session not found' }; + } + const panes = await sql` + SELECT id, session_id, position, kind, state, created_at + FROM session_panes + WHERE session_id = ${req.params.id} + ORDER BY position ASC + `; + return { panes }; + } + ); + + // POST /api/sessions/:id/panes — create a new pane + app.post<{ Params: { id: string } }>( + '/api/sessions/:id/panes', + async (req, reply) => { + const body = (req.body ?? {}) as PaneCreateRequest; + const { kind, position } = body; + + if (!kind || !VALID_KINDS.has(kind)) { + reply.code(400); + return { error: 'kind must be "chat" or "file_browser"' }; + } + + const sessionRows = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`; + if (sessionRows.length === 0) { + reply.code(404); + return { error: 'session not found' }; + } + + const sid = req.params.id; + + const countRows = await sql<{ n: number }[]>` + SELECT COUNT(*)::int AS n FROM session_panes WHERE session_id = ${sid} + `; + const count = countRows[0]?.n ?? 0; + + if (count >= MAX_PANES) { + reply.code(400); + return { error: `session already has ${MAX_PANES} panes (maximum)` }; + } + + // Determine insert position + let insertPos: number; + if (position === undefined || position === null) { + insertPos = count; + } else { + if (position < 0 || position > count) { + reply.code(400); + return { error: `position must be between 0 and ${count} (existing count)` }; + } + insertPos = position; + } + + const state = {}; + + const inserted = await sql.begin(async (tx) => { + await tx`UPDATE session_panes SET position = -position - 1 + WHERE session_id = ${sid} AND position >= ${insertPos}`; + const [row] = await tx` + INSERT INTO session_panes (session_id, position, kind, state) + VALUES (${sid}, ${insertPos}, ${kind}, ${JSON.stringify(state)}::jsonb) + RETURNING id, session_id, position, kind, state, created_at + `; + await tx`UPDATE session_panes SET position = -position + WHERE session_id = ${sid} AND position < 0`; + return row; + }); + + reply.code(201); + return inserted as Pane; + } + ); + + // PATCH /api/panes/:id — update state and/or position + app.patch<{ Params: { id: string } }>( + '/api/panes/:id', + async (req, reply) => { + const body = (req.body ?? {}) as PaneUpdateRequest; + const { state, position } = body; + + if (state === undefined && position === undefined) { + reply.code(400); + return { error: 'must provide at least one of: state, position' }; + } + + const paneRows = await sql` + SELECT id, session_id, position, kind, state, created_at + FROM session_panes WHERE id = ${req.params.id} + `; + if (paneRows.length === 0) { + reply.code(404); + return { error: 'pane not found' }; + } + const pane = paneRows[0]!; + const sid = pane.session_id; + const oldPos = pane.position; + + // Validate position if provided + if (position !== undefined) { + const countRows = await sql<{ n: number }[]>` + SELECT COUNT(*)::int AS n FROM session_panes WHERE session_id = ${sid} + `; + const count = countRows[0]?.n ?? 0; + if (position < 0 || position >= count) { + reply.code(400); + return { error: `position must be between 0 and ${count - 1}` }; + } + } + + // Apply position change if needed + if (position !== undefined && position !== oldPos) { + await movePane(sql, req.params.id, sid, oldPos, position); + } + + // Apply state change if provided + if (state !== undefined) { + await sql` + UPDATE session_panes SET state = ${JSON.stringify(state)}::jsonb + WHERE id = ${req.params.id} + `; + } + + const [updated] = await sql` + SELECT id, session_id, position, kind, state, created_at + FROM session_panes WHERE id = ${req.params.id} + `; + return updated as Pane; + } + ); + + // DELETE /api/panes/:id — delete a pane, shift remaining down + app.delete<{ Params: { id: string } }>( + '/api/panes/:id', + async (req, reply) => { + const paneRows = await sql<{ id: string; session_id: string; position: number }[]>` + SELECT id, session_id, position FROM session_panes WHERE id = ${req.params.id} + `; + if (paneRows.length === 0) { + reply.code(404); + return { error: 'pane not found' }; + } + const { session_id: sid, position: P } = paneRows[0]!; + + await sql.begin(async (tx) => { + await tx`DELETE FROM session_panes WHERE id = ${req.params.id}`; + await tx`UPDATE session_panes SET position = -position + WHERE session_id = ${sid} AND position > ${P}`; + await tx`UPDATE session_panes SET position = -position - 1 + WHERE session_id = ${sid} AND position < 0`; + }); + + reply.code(204); + return null; + } + ); +} diff --git a/apps/server/src/routes/sessions.ts b/apps/server/src/routes/sessions.ts index 875cf87..13cb747 100644 --- a/apps/server/src/routes/sessions.ts +++ b/apps/server/src/routes/sessions.ts @@ -74,11 +74,18 @@ export function registerSessionRoutes( const name = parsed.data.name ?? 'New session'; const systemPrompt = parsed.data.system_prompt ?? ''; - const [row] = await sql` - INSERT INTO sessions (project_id, name, model, system_prompt) - VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt}) - RETURNING id, project_id, name, model, system_prompt, created_at, updated_at - `; + const row = await sql.begin(async (tx) => { + const [session] = await tx` + INSERT INTO sessions (project_id, name, model, system_prompt) + VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt}) + RETURNING id, project_id, name, model, system_prompt, created_at, updated_at + `; + await tx` + INSERT INTO session_panes (session_id, position, kind, state) + VALUES (${session!.id}, 0, 'chat', '{}'::jsonb) + `; + return session!; + }); reply.code(201); return row; }