batch4: chats-in-sessions, force-send, /compact, right-rail file browser

Session 1:N Chat data model with backfill. Workspace switches to client-side
multi-tab pane management. Right-rail file browser with float-over viewer and
click-drag line selection replaces FileBrowserPane. Adds /compact streaming
summarizer (respects compact markers in context builder), force-send (cancels
in-flight, persists partial as 'cancelled', awaits cancellation completion via
deferred Promise + 5s timeout), message queue, stop generation, chat
auto-rename, session archive/unarchive with Closed Sessions section on repo
landing page. CHECK constraints on sessions.status, messages.role,
messages.status with KEEP IN SYNC comments tying to MESSAGE_ROLES /
MESSAGE_STATUSES const arrays. Deletes dead pane routes/hook and the
api.panes.* client block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 20:39:48 +00:00
parent 6d9515b8a5
commit c35ec65fc4
37 changed files with 3290 additions and 1012 deletions

View File

@@ -1,21 +1,23 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Message, Session } from '../types/api.js';
import { requireUser } from '../auth.js';
import type { Chat, Message, Session } from '../types/api.js';
const SendBody = z.object({
content: z.string().min(1).max(64_000),
});
interface MessageHandlers {
enqueueInference: (sessionId: string, assistantMessageId: string, user: string) => void;
enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
enqueueCompact: (sessionId: string, chatId: string, compactMessageId: string, user: string) => void;
publishUserMessage: (
sessionId: string,
chatId: string,
userMessageId: string,
content: string
) => void;
publishMessagesDeleted: (sessionId: string, messageIds: string[]) => void;
publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void;
cancelInference: (sessionId: string, chatId: string) => Promise<boolean>;
}
export function registerMessageRoutes(
@@ -32,7 +34,7 @@ export function registerMessageRoutes(
return { error: 'session not found' };
}
const rows = await sql<Message[]>`
SELECT id, session_id, role, content, tool_calls, tool_results, status, last_seq,
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
FROM messages
WHERE session_id = ${req.params.id}
@@ -43,7 +45,7 @@ export function registerMessageRoutes(
);
app.post<{ Params: { id: string } }>(
'/api/sessions/:id/messages',
'/api/chats/:id/messages',
async (req, reply) => {
const parsed = SendBody.safeParse(req.body);
if (!parsed.success) {
@@ -51,33 +53,39 @@ export function registerMessageRoutes(
return { error: 'invalid body', details: parsed.error.flatten() };
}
const session = await sql<Session[]>`SELECT id FROM sessions WHERE id = ${req.params.id}`;
if (session.length === 0) {
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'session not found' };
return { error: 'chat not found' };
}
const chat = chatRows[0]!;
const sessionId = chat.session_id;
const result = await sql.begin(async (tx) => {
const [userMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, role, content, status, created_at)
VALUES (${req.params.id}, 'user', ${parsed.data.content}, 'complete', clock_timestamp())
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'user', ${parsed.data.content}, 'complete', clock_timestamp())
RETURNING id
`;
const [assistantMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, role, content, status, created_at)
VALUES (${req.params.id}, 'assistant', '', 'streaming', clock_timestamp())
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await tx`UPDATE sessions SET updated_at = NOW() WHERE id = ${req.params.id}`;
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
});
handlers.publishUserMessage(
req.params.id,
sessionId,
chat.id,
result.user_message_id,
parsed.data.content
);
handlers.enqueueInference(req.params.id, result.assistant_message_id, requireUser(req));
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
reply.code(202);
return result;
@@ -85,14 +93,24 @@ export function registerMessageRoutes(
);
app.post<{ Params: { id: string; message_id: string } }>(
'/api/sessions/:id/messages/:message_id/regenerate',
'/api/chats/:id/messages/:message_id/regenerate',
async (req, reply) => {
const { id: sessionId, message_id: targetId } = req.params;
const { id: chatId, message_id: targetId } = req.params;
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${chatId}
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const chat = chatRows[0]!;
const sessionId = chat.session_id;
const target = await sql<{ id: string; role: string; status: string }[]>`
SELECT id, role, status
FROM messages
WHERE session_id = ${sessionId} AND id = ${targetId}
WHERE chat_id = ${chatId} AND id = ${targetId}
`;
if (target.length === 0) {
reply.code(404);
@@ -109,34 +127,141 @@ export function registerMessageRoutes(
}
const { newAssistantId, deletedIds } = await sql.begin(async (tx) => {
// Subquery keeps created_at in postgres at TIMESTAMPTZ µs precision.
// Round-tripping through JS Date loses sub-ms precision and can pull
// earlier rows (e.g. the triggering user message) into the >= bound.
const deletedRows = await tx<{ id: string }[]>`
DELETE FROM messages
WHERE session_id = ${sessionId}
WHERE chat_id = ${chatId}
AND created_at >= (
SELECT created_at FROM messages WHERE id = ${targetId}
)
RETURNING id
`;
const [row] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, role, content, status, created_at)
VALUES (${sessionId}, 'assistant', '', 'streaming', clock_timestamp())
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await tx`UPDATE sessions SET updated_at = NOW() WHERE id = ${sessionId}`;
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
return {
newAssistantId: row!.id,
deletedIds: deletedRows.map((r) => r.id),
};
});
handlers.publishMessagesDeleted(sessionId, deletedIds);
handlers.enqueueInference(sessionId, newAssistantId, requireUser(req));
handlers.publishMessagesDeleted(sessionId, chatId, deletedIds);
handlers.enqueueInference(sessionId, chatId, newAssistantId, 'default');
reply.code(202);
return { assistant_message_id: newAssistantId };
}
);
app.post<{ Params: { id: string } }>(
'/api/chats/:id/compact',
async (req, reply) => {
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const chat = chatRows[0]!;
const sessionId = chat.session_id;
const [compactMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, kind, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'system', '', 'compact', 'streaming', clock_timestamp())
RETURNING id
`;
handlers.enqueueCompact(sessionId, chat.id, compactMsg!.id, 'default');
reply.code(202);
return { compact_message_id: compactMsg!.id };
}
);
app.post<{ Params: { id: string } }>(
'/api/chats/:id/stop',
async (req, reply) => {
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${req.params.id}
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const chat = chatRows[0]!;
const cancelled = await handlers.cancelInference(chat.session_id, chat.id);
if (!cancelled) {
reply.code(409);
return { error: 'no active generation to stop' };
}
reply.code(200);
return { stopped: true };
}
);
app.post<{ Params: { id: string } }>(
'/api/chats/:id/force_send',
async (req, reply) => {
const parsed = SendBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const chat = chatRows[0]!;
const sessionId = chat.session_id;
// Await actual cancellation completion (catch block persists state).
// 5s timeout guards against llama-swap stalls; if hit, proceed anyway.
await Promise.race([
handlers.cancelInference(sessionId, chat.id).then(() => undefined),
new Promise<void>((_, rej) =>
setTimeout(() => rej(new Error('cancel-timeout')), 5000)
),
]).catch((e: Error) => {
if (e.message !== 'cancel-timeout') throw e;
req.log.warn({ chatId: chat.id }, 'cancel timeout exceeded, proceeding with force-send');
});
const result = await sql.begin(async (tx) => {
const [userMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'user', ${parsed.data.content}, 'complete', clock_timestamp())
RETURNING id
`;
const [assistantMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
});
handlers.publishUserMessage(
sessionId,
chat.id,
result.user_message_id,
parsed.data.content
);
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
reply.code(202);
return result;
}
);
}