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>
This commit is contained in:
@@ -83,6 +83,7 @@ async function main() {
|
||||
cancelInference: async (sessionId, chatId) => {
|
||||
return inference.cancel(sessionId, chatId);
|
||||
},
|
||||
hasActiveInference: (chatId) => inference.hasActive(chatId),
|
||||
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
|
||||
broker.publish(sessionId, {
|
||||
type: 'message_started',
|
||||
@@ -144,6 +145,9 @@ async function main() {
|
||||
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||
|
||||
// Bound to 0.0.0.0 intentionally. Public access goes through Caddy → Authelia.
|
||||
// Direct Tailscale access (100.114.205.53:9500) is unauthenticated by design;
|
||||
// the threat model treats Tailnet membership as the trust boundary.
|
||||
await app.listen({ port: config.PORT, host: config.HOST });
|
||||
app.log.info(`boocode server listening on http://${config.HOST}:${config.PORT}`);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@ 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,
|
||||
@@ -181,6 +186,78 @@ export function registerChatRoutes(
|
||||
}
|
||||
);
|
||||
|
||||
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
|
||||
)
|
||||
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'
|
||||
)
|
||||
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) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ interface MessageHandlers {
|
||||
) => void;
|
||||
publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void;
|
||||
cancelInference: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||
hasActiveInference: (chatId: string) => boolean;
|
||||
}
|
||||
|
||||
export function registerMessageRoutes(
|
||||
@@ -156,6 +157,53 @@ export function registerMessageRoutes(
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string; message_id: string } }>(
|
||||
'/api/chats/:id/messages/:message_id',
|
||||
async (req, reply) => {
|
||||
const { id: chatId, message_id: messageId } = 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]!;
|
||||
|
||||
if (handlers.hasActiveInference(chatId)) {
|
||||
reply.code(409);
|
||||
return { error: 'chat is currently streaming; stop it first' };
|
||||
}
|
||||
|
||||
const deletedIds = await sql.begin(async (tx) => {
|
||||
const deletedRows = await tx<{ id: string }[]>`
|
||||
DELETE FROM messages
|
||||
WHERE chat_id = ${chatId}
|
||||
AND created_at >= (
|
||||
SELECT created_at FROM messages
|
||||
WHERE id = ${messageId} AND chat_id = ${chatId}
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
if (deletedRows.length > 0) {
|
||||
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
|
||||
}
|
||||
return deletedRows.map((r) => r.id);
|
||||
});
|
||||
|
||||
if (deletedIds.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'message not found' };
|
||||
}
|
||||
|
||||
handlers.publishMessagesDeleted(chat.session_id, chatId, deletedIds);
|
||||
|
||||
reply.code(204);
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/chats/:id/compact',
|
||||
async (req, reply) => {
|
||||
|
||||
@@ -134,7 +134,15 @@ export function registerSessionRoutes(
|
||||
reply.code(404);
|
||||
return { error: 'session not found' };
|
||||
}
|
||||
return rows[0];
|
||||
const session = rows[0]!;
|
||||
if (name !== undefined) {
|
||||
broker.publishUser('default', {
|
||||
type: 'session_renamed',
|
||||
session_id: session.id,
|
||||
name: session.name,
|
||||
});
|
||||
}
|
||||
return session;
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS projects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||
last_session_id UUID
|
||||
);
|
||||
|
||||
@@ -12,8 +12,8 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
name TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
system_prompt TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id, updated_at DESC);
|
||||
@@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
tool_results JSONB,
|
||||
status TEXT NOT NULL DEFAULT 'complete',
|
||||
last_seq INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
|
||||
@@ -60,14 +60,9 @@ CREATE TABLE IF NOT EXISTS session_panes (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_panes_session ON session_panes (session_id);
|
||||
|
||||
-- Backfill: ensure every session has at least one pane (default Chat).
|
||||
-- Idempotent: skipped on subsequent runs because session_panes rows already exist.
|
||||
INSERT INTO session_panes (session_id, position, kind, state)
|
||||
SELECT s.id, 0, 'chat', '{}'::jsonb
|
||||
FROM sessions s
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM session_panes p WHERE p.session_id = s.id
|
||||
);
|
||||
-- v1.4: backfill removed. Pane layout is client-side (localStorage) since v1.2-batch4.
|
||||
-- The CREATE TABLE above is retained for additive-schema discipline; drop is a
|
||||
-- future destructive migration.
|
||||
|
||||
-- v1.2: sessions.status (open | archived)
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
|
||||
|
||||
@@ -764,6 +764,10 @@ export function createInferenceRunner(
|
||||
await reg.completed.catch(() => {});
|
||||
return true;
|
||||
},
|
||||
|
||||
hasActive(chatId: string): boolean {
|
||||
return registry.has(chatId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -176,6 +176,11 @@ export interface SessionUpdatedFrame {
|
||||
name: string;
|
||||
updated_at: string;
|
||||
}
|
||||
export interface SessionRenamedFrame {
|
||||
type: 'session_renamed';
|
||||
session_id: string;
|
||||
name: string;
|
||||
}
|
||||
export interface SessionArchivedFrame {
|
||||
type: 'session_archived';
|
||||
session_id: string;
|
||||
@@ -226,6 +231,7 @@ export type UserStreamFrame =
|
||||
| SessionCreatedFrame
|
||||
| SessionDeletedFrame
|
||||
| SessionUpdatedFrame
|
||||
| SessionRenamedFrame
|
||||
| SessionArchivedFrame
|
||||
| ChatCreatedFrame
|
||||
| ChatUpdatedFrame
|
||||
|
||||
Reference in New Issue
Block a user