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,31 +0,0 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
declare module 'fastify' {
interface FastifyRequest {
user?: string;
}
}
const PUBLIC_PATHS = new Set<string>(['/api/health']);
export function registerAuth(app: FastifyInstance): void {
app.addHook('onRequest', async (req, reply) => {
if (!req.url.startsWith('/api')) return;
if (PUBLIC_PATHS.has(req.routeOptions.url ?? req.url.split('?')[0]!)) return;
const header = req.headers['remote-user'];
const user = Array.isArray(header) ? header[0] : header;
if (!user || user.trim() === '') {
reply.code(401).send({ error: 'unauthenticated' });
return reply;
}
req.user = user.trim();
});
}
export function requireUser(req: FastifyRequest): string {
if (!req.user) {
throw new Error('user not set on request — auth hook must run first');
}
return req.user;
}

View File

@@ -5,15 +5,15 @@ import { existsSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { loadConfig } from './config.js'; import { loadConfig } from './config.js';
import { getSql, applySchema, pingDb, closeDb } from './db.js'; import { getSql, applySchema, pingDb, closeDb } from './db.js';
import { registerAuth } from './auth.js';
import { registerProjectRoutes } from './routes/projects.js'; import { registerProjectRoutes } from './routes/projects.js';
import { registerSessionRoutes } from './routes/sessions.js'; import { registerSessionRoutes } from './routes/sessions.js';
import { registerSettingsRoutes } from './routes/settings.js'; import { registerSettingsRoutes } from './routes/settings.js';
import { registerMessageRoutes } from './routes/messages.js'; import { registerMessageRoutes } from './routes/messages.js';
import { registerChatRoutes } from './routes/chats.js';
import { registerSidebarRoutes } from './routes/sidebar.js'; import { registerSidebarRoutes } from './routes/sidebar.js';
import { registerWebSocket } from './routes/ws.js'; import { registerWebSocket } from './routes/ws.js';
import { registerModelRoutes } from './routes/models.js'; import { registerModelRoutes } from './routes/models.js';
import { registerPaneRoutes } from './routes/panes.js';
import { createInferenceRunner } from './services/inference.js'; import { createInferenceRunner } from './services/inference.js';
import { createBroker } from './services/broker.js'; import { createBroker } from './services/broker.js';
@@ -30,8 +30,6 @@ async function main() {
await app.register(fastifyWebsocket); await app.register(fastifyWebsocket);
registerAuth(app);
app.get('/api/health', async () => { app.get('/api/health', async () => {
const dbOk = await pingDb(sql); const dbOk = await pingDb(sql);
return { status: dbOk ? 'ok' : 'degraded', db: dbOk }; return { status: dbOk ? 'ok' : 'degraded', db: dbOk };
@@ -44,7 +42,7 @@ async function main() {
registerSettingsRoutes(app, sql); registerSettingsRoutes(app, sql);
registerModelRoutes(app, config); registerModelRoutes(app, config);
registerSidebarRoutes(app, sql); registerSidebarRoutes(app, sql);
registerPaneRoutes(app, sql); registerChatRoutes(app, sql, broker);
const inference = createInferenceRunner( const inference = createInferenceRunner(
{ {
@@ -60,29 +58,39 @@ async function main() {
} }
); );
registerMessageRoutes(app, sql, { registerMessageRoutes(app, sql, {
enqueueInference: (sessionId, assistantId, user) => { enqueueInference: (sessionId, chatId, assistantId, user) => {
inference.enqueue(sessionId, assistantId, user); inference.enqueue(sessionId, chatId, assistantId, user);
}, },
publishUserMessage: (sessionId, userMessageId, content) => { enqueueCompact: (sessionId, chatId, compactId, user) => {
inference.enqueueCompact(sessionId, chatId, compactId, user);
},
cancelInference: async (sessionId, chatId) => {
return inference.cancel(sessionId, chatId);
},
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
broker.publish(sessionId, { broker.publish(sessionId, {
type: 'message_started', type: 'message_started',
message_id: userMessageId, message_id: userMessageId,
chat_id: chatId,
role: 'user', role: 'user',
}); });
broker.publish(sessionId, { broker.publish(sessionId, {
type: 'delta', type: 'delta',
message_id: userMessageId, message_id: userMessageId,
chat_id: chatId,
content, content,
}); });
broker.publish(sessionId, { broker.publish(sessionId, {
type: 'message_complete', type: 'message_complete',
message_id: userMessageId, message_id: userMessageId,
chat_id: chatId,
}); });
}, },
publishMessagesDeleted: (sessionId, messageIds) => { publishMessagesDeleted: (sessionId, chatId, messageIds) => {
broker.publish(sessionId, { broker.publish(sessionId, {
type: 'messages_deleted', type: 'messages_deleted',
message_ids: messageIds, message_ids: messageIds,
chat_id: chatId,
}); });
}, },
}); });

View File

@@ -0,0 +1,147 @@
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).optional(),
status: z.enum(['open', 'closed']).optional(),
});
export function registerChatRoutes(
app: FastifyInstance,
sql: Sql,
broker: Broker
): void {
app.get<{ Params: { id: 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 rows = await sql<Chat[]>`
SELECT id, session_id, name, status, created_at, updated_at
FROM chats
WHERE session_id = ${req.params.id}
ORDER BY 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 { name, status } = parsed.data;
if (name === undefined && status === undefined) {
reply.code(400);
return { error: 'must provide name or status' };
}
const rows = await sql<Chat[]>`
UPDATE chats
SET
name = COALESCE(${name ?? null}, name),
status = COALESCE(${status ?? null}, status),
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]!;
if (status === 'closed') {
broker.publishUser('default', {
type: 'chat_closed',
chat_id: chat.id,
session_id: chat.session_id,
});
} else {
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;
}
);
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' };
}
reply.code(204);
return null;
}
);
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
FROM messages
WHERE chat_id = ${req.params.id}
ORDER BY created_at ASC, id ASC
`;
return rows;
}
);
}

View File

@@ -1,21 +1,23 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import type { Message, Session } from '../types/api.js'; import type { Chat, Message, Session } from '../types/api.js';
import { requireUser } from '../auth.js';
const SendBody = z.object({ const SendBody = z.object({
content: z.string().min(1).max(64_000), content: z.string().min(1).max(64_000),
}); });
interface MessageHandlers { 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: ( publishUserMessage: (
sessionId: string, sessionId: string,
chatId: string,
userMessageId: string, userMessageId: string,
content: string content: string
) => void; ) => 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( export function registerMessageRoutes(
@@ -32,7 +34,7 @@ export function registerMessageRoutes(
return { error: 'session not found' }; return { error: 'session not found' };
} }
const rows = await sql<Message[]>` 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 tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at
FROM messages FROM messages
WHERE session_id = ${req.params.id} WHERE session_id = ${req.params.id}
@@ -43,7 +45,7 @@ export function registerMessageRoutes(
); );
app.post<{ Params: { id: string } }>( app.post<{ Params: { id: string } }>(
'/api/sessions/:id/messages', '/api/chats/:id/messages',
async (req, reply) => { async (req, reply) => {
const parsed = SendBody.safeParse(req.body); const parsed = SendBody.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
@@ -51,33 +53,39 @@ export function registerMessageRoutes(
return { error: 'invalid body', details: parsed.error.flatten() }; return { error: 'invalid body', details: parsed.error.flatten() };
} }
const session = await sql<Session[]>`SELECT id FROM sessions WHERE id = ${req.params.id}`; const chatRows = await sql<Chat[]>`
if (session.length === 0) { SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404); 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 result = await sql.begin(async (tx) => {
const [userMsg] = await tx<{ id: string }[]>` const [userMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, role, content, status, created_at) INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${req.params.id}, 'user', ${parsed.data.content}, 'complete', clock_timestamp()) VALUES (${sessionId}, ${chat.id}, 'user', ${parsed.data.content}, 'complete', clock_timestamp())
RETURNING id RETURNING id
`; `;
const [assistantMsg] = await tx<{ id: string }[]>` const [assistantMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, role, content, status, created_at) INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${req.params.id}, 'assistant', '', 'streaming', clock_timestamp()) VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id 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 }; return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
}); });
handlers.publishUserMessage( handlers.publishUserMessage(
req.params.id, sessionId,
chat.id,
result.user_message_id, result.user_message_id,
parsed.data.content 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); reply.code(202);
return result; return result;
@@ -85,14 +93,24 @@ export function registerMessageRoutes(
); );
app.post<{ Params: { id: string; message_id: string } }>( 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) => { 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 }[]>` const target = await sql<{ id: string; role: string; status: string }[]>`
SELECT id, role, status SELECT id, role, status
FROM messages FROM messages
WHERE session_id = ${sessionId} AND id = ${targetId} WHERE chat_id = ${chatId} AND id = ${targetId}
`; `;
if (target.length === 0) { if (target.length === 0) {
reply.code(404); reply.code(404);
@@ -109,34 +127,141 @@ export function registerMessageRoutes(
} }
const { newAssistantId, deletedIds } = await sql.begin(async (tx) => { 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 }[]>` const deletedRows = await tx<{ id: string }[]>`
DELETE FROM messages DELETE FROM messages
WHERE session_id = ${sessionId} WHERE chat_id = ${chatId}
AND created_at >= ( AND created_at >= (
SELECT created_at FROM messages WHERE id = ${targetId} SELECT created_at FROM messages WHERE id = ${targetId}
) )
RETURNING id RETURNING id
`; `;
const [row] = await tx<{ id: string }[]>` const [row] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, role, content, status, created_at) INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, 'assistant', '', 'streaming', clock_timestamp()) VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id 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 { return {
newAssistantId: row!.id, newAssistantId: row!.id,
deletedIds: deletedRows.map((r) => r.id), deletedIds: deletedRows.map((r) => r.id),
}; };
}); });
handlers.publishMessagesDeleted(sessionId, deletedIds); handlers.publishMessagesDeleted(sessionId, chatId, deletedIds);
handlers.enqueueInference(sessionId, newAssistantId, requireUser(req)); handlers.enqueueInference(sessionId, chatId, newAssistantId, 'default');
reply.code(202); reply.code(202);
return { assistant_message_id: newAssistantId }; 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;
}
);
} }

View File

@@ -1,217 +0,0 @@
import type { FastifyInstance } from 'fastify';
import type { TransactionSql } from 'postgres';
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(
tx: TransactionSql,
paneId: string,
sid: string,
oldPos: number,
newPos: number
): Promise<void> {
if (oldPos === newPos) return;
// Move target pane to a sentinel well outside the negate range [-MAX_PANES, -1]
// so it never collides with negated rows during the shift steps.
await tx`UPDATE session_panes SET position = -100 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 < 0 AND id != ${paneId}`;
}
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<Pane[]>`
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 state = {};
let insertError: string | null = null;
const inserted = await sql.begin(async (tx) => {
const countResult = await tx<{ n: number }[]>`
SELECT COUNT(*)::int AS n FROM session_panes WHERE session_id = ${sid}
`;
const n = countResult[0]!.n;
if (n >= MAX_PANES) {
throw new Error('MAX_PANES_EXCEEDED');
}
let insertPos: number;
if (position === undefined || position === null) {
insertPos = n;
} else {
if (position < 0 || position > n) {
throw new Error('OUT_OF_BOUNDS');
}
insertPos = position;
}
await tx`UPDATE session_panes SET position = -position - 1
WHERE session_id = ${sid} AND position >= ${insertPos}`;
const [row] = await tx<Pane[]>`
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;
}).catch((err: Error) => {
insertError = err.message;
return null;
});
if (insertError === 'MAX_PANES_EXCEEDED') {
reply.code(400);
return { error: `session already has ${MAX_PANES} panes (maximum)` };
}
if (insertError === 'OUT_OF_BOUNDS') {
reply.code(400);
return { error: `position out of bounds` };
}
if (insertError) {
reply.code(500);
return { error: 'internal error' };
}
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<Pane[]>`
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;
// Apply position and/or state changes atomically
let patchError: string | null = null;
await sql.begin(async (tx) => {
if (position !== undefined) {
const countRows = await tx<{ 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) {
throw `position must be between 0 and ${count - 1}`;
}
}
if (position !== undefined && position !== oldPos) {
await movePane(tx, req.params.id, sid, oldPos, position);
}
if (state !== undefined) {
await tx`
UPDATE session_panes SET state = ${JSON.stringify(state)}::jsonb
WHERE id = ${req.params.id}
`;
}
}).catch((err: unknown) => {
if (typeof err === 'string') {
patchError = err;
} else {
throw err;
}
});
if (patchError !== null) {
reply.code(400);
return { error: patchError };
}
const [updated] = await sql<Pane[]>`
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;
}
);
}

View File

@@ -6,7 +6,6 @@ import type { Sql } from '../db.js';
import type { Config } from '../config.js'; import type { Config } from '../config.js';
import type { Broker } from '../services/broker.js'; import type { Broker } from '../services/broker.js';
import type { Project, AvailableProject } from '../types/api.js'; import type { Project, AvailableProject } from '../types/api.js';
import { requireUser } from '../auth.js';
import { resolveProjectRoot, PathScopeError } from '../services/path_guard.js'; import { resolveProjectRoot, PathScopeError } from '../services/path_guard.js';
import { listDir, viewFile } from '../services/file_ops.js'; import { listDir, viewFile } from '../services/file_ops.js';
import { getProjectFiles } from '../services/file_index.js'; import { getProjectFiles } from '../services/file_index.js';
@@ -77,7 +76,7 @@ export function registerProjectRoutes(
VALUES (${name}, ${resolved.real}) VALUES (${name}, ${resolved.real})
RETURNING id, name, path, added_at, last_session_id RETURNING id, name, path, added_at, last_session_id
`; `;
broker.publishUser(requireUser(req), { type: 'project_created', project: row as unknown as Project }); broker.publishUser('default', { type: 'project_created', project: row as unknown as Project });
reply.code(201); reply.code(201);
return row; return row;
} catch (err) { } catch (err) {
@@ -96,7 +95,7 @@ export function registerProjectRoutes(
reply.code(404); reply.code(404);
return { error: 'not found' }; return { error: 'not found' };
} }
broker.publishUser(requireUser(req), { type: 'project_deleted', project_id: id }); broker.publishUser('default', { type: 'project_deleted', project_id: id });
reply.code(204); reply.code(204);
return null; return null;
}); });

View File

@@ -5,7 +5,6 @@ import type { Config } from '../config.js';
import type { Broker } from '../services/broker.js'; import type { Broker } from '../services/broker.js';
import type { Session } from '../types/api.js'; import type { Session } from '../types/api.js';
import { getSetting } from './settings.js'; import { getSetting } from './settings.js';
import { requireUser } from '../auth.js';
const CreateBody = z.object({ const CreateBody = z.object({
name: z.string().min(1).max(200).optional(), name: z.string().min(1).max(200).optional(),
@@ -31,7 +30,7 @@ export function registerSessionRoutes(
config: Config, config: Config,
broker: Broker broker: Broker
): void { ): void {
app.get<{ Params: { id: string } }>( app.get<{ Params: { id: string }; Querystring: { status?: string } }>(
'/api/projects/:id/sessions', '/api/projects/:id/sessions',
async (req, reply) => { async (req, reply) => {
const project = await sql`SELECT id FROM projects WHERE id = ${req.params.id}`; const project = await sql`SELECT id FROM projects WHERE id = ${req.params.id}`;
@@ -39,10 +38,11 @@ export function registerSessionRoutes(
reply.code(404); reply.code(404);
return { error: 'project not found' }; return { error: 'project not found' };
} }
const status = req.query.status === 'archived' ? 'archived' : 'open';
const rows = await sql<Session[]>` const rows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, created_at, updated_at SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at
FROM sessions FROM sessions
WHERE project_id = ${req.params.id} WHERE project_id = ${req.params.id} AND status = ${status}
ORDER BY updated_at DESC ORDER BY updated_at DESC
`; `;
return rows; return rows;
@@ -81,15 +81,15 @@ export function registerSessionRoutes(
const [session] = await tx<Session[]>` const [session] = await tx<Session[]>`
INSERT INTO sessions (project_id, name, model, system_prompt) INSERT INTO sessions (project_id, name, model, system_prompt)
VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt}) VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt})
RETURNING id, project_id, name, model, system_prompt, created_at, updated_at RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at
`; `;
await tx` await tx`
INSERT INTO session_panes (session_id, position, kind, state) INSERT INTO chats (session_id, name, status)
VALUES (${session!.id}, 0, 'chat', '{}'::jsonb) VALUES (${session!.id}, NULL, 'open')
`; `;
return session!; return session!;
}); });
broker.publishUser(requireUser(req), { broker.publishUser('default', {
type: 'session_created', type: 'session_created',
session: row, session: row,
project_id: row.project_id, project_id: row.project_id,
@@ -101,7 +101,7 @@ export function registerSessionRoutes(
app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => { app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
const rows = await sql<Session[]>` const rows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, created_at, updated_at SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at
FROM sessions WHERE id = ${req.params.id} FROM sessions WHERE id = ${req.params.id}
`; `;
if (rows.length === 0) { if (rows.length === 0) {
@@ -128,7 +128,7 @@ export function registerSessionRoutes(
system_prompt = COALESCE(${system_prompt ?? null}, system_prompt), system_prompt = COALESCE(${system_prompt ?? null}, system_prompt),
updated_at = clock_timestamp() updated_at = clock_timestamp()
WHERE id = ${req.params.id} WHERE id = ${req.params.id}
RETURNING id, project_id, name, model, system_prompt, created_at, updated_at RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at
`; `;
if (rows.length === 0) { if (rows.length === 0) {
reply.code(404); reply.code(404);
@@ -138,6 +138,51 @@ export function registerSessionRoutes(
} }
); );
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 } }>( app.delete<{ Params: { id: string } }>(
'/api/sessions/:id', '/api/sessions/:id',
async (req, reply) => { async (req, reply) => {
@@ -150,7 +195,7 @@ export function registerSessionRoutes(
return { error: 'not found' }; return { error: 'not found' };
} }
const project_id = deleted[0]!.project_id; const project_id = deleted[0]!.project_id;
broker.publishUser(requireUser(req), { type: 'session_deleted', session_id: id, project_id }); broker.publishUser('default', { type: 'session_deleted', session_id: id, project_id });
reply.code(204); reply.code(204);
return null; return null;
} }

View File

@@ -20,14 +20,14 @@ export function registerSidebarRoutes(app: FastifyInstance, sql: Sql): void {
sql<SidebarSession[]>` sql<SidebarSession[]>`
SELECT id, project_id, name, model, updated_at SELECT id, project_id, name, model, updated_at
FROM sessions FROM sessions
WHERE project_id = ${p.id} WHERE project_id = ${p.id} AND status = 'open'
ORDER BY updated_at DESC ORDER BY updated_at DESC
LIMIT 6 LIMIT 6
`, `,
sql<{ n: number }[]>` sql<{ n: number }[]>`
SELECT COUNT(*)::int AS n SELECT COUNT(*)::int AS n
FROM sessions FROM sessions
WHERE project_id = ${p.id} WHERE project_id = ${p.id} AND status = 'open'
`, `,
]); ]);
return { return {

View File

@@ -22,7 +22,7 @@ export function registerWebSocket(
} }
const messages = await sql<Message[]>` const messages = 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 tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at
FROM messages FROM messages
WHERE session_id = ${sessionId} WHERE session_id = ${sessionId}
@@ -44,15 +44,8 @@ export function registerWebSocket(
} }
); );
app.get('/api/ws/user', { websocket: true }, async (socket, req) => { app.get('/api/ws/user', { websocket: true }, async (socket) => {
const user = req.user; const user = 'default';
// defensive: global auth hook (auth.ts) already rejects unauthenticated /api/* requests;
// keep the explicit check here to close the WS cleanly (1008) rather than throwing.
if (!user) {
socket.close(1008, 'unauthenticated');
return;
}
// No snapshot — user channel is purely live updates.
const unsubscribe = broker.subscribeUser(user, (frame) => { const unsubscribe = broker.subscribeUser(user, (frame) => {
if (socket.readyState !== socket.OPEN) return; if (socket.readyState !== socket.OPEN) return;
try { try {

View File

@@ -21,11 +21,11 @@ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id, updated_
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'tool')), role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '',
tool_calls JSONB, tool_calls JSONB,
tool_results JSONB, tool_results JSONB,
status TEXT NOT NULL DEFAULT 'complete' CHECK (status IN ('streaming', 'complete', 'failed')), status TEXT NOT NULL DEFAULT 'complete',
last_seq INT NOT NULL DEFAULT 0, last_seq INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
@@ -47,6 +47,8 @@ CREATE TABLE IF NOT EXISTS settings (
INSERT INTO settings (key, value) VALUES ('default_model', '"qwen3.6-35b-a3b-mxfp4"') ON CONFLICT (key) DO NOTHING; INSERT INTO settings (key, value) VALUES ('default_model', '"qwen3.6-35b-a3b-mxfp4"') ON CONFLICT (key) DO NOTHING;
-- DEPRECATED: client-side pane state as of v1.2-batch4. Table retained per
-- additive schema rule; no writes. Drop in a future destructive migration.
CREATE TABLE IF NOT EXISTS session_panes ( CREATE TABLE IF NOT EXISTS session_panes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
@@ -66,3 +68,67 @@ FROM sessions s
WHERE NOT EXISTS ( WHERE NOT EXISTS (
SELECT 1 FROM session_panes p WHERE p.session_id = s.id SELECT 1 FROM session_panes p WHERE p.session_id = s.id
); );
-- v1.2: sessions.status (open | archived)
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
-- v1.2: chats table
CREATE TABLE IF NOT EXISTS chats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
name TEXT,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')),
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS idx_chats_session_status ON chats (session_id, status, updated_at DESC);
-- v1.2: messages.chat_id + messages.kind
ALTER TABLE messages ADD COLUMN IF NOT EXISTS chat_id UUID REFERENCES chats(id) ON DELETE CASCADE;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'message';
CREATE INDEX IF NOT EXISTS idx_messages_chat ON messages (chat_id, created_at);
-- Backfill: one chat per existing session that has none yet
INSERT INTO chats (session_id, name, status, created_at, updated_at)
SELECT s.id, s.name, 'open', s.created_at, s.updated_at
FROM sessions s
WHERE NOT EXISTS (
SELECT 1 FROM chats c WHERE c.session_id = s.id
);
-- Backfill: link orphaned messages to their session's first chat
UPDATE messages SET chat_id = (
SELECT c.id FROM chats c WHERE c.session_id = messages.session_id ORDER BY c.created_at ASC LIMIT 1
)
WHERE chat_id IS NULL;
-- Enforce NOT NULL on chat_id once all rows are backfilled
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'messages' AND column_name = 'chat_id' AND is_nullable = 'YES'
) AND NOT EXISTS (
SELECT 1 FROM messages WHERE chat_id IS NULL
) THEN
ALTER TABLE messages ALTER COLUMN chat_id SET NOT NULL;
END IF;
END $$;
-- v1.2.1: CHECK constraints for sessions.status and messages (role, status)
-- KEEP IN SYNC: apps/server/src/types/api.ts (MESSAGE_ROLES, MESSAGE_STATUSES, SessionStatus)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'sessions_status_chk') THEN
ALTER TABLE sessions ADD CONSTRAINT sessions_status_chk
CHECK (status IN ('open', 'archived'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_role_chk') THEN
ALTER TABLE messages ADD CONSTRAINT messages_role_chk
CHECK (role IN ('user', 'assistant', 'system', 'tool'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_status_chk') THEN
ALTER TABLE messages ADD CONSTRAINT messages_status_chk
CHECK (status IN ('streaming', 'complete', 'failed', 'cancelled'));
END IF;
END $$;

View File

@@ -5,31 +5,12 @@ const NAMING_SYSTEM_PROMPT =
const MAX_TITLE_CHARS = 60; const MAX_TITLE_CHARS = 60;
// QWEN3 NON-STREAMING UTILITY-CALL PATTERN
// ----------------------------------------
// Qwen3-family chat templates default to chain-of-thought reasoning: the
// model emits a long <think>…</think> block into `reasoning_content` and
// only finalizes a real reply in `content`. For short utility calls
// (naming, classification, routing, summarization) with a tight token
// budget, the model burns the entire budget on reasoning and returns:
// - content: ""
// - reasoning_content: "Thinking Process: 1. ..." (mid-thought, truncated)
// - finish_reason: "length"
// Fix: pass `chat_template_kwargs: { enable_thinking: false }` to skip the
// thinking block, and keep `max_tokens` low (~30 is plenty for a 4-word
// title). The kwarg is a no-op for non-Qwen chat templates, so it's safe
// to apply unconditionally for any short non-streaming model call.
// Apply this same pattern to: fork-message (planned), agent-routing
// (planned), web-search summarization (planned).
function cleanTitle(raw: string): string { function cleanTitle(raw: string): string {
let name = raw.trim(); let name = raw.trim();
// Strip surrounding straight or smart quotes (one layer).
const quotes = ['"', "'", '`', '', '', '“', '”']; const quotes = ['"', "'", '`', '', '', '“', '”'];
while (name.length >= 2 && quotes.includes(name[0]!) && quotes.includes(name[name.length - 1]!)) { while (name.length >= 2 && quotes.includes(name[0]!) && quotes.includes(name[name.length - 1]!)) {
name = name.slice(1, -1).trim(); name = name.slice(1, -1).trim();
} }
// Drop a leading "Title:" prefix if the model added one despite instructions.
name = name.replace(/^title\s*:\s*/i, '').trim(); name = name.replace(/^title\s*:\s*/i, '').trim();
if (name.length > MAX_TITLE_CHARS) { if (name.length > MAX_TITLE_CHARS) {
name = name.slice(0, MAX_TITLE_CHARS).trim(); name = name.slice(0, MAX_TITLE_CHARS).trim();
@@ -46,13 +27,10 @@ interface NamingResponse {
}>; }>;
} }
// Some Qwen-family models emit "thinking" tokens into reasoning_content and
// only finalize a real reply in content. Pull a sensible candidate string.
function pickTitleSource(data: NamingResponse): string { function pickTitleSource(data: NamingResponse): string {
const choice = data.choices?.[0]?.message; const choice = data.choices?.[0]?.message;
if (!choice) return ''; if (!choice) return '';
if (choice.content && choice.content.trim().length > 0) return choice.content; if (choice.content && choice.content.trim().length > 0) return choice.content;
// Fallback: try to extract a last-line title from reasoning, if present.
const reasoning = choice.reasoning_content ?? ''; const reasoning = choice.reasoning_content ?? '';
if (reasoning.length === 0) return ''; if (reasoning.length === 0) return '';
const lines = reasoning const lines = reasoning
@@ -62,38 +40,44 @@ function pickTitleSource(data: NamingResponse): string {
return lines[lines.length - 1] ?? ''; return lines[lines.length - 1] ?? '';
} }
export async function maybeAutoNameSession( export async function maybeAutoNameChat(
ctx: InferenceContext, ctx: InferenceContext,
chatId: string,
sessionId: string sessionId: string
): Promise<void> { ): Promise<void> {
const counts = await ctx.sql<{ n: number }[]>` const counts = await ctx.sql<{ n: number }[]>`
SELECT COUNT(*)::int AS n SELECT COUNT(*)::int AS n
FROM messages FROM messages
WHERE session_id = ${sessionId} WHERE chat_id = ${chatId}
AND role = 'assistant' AND role = 'assistant'
AND status = 'complete' AND status = 'complete'
`; `;
if (counts[0]?.n !== 1) return; if (counts[0]?.n !== 1) return;
const sessionRows = await ctx.sql< const chatRows = await ctx.sql<
{ id: string; name: string; model: string }[] { id: string; name: string | null; session_id: string }[]
>` >`
SELECT id, name, model FROM sessions WHERE id = ${sessionId} SELECT id, name, session_id FROM chats WHERE id = ${chatId}
`; `;
const session = sessionRows[0]; const chat = chatRows[0];
if (!session) return; if (!chat) return;
const existingName = session.name ?? ''; if (chat.name !== null && chat.name !== '') return;
if (existingName !== '' && existingName !== 'New session') return;
const sessionRows = await ctx.sql<{ model: string }[]>`
SELECT model FROM sessions WHERE id = ${sessionId}
`;
const model = sessionRows[0]?.model;
if (!model) return;
const userMsg = await ctx.sql<{ content: string }[]>` const userMsg = await ctx.sql<{ content: string }[]>`
SELECT content FROM messages SELECT content FROM messages
WHERE session_id = ${sessionId} AND role = 'user' WHERE chat_id = ${chatId} AND role = 'user'
ORDER BY created_at ASC ORDER BY created_at ASC
LIMIT 1 LIMIT 1
`; `;
const assistantMsg = await ctx.sql<{ content: string }[]>` const assistantMsg = await ctx.sql<{ content: string }[]>`
SELECT content FROM messages SELECT content FROM messages
WHERE session_id = ${sessionId} WHERE chat_id = ${chatId}
AND role = 'assistant' AND role = 'assistant'
AND status = 'complete' AND status = 'complete'
ORDER BY created_at ASC ORDER BY created_at ASC
@@ -105,7 +89,7 @@ export async function maybeAutoNameSession(
const assistantText = assistantMsg[0].content.slice(0, 2000); const assistantText = assistantMsg[0].content.slice(0, 2000);
const body = { const body = {
model: session.model, model,
messages: [ messages: [
{ role: 'system', content: NAMING_SYSTEM_PROMPT }, { role: 'system', content: NAMING_SYSTEM_PROMPT },
{ {
@@ -116,9 +100,6 @@ export async function maybeAutoNameSession(
max_tokens: 30, max_tokens: 30,
temperature: 0.3, temperature: 0.3,
stream: false, stream: false,
// Qwen-family models default to chain-of-thought; this template kwarg
// tells llama.cpp's chat template renderer to skip the thinking block.
// Harmless for non-Qwen models.
chat_template_kwargs: { enable_thinking: false }, chat_template_kwargs: { enable_thinking: false },
}; };
@@ -135,23 +116,30 @@ export async function maybeAutoNameSession(
const raw = pickTitleSource(data); const raw = pickTitleSource(data);
const name = cleanTitle(raw); const name = cleanTitle(raw);
if (!name) { if (!name) {
ctx.log.warn({ sessionId, raw }, 'auto-name: empty title from model'); ctx.log.warn({ chatId, raw }, 'auto-name: empty title from model');
return; return;
} }
const updated = await ctx.sql<{ id: string; name: string }[]>` const updated = await ctx.sql<{ id: string; name: string; session_id: string; updated_at: string }[]>`
UPDATE sessions UPDATE chats
SET name = ${name}, updated_at = NOW() SET name = ${name}, updated_at = clock_timestamp()
WHERE id = ${sessionId} WHERE id = ${chatId}
AND (name IS NULL OR name = '' OR name = 'New session') AND (name IS NULL OR name = '')
RETURNING id, name RETURNING id, name, session_id, updated_at
`; `;
if (updated.length === 0) return; if (updated.length === 0) return;
ctx.publish(sessionId, { ctx.publish(sessionId, {
type: 'session_renamed', type: 'chat_renamed',
session_id: sessionId, chat_id: chatId,
name, name,
}); });
ctx.log.info({ sessionId, name }, 'session auto-named'); ctx.publishUser({
type: 'chat_updated',
chat_id: chatId,
session_id: sessionId,
name,
updated_at: updated[0]!.updated_at,
});
ctx.log.info({ chatId, name }, 'chat auto-named');
} }

View File

@@ -4,7 +4,7 @@ import type { Config } from '../config.js';
import type { Message, Project, Session, ToolCall, UserStreamFrame } from '../types/api.js'; import type { Message, Project, Session, ToolCall, UserStreamFrame } from '../types/api.js';
import { ALL_TOOLS, TOOLS_BY_NAME, toolJsonSchemas } from './tools.js'; import { ALL_TOOLS, TOOLS_BY_NAME, toolJsonSchemas } from './tools.js';
import { PathScopeError, resolveProjectRoot } from './path_guard.js'; import { PathScopeError, resolveProjectRoot } from './path_guard.js';
import { maybeAutoNameSession } from './auto_name.js'; import { maybeAutoNameChat } from './auto_name.js';
const BASE_SYSTEM_PROMPT = (projectPath: string) => const BASE_SYSTEM_PROMPT = (projectPath: string) =>
`You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`; `You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`;
@@ -21,9 +21,11 @@ export interface InferenceFrame {
| 'message_complete' | 'message_complete'
| 'messages_deleted' | 'messages_deleted'
| 'session_renamed' | 'session_renamed'
| 'chat_renamed'
| 'error'; | 'error';
message_id?: string; message_id?: string;
message_ids?: string[]; message_ids?: string[];
chat_id?: string;
tool_message_id?: string; tool_message_id?: string;
tool_call_id?: string; tool_call_id?: string;
role?: 'assistant' | 'tool' | 'user'; role?: 'assistant' | 'tool' | 'user';
@@ -101,8 +103,23 @@ export function buildMessagesPayload(
} }
out.push({ role: 'system', content: systemPrompt }); out.push({ role: 'system', content: systemPrompt });
for (const m of history) { // Find the latest compact marker — only send messages from that point onwards
let startIdx = 0;
for (let i = history.length - 1; i >= 0; i--) {
if (history[i]!.kind === 'compact') {
startIdx = i;
break;
}
}
for (let i = startIdx; i < history.length; i++) {
const m = history[i]!;
if (m.kind === 'compact') {
out.push({ role: 'system', content: m.content });
continue;
}
if (m.role === 'assistant' && m.status === 'streaming') continue; if (m.role === 'assistant' && m.status === 'streaming') continue;
if (m.role === 'assistant' && m.status === 'cancelled') continue;
if (m.role === 'tool') { if (m.role === 'tool') {
const tr = m.tool_results; const tr = m.tool_results;
if (!tr) continue; if (!tr) continue;
@@ -140,10 +157,11 @@ export function buildMessagesPayload(
async function loadContext( async function loadContext(
sql: Sql, sql: Sql,
sessionId: string sessionId: string,
chatId: string
): Promise<{ session: Session; project: Project; history: Message[] } | null> { ): Promise<{ session: Session; project: Project; history: Message[] } | null> {
const sessionRows = await sql<Session[]>` const sessionRows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, created_at, updated_at SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at
FROM sessions WHERE id = ${sessionId} FROM sessions WHERE id = ${sessionId}
`; `;
if (sessionRows.length === 0) return null; if (sessionRows.length === 0) return null;
@@ -157,10 +175,10 @@ async function loadContext(
const project = projectRows[0]!; const project = projectRows[0]!;
const history = await sql<Message[]>` const history = 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 tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at
FROM messages FROM messages
WHERE session_id = ${sessionId} WHERE chat_id = ${chatId}
ORDER BY created_at ASC, id ASC ORDER BY created_at ASC, id ASC
`; `;
@@ -204,7 +222,8 @@ async function streamCompletion(
model: string, model: string,
messages: OpenAiMessage[], messages: OpenAiMessage[],
includeTools: boolean, includeTools: boolean,
onDelta: (content: string) => void onDelta: (content: string) => void,
signal?: AbortSignal
): Promise<StreamResult> { ): Promise<StreamResult> {
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
model, model,
@@ -221,6 +240,7 @@ async function streamCompletion(
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
signal,
}); });
if (!res.ok || !res.body) { if (!res.ok || !res.body) {
const text = await res.text().catch(() => ''); const text = await res.text().catch(() => '');
@@ -331,8 +351,10 @@ async function executeToolCall(
async function runAssistantTurn( async function runAssistantTurn(
ctx: InferenceContext, ctx: InferenceContext,
sessionId: string, sessionId: string,
chatId: string,
assistantMessageId: string, assistantMessageId: string,
depth: number depth: number,
signal?: AbortSignal
): Promise<void> { ): Promise<void> {
if (depth > MAX_TOOL_LOOP_DEPTH) { if (depth > MAX_TOOL_LOOP_DEPTH) {
await ctx.sql` await ctx.sql`
@@ -345,12 +367,13 @@ async function runAssistantTurn(
ctx.publish(sessionId, { ctx.publish(sessionId, {
type: 'error', type: 'error',
message_id: assistantMessageId, message_id: assistantMessageId,
chat_id: chatId,
error: 'tool loop depth exceeded', error: 'tool loop depth exceeded',
}); });
return; return;
} }
const loaded = await loadContext(ctx.sql, sessionId); const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (!loaded) { if (!loaded) {
ctx.log.warn({ sessionId }, 'inference: session or project missing'); ctx.log.warn({ sessionId }, 'inference: session or project missing');
return; return;
@@ -370,6 +393,7 @@ async function runAssistantTurn(
ctx.publish(sessionId, { ctx.publish(sessionId, {
type: 'message_started', type: 'message_started',
message_id: assistantMessageId, message_id: assistantMessageId,
chat_id: chatId,
role: 'assistant', role: 'assistant',
}); });
@@ -408,21 +432,25 @@ async function runAssistantTurn(
ctx.publish(sessionId, { ctx.publish(sessionId, {
type: 'delta', type: 'delta',
message_id: assistantMessageId, message_id: assistantMessageId,
chat_id: chatId,
content: delta, content: delta,
}); });
ctx.log.debug({ sessionId, delta }, 'inference delta'); ctx.log.debug({ sessionId, delta }, 'inference delta');
scheduleFlush(); scheduleFlush();
} },
signal
); );
} catch (err) { } catch (err) {
if (pendingFlushTimer) { if (pendingFlushTimer) {
clearTimeout(pendingFlushTimer); clearTimeout(pendingFlushTimer);
pendingFlushTimer = null; pendingFlushTimer = null;
} }
const errMsg = err instanceof Error ? err.message : String(err); await flushPromise;
const isAbort = err instanceof Error && err.name === 'AbortError';
const finalStatus = isAbort ? 'cancelled' : 'failed';
await ctx.sql` await ctx.sql`
UPDATE messages UPDATE messages
SET status = 'failed', SET status = ${finalStatus},
content = ${accumulated}, content = ${accumulated},
finished_at = clock_timestamp() finished_at = clock_timestamp()
WHERE id = ${assistantMessageId} WHERE id = ${assistantMessageId}
@@ -433,12 +461,23 @@ async function runAssistantTurn(
RETURNING project_id, name, updated_at RETURNING project_id, name, updated_at
`; `;
ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: failSessRow!.project_id, name: failSessRow!.name, updated_at: failSessRow!.updated_at }); ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: failSessRow!.project_id, name: failSessRow!.name, updated_at: failSessRow!.updated_at });
if (isAbort) {
ctx.publish(sessionId, {
type: 'message_complete',
message_id: assistantMessageId,
chat_id: chatId,
});
ctx.log.info({ sessionId, chatId, assistantMessageId }, 'inference cancelled');
} else {
const errMsg = err instanceof Error ? err.message : String(err);
ctx.publish(sessionId, { ctx.publish(sessionId, {
type: 'error', type: 'error',
message_id: assistantMessageId, message_id: assistantMessageId,
chat_id: chatId,
error: errMsg, error: errMsg,
}); });
ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed'); ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed');
}
return; return;
} }
@@ -475,12 +514,14 @@ async function runAssistantTurn(
ctx.publish(sessionId, { ctx.publish(sessionId, {
type: 'tool_call', type: 'tool_call',
message_id: assistantMessageId, message_id: assistantMessageId,
chat_id: chatId,
tool_call: tc, tool_call: tc,
}); });
} }
ctx.publish(sessionId, { ctx.publish(sessionId, {
type: 'message_complete', type: 'message_complete',
message_id: assistantMessageId, message_id: assistantMessageId,
chat_id: chatId,
tokens_used: updated?.tokens_used ?? null, tokens_used: updated?.tokens_used ?? null,
ctx_used: updated?.ctx_used ?? null, ctx_used: updated?.ctx_used ?? null,
ctx_max: updated?.ctx_max ?? null, ctx_max: updated?.ctx_max ?? null,
@@ -492,8 +533,8 @@ async function runAssistantTurn(
await Promise.all( await Promise.all(
toolCalls.map(async (tc) => { toolCalls.map(async (tc) => {
const [toolRow] = await ctx.sql<{ id: string }[]>` const [toolRow] = await ctx.sql<{ id: string }[]>`
INSERT INTO messages (session_id, role, content, status, created_at) INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, 'tool', '', 'complete', clock_timestamp()) VALUES (${sessionId}, ${chatId}, 'tool', '', 'complete', clock_timestamp())
RETURNING id RETURNING id
`; `;
const toolMessageId = toolRow!.id; const toolMessageId = toolRow!.id;
@@ -512,6 +553,7 @@ async function runAssistantTurn(
ctx.publish(sessionId, { ctx.publish(sessionId, {
type: 'tool_result', type: 'tool_result',
tool_message_id: toolMessageId, tool_message_id: toolMessageId,
chat_id: chatId,
tool_call_id: tc.id, tool_call_id: tc.id,
output: tres.output, output: tres.output,
truncated: tres.truncated, truncated: tres.truncated,
@@ -521,11 +563,11 @@ async function runAssistantTurn(
); );
const [nextAssistant] = await ctx.sql<{ id: string }[]>` const [nextAssistant] = await ctx.sql<{ id: string }[]>`
INSERT INTO messages (session_id, role, content, status, created_at) INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, 'assistant', '', 'streaming', clock_timestamp()) VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id RETURNING id
`; `;
await runAssistantTurn(ctx, sessionId, nextAssistant!.id, depth + 1); await runAssistantTurn(ctx, sessionId, chatId, nextAssistant!.id, depth + 1, signal);
return; return;
} }
@@ -551,6 +593,7 @@ async function runAssistantTurn(
ctx.publish(sessionId, { ctx.publish(sessionId, {
type: 'message_complete', type: 'message_complete',
message_id: assistantMessageId, message_id: assistantMessageId,
chat_id: chatId,
tokens_used: updated?.tokens_used ?? null, tokens_used: updated?.tokens_used ?? null,
ctx_used: updated?.ctx_used ?? null, ctx_used: updated?.ctx_used ?? null,
ctx_max: updated?.ctx_max ?? null, ctx_max: updated?.ctx_max ?? null,
@@ -561,6 +604,7 @@ async function runAssistantTurn(
ctx.log.info( ctx.log.info(
{ {
sessionId, sessionId,
chatId,
assistantMessageId, assistantMessageId,
finishReason, finishReason,
chars: content.length, chars: content.length,
@@ -574,36 +618,153 @@ async function runAssistantTurn(
export async function runInference( export async function runInference(
ctx: InferenceContext, ctx: InferenceContext,
sessionId: string, sessionId: string,
assistantMessageId: string chatId: string,
assistantMessageId: string,
signal?: AbortSignal
): Promise<void> { ): Promise<void> {
return runAssistantTurn(ctx, sessionId, assistantMessageId, 0); return runAssistantTurn(ctx, sessionId, chatId, assistantMessageId, 0, signal);
}
const COMPACT_SYSTEM_PROMPT =
'Summarize the preceding conversation into a dense but complete context paragraph. Preserve all key facts, decisions, file paths, code patterns, and action items. Do not add any new information. Output only the summary paragraph.';
async function runCompact(
ctx: InferenceContext,
sessionId: string,
chatId: string,
compactMessageId: string
): Promise<void> {
const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (!loaded) return;
const { session, project, history } = loaded;
const messagesForSummary = buildMessagesPayload(session, project,
history.filter((m) => m.id !== compactMessageId)
);
messagesForSummary.push({
role: 'system',
content: COMPACT_SYSTEM_PROMPT,
});
ctx.publish(sessionId, {
type: 'message_started',
message_id: compactMessageId,
chat_id: chatId,
role: 'assistant',
});
let content = '';
try {
const result = await streamCompletion(
ctx,
session.model,
messagesForSummary,
false,
(delta) => {
content += delta;
ctx.publish(sessionId, {
type: 'delta',
message_id: compactMessageId,
chat_id: chatId,
content: delta,
});
}
);
content = result.content;
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
await ctx.sql`
UPDATE messages SET status = 'failed', content = ${content}, finished_at = clock_timestamp()
WHERE id = ${compactMessageId}
`;
ctx.publish(sessionId, {
type: 'error',
message_id: compactMessageId,
chat_id: chatId,
error: errMsg,
});
return;
}
const preCompactCount = history.filter((m) => m.id !== compactMessageId && m.kind !== 'compact').length;
const summary = `[Context compacted — ${preCompactCount} messages summarized]\n\n${content}`;
await ctx.sql`
UPDATE messages SET content = ${summary}, status = 'complete', finished_at = clock_timestamp()
WHERE id = ${compactMessageId}
`;
ctx.publish(sessionId, {
type: 'message_complete',
message_id: compactMessageId,
chat_id: chatId,
});
}
interface InferenceRegistration {
controller: AbortController;
completed: Promise<void>;
} }
export function createInferenceRunner( export function createInferenceRunner(
ctx: Omit<InferenceContext, 'publishUser'>, ctx: Omit<InferenceContext, 'publishUser'>,
publishUserFn: (user: string, frame: UserStreamFrame) => void publishUserFn: (user: string, frame: UserStreamFrame) => void
) { ) {
const registry = new Map<string, InferenceRegistration>();
return { return {
enqueue(sessionId: string, assistantMessageId: string, user: string) { enqueue(sessionId: string, chatId: string, assistantMessageId: string, user: string) {
const callCtx: InferenceContext = {
...ctx,
publishUser: (frame) => publishUserFn(user, frame),
};
const controller = new AbortController();
let resolveCompleted!: () => void;
const completed = new Promise<void>((res) => { resolveCompleted = res; });
const registration: InferenceRegistration = { controller, completed };
registry.set(chatId, registration);
void (async () => {
try {
await runInference(callCtx, sessionId, chatId, assistantMessageId, controller.signal);
setImmediate(() => {
void maybeAutoNameChat(callCtx, chatId, sessionId).catch((err: Error) => {
callCtx.log.warn({ err, chatId }, 'auto-name failed');
});
});
} catch (err) {
callCtx.log.error({ err }, 'unhandled inference error');
} finally {
resolveCompleted();
// Only clear our own registration; a force-send may have replaced it.
if (registry.get(chatId) === registration) {
registry.delete(chatId);
}
}
})();
},
enqueueCompact(sessionId: string, chatId: string, compactMessageId: string, user: string) {
const callCtx: InferenceContext = { const callCtx: InferenceContext = {
...ctx, ...ctx,
publishUser: (frame) => publishUserFn(user, frame), publishUser: (frame) => publishUserFn(user, frame),
}; };
void (async () => { void (async () => {
try { try {
await runInference(callCtx, sessionId, assistantMessageId); await runCompact(callCtx, sessionId, chatId, compactMessageId);
setImmediate(() => {
void maybeAutoNameSession(callCtx, sessionId).catch((err) => {
callCtx.log.warn({ err, sessionId }, 'auto-name failed');
});
});
} catch (err) { } catch (err) {
callCtx.log.error({ err }, 'unhandled inference error'); callCtx.log.error({ err }, 'unhandled compact error');
} }
})(); })();
}, },
async cancel(_sessionId: string, chatId: string): Promise<boolean> {
const reg = registry.get(chatId);
if (!reg) return false;
reg.controller.abort();
// Swallow — we just need to wait for the catch/finally to persist state.
await reg.completed.catch(() => {});
return true;
},
}; };
} }
// Reference to keep ALL_TOOLS imported for type checks if needed
export const _toolNames = ALL_TOOLS.map((t) => t.name); export const _toolNames = ALL_TOOLS.map((t) => t.name);

View File

@@ -11,18 +11,39 @@ export interface AvailableProject {
name: string; name: string;
} }
export type SessionStatus = 'open' | 'archived';
export interface Session { export interface Session {
id: string; id: string;
project_id: string; project_id: string;
name: string; name: string;
model: string; model: string;
system_prompt: string; system_prompt: string;
status: SessionStatus;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
export type MessageRole = 'user' | 'assistant' | 'tool'; export type ChatStatus = 'open' | 'closed';
export type MessageStatus = 'streaming' | 'complete' | 'failed';
export interface Chat {
id: string;
session_id: string;
name: string | null;
status: ChatStatus;
created_at: string;
updated_at: string;
}
// KEEP IN SYNC: apps/server/src/schema.sql messages_role_chk / messages_status_chk
export const MESSAGE_ROLES = ['user', 'assistant', 'system', 'tool'] as const;
export type MessageRole = typeof MESSAGE_ROLES[number];
export const MESSAGE_STATUSES = ['streaming', 'complete', 'failed', 'cancelled'] as const;
export type MessageStatus = typeof MESSAGE_STATUSES[number];
export const MESSAGE_KINDS = ['message', 'compact'] as const;
export type MessageKind = typeof MESSAGE_KINDS[number];
export interface ToolCall { export interface ToolCall {
id: string; id: string;
@@ -40,8 +61,10 @@ export interface ToolResult {
export interface Message { export interface Message {
id: string; id: string;
session_id: string; session_id: string;
chat_id: string;
role: MessageRole; role: MessageRole;
content: string; content: string;
kind: MessageKind;
tool_calls: ToolCall[] | null; tool_calls: ToolCall[] | null;
tool_results: ToolResult | null; tool_results: ToolResult | null;
status: MessageStatus; status: MessageStatus;
@@ -139,9 +162,35 @@ export interface SessionUpdatedFrame {
name: string; name: string;
updated_at: string; updated_at: string;
} }
export interface SessionArchivedFrame {
type: 'session_archived';
session_id: string;
project_id: string;
}
export interface ChatCreatedFrame {
type: 'chat_created';
chat: Chat;
session_id: string;
}
export interface ChatUpdatedFrame {
type: 'chat_updated';
chat_id: string;
session_id: string;
name: string | null;
updated_at: string;
}
export interface ChatClosedFrame {
type: 'chat_closed';
chat_id: string;
session_id: string;
}
export type UserStreamFrame = export type UserStreamFrame =
| ProjectCreatedFrame | ProjectCreatedFrame
| ProjectDeletedFrame | ProjectDeletedFrame
| SessionCreatedFrame | SessionCreatedFrame
| SessionDeletedFrame | SessionDeletedFrame
| SessionUpdatedFrame; | SessionUpdatedFrame
| SessionArchivedFrame
| ChatCreatedFrame
| ChatUpdatedFrame
| ChatClosedFrame;

View File

@@ -1,11 +1,29 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { useEffect, useState } from 'react';
import { BrowserRouter, Routes, Route, useParams } from 'react-router-dom';
import { api } from '@/api/client';
import { ProjectSidebar } from '@/components/ProjectSidebar'; import { ProjectSidebar } from '@/components/ProjectSidebar';
import { RightRail } from '@/components/RightRail';
import { Home } from '@/pages/Home'; import { Home } from '@/pages/Home';
import { Project } from '@/pages/Project'; import { Project } from '@/pages/Project';
import { Session } from '@/pages/Session'; import { Session } from '@/pages/Session';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import { useUserEvents } from '@/hooks/useUserEvents'; import { useUserEvents } from '@/hooks/useUserEvents';
function SessionRightRail() {
const { id } = useParams<{ id: string }>();
if (!id) return null;
return <RightRailForSession sessionId={id} />;
}
function RightRailForSession({ sessionId }: { sessionId: string }) {
const [projectId, setProjectId] = useState<string | null>(null);
useEffect(() => {
api.sessions.get(sessionId).then((s) => setProjectId(s.project_id)).catch(() => {});
}, [sessionId]);
if (!projectId) return null;
return <RightRail projectId={projectId} />;
}
function AppShell() { function AppShell() {
useUserEvents(); useUserEvents();
return ( return (
@@ -18,6 +36,9 @@ function AppShell() {
<Route path="/session/:id" element={<Session />} /> <Route path="/session/:id" element={<Session />} />
</Routes> </Routes>
</main> </main>
<Routes>
<Route path="/session/:id" element={<SessionRightRail />} />
</Routes>
<Toaster position="bottom-right" /> <Toaster position="bottom-right" />
</div> </div>
); );

View File

@@ -2,14 +2,12 @@ import type {
Project, Project,
AvailableProject, AvailableProject,
Session, Session,
Chat,
Message, Message,
ModelInfo, ModelInfo,
SidebarResponse, SidebarResponse,
ListDirResult, ListDirResult,
ViewFileResult, ViewFileResult,
Pane,
PaneCreateRequest,
PaneUpdateRequest,
} from './types'; } from './types';
export class ApiError extends Error { export class ApiError extends Error {
@@ -61,8 +59,8 @@ export const api = {
}, },
sessions: { sessions: {
listForProject: (projectId: string) => listForProject: (projectId: string, status?: 'open' | 'archived') =>
request<Session[]>(`/api/projects/${projectId}/sessions`), request<Session[]>(`/api/projects/${projectId}/sessions${status ? `?status=${status}` : ''}`),
create: ( create: (
projectId: string, projectId: string,
body: { name?: string; model?: string; system_prompt?: string } body: { name?: string; model?: string; system_prompt?: string }
@@ -82,22 +80,54 @@ export const api = {
}), }),
remove: (id: string) => remove: (id: string) =>
request<void>(`/api/sessions/${id}`, { method: 'DELETE' }), request<void>(`/api/sessions/${id}`, { method: 'DELETE' }),
archive: (id: string) =>
request<void>(`/api/sessions/${id}/archive`, { method: 'POST' }),
unarchive: (id: string) =>
request<Session>(`/api/sessions/${id}/unarchive`, { method: 'POST' }),
},
chats: {
listForSession: (sessionId: string) =>
request<Chat[]>(`/api/sessions/${sessionId}/chats`),
create: (sessionId: string, body?: { name?: string }) =>
request<Chat>(`/api/sessions/${sessionId}/chats`, {
method: 'POST',
body: JSON.stringify(body ?? {}),
}),
update: (chatId: string, body: { name?: string; status?: 'open' | 'closed' }) =>
request<Chat>(`/api/chats/${chatId}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
remove: (chatId: string) =>
request<void>(`/api/chats/${chatId}`, { method: 'DELETE' }),
messages: (chatId: string) =>
request<Message[]>(`/api/chats/${chatId}/messages`),
compact: (chatId: string) =>
request<{ compact_message_id: string }>(`/api/chats/${chatId}/compact`, { method: 'POST' }),
stop: (chatId: string) =>
request<{ stopped: boolean }>(`/api/chats/${chatId}/stop`, { method: 'POST' }),
forceSend: (chatId: string, content: string) =>
request<{ user_message_id: string; assistant_message_id: string }>(
`/api/chats/${chatId}/force_send`,
{ method: 'POST', body: JSON.stringify({ content }) }
),
}, },
messages: { messages: {
list: (sessionId: string) => list: (sessionId: string) =>
request<Message[]>(`/api/sessions/${sessionId}/messages`), request<Message[]>(`/api/sessions/${sessionId}/messages`),
send: (sessionId: string, content: string) => send: (chatId: string, content: string) =>
request<{ user_message_id: string; assistant_message_id: string }>( request<{ user_message_id: string; assistant_message_id: string }>(
`/api/sessions/${sessionId}/messages`, `/api/chats/${chatId}/messages`,
{ {
method: 'POST', method: 'POST',
body: JSON.stringify({ content }), body: JSON.stringify({ content }),
} }
), ),
regenerate: (sessionId: string, messageId: string) => regenerate: (chatId: string, messageId: string) =>
request<{ assistant_message_id: string }>( request<{ assistant_message_id: string }>(
`/api/sessions/${sessionId}/messages/${messageId}/regenerate`, `/api/chats/${chatId}/messages/${messageId}/regenerate`,
{ method: 'POST' } { method: 'POST' }
), ),
}, },
@@ -116,21 +146,4 @@ export const api = {
sidebar: { sidebar: {
get: () => request<SidebarResponse>('/api/sidebar'), get: () => request<SidebarResponse>('/api/sidebar'),
}, },
panes: {
getForSession: (sessionId: string) =>
request<{ panes: Pane[] }>(`/api/sessions/${sessionId}/panes`),
create: (sessionId: string, body: PaneCreateRequest) =>
request<Pane>(`/api/sessions/${sessionId}/panes`, {
method: 'POST',
body: JSON.stringify(body),
}),
update: (id: string, body: PaneUpdateRequest) =>
request<Pane>(`/api/panes/${id}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
remove: (id: string) =>
request<void>(`/api/panes/${id}`, { method: 'DELETE' }),
},
}; };

View File

@@ -11,18 +11,33 @@ export interface AvailableProject {
name: string; name: string;
} }
export type SessionStatus = 'open' | 'archived';
export interface Session { export interface Session {
id: string; id: string;
project_id: string; project_id: string;
name: string; name: string;
model: string; model: string;
system_prompt: string; system_prompt: string;
status: SessionStatus;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
export type MessageRole = 'user' | 'assistant' | 'tool'; export type ChatStatus = 'open' | 'closed';
export type MessageStatus = 'streaming' | 'complete' | 'failed';
export interface Chat {
id: string;
session_id: string;
name: string | null;
status: ChatStatus;
created_at: string;
updated_at: string;
}
export type MessageRole = 'user' | 'assistant' | 'tool' | 'system';
export type MessageStatus = 'streaming' | 'complete' | 'failed' | 'cancelled';
export type MessageKind = 'message' | 'compact';
export interface ToolCall { export interface ToolCall {
id: string; id: string;
@@ -40,8 +55,10 @@ export interface ToolResult {
export interface Message { export interface Message {
id: string; id: string;
session_id: string; session_id: string;
chat_id: string;
role: MessageRole; role: MessageRole;
content: string; content: string;
kind: MessageKind;
tool_calls: ToolCall[] | null; tool_calls: ToolCall[] | null;
tool_results: ToolResult | null; tool_results: ToolResult | null;
status: MessageStatus; status: MessageStatus;
@@ -127,14 +144,25 @@ export interface PaneUpdateRequest {
position?: number; position?: number;
} }
export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty';
export interface WorkspacePane {
id: string;
kind: WorkspacePaneKind;
chatId?: string;
chatIds: string[];
activeChatIdx: number;
}
export type WsFrame = export type WsFrame =
| { type: 'snapshot'; messages: Message[] } | { type: 'snapshot'; messages: Message[] }
| { type: 'message_started'; message_id: string; role: MessageRole } | { type: 'message_started'; message_id: string; chat_id?: string; role: MessageRole }
| { type: 'delta'; message_id: string; content: string } | { type: 'delta'; message_id: string; chat_id?: string; content: string }
| { type: 'tool_call'; message_id: string; tool_call: ToolCall } | { type: 'tool_call'; message_id: string; chat_id?: string; tool_call: ToolCall }
| { | {
type: 'tool_result'; type: 'tool_result';
tool_message_id: string; tool_message_id: string;
chat_id?: string;
tool_call_id: string; tool_call_id: string;
output: unknown; output: unknown;
truncated: boolean; truncated: boolean;
@@ -143,12 +171,14 @@ export type WsFrame =
| { | {
type: 'message_complete'; type: 'message_complete';
message_id: string; message_id: string;
chat_id?: string;
tokens_used?: number | null; tokens_used?: number | null;
ctx_used?: number | null; ctx_used?: number | null;
ctx_max?: number | null; ctx_max?: number | null;
started_at?: string | null; started_at?: string | null;
finished_at?: string | null; finished_at?: string | null;
} }
| { type: 'messages_deleted'; message_ids: string[] } | { type: 'messages_deleted'; message_ids: string[]; chat_id?: string }
| { type: 'session_renamed'; session_id: string; name: string } | { type: 'session_renamed'; session_id: string; name: string; chat_id?: string }
| { type: 'error'; message_id?: string; error: string }; | { type: 'chat_renamed'; chat_id: string; name: string }
| { type: 'error'; message_id?: string; chat_id?: string; error: string };

View File

@@ -0,0 +1,41 @@
import { FileText, X } from 'lucide-react';
import type { Attachment } from '@/lib/attachments';
interface Props {
attachment: Attachment;
onRemove: (id: string) => void;
onPreview: (attachment: Attachment) => void;
}
export function AttachmentChip({ attachment, onRemove, onPreview }: Props) {
const lineCount = attachment.content.split('\n').length;
const label =
attachment.kind === 'lines' && attachment.range
? `${attachment.filename}:${attachment.range[0]}-${attachment.range[1]}`
: attachment.filename;
return (
<div className="flex items-center gap-1.5 bg-muted/60 border border-border rounded px-2 py-0.5 text-xs font-mono">
<button
type="button"
onClick={() => onPreview(attachment)}
className="flex items-center gap-1.5 hover:bg-muted/60 transition-colors min-w-0"
>
<FileText className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate max-w-[200px]">{label}</span>
<span className="text-muted-foreground whitespace-nowrap">
+{lineCount} lines
</span>
</button>
<button
type="button"
onClick={() => onRemove(attachment.id)}
className="ml-0.5 rounded hover:bg-muted-foreground/20 p-0.5 shrink-0"
aria-label="Remove attachment"
>
<X className="size-3" />
</button>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import type { Attachment } from '@/lib/attachments';
import { CodeBlock } from '@/components/CodeBlock';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface Props {
attachment: Attachment | null;
onClose: () => void;
}
export function AttachmentPreviewModal({ attachment, onClose }: Props) {
const title = attachment
? attachment.kind === 'lines' && attachment.range
? `${attachment.filename}:${attachment.range[0]}-${attachment.range[1]}`
: attachment.filename
: '';
return (
<Dialog open={attachment !== null} onOpenChange={() => onClose()}>
<DialogContent className="sm:max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="font-mono text-sm">{title}</DialogTitle>
</DialogHeader>
{attachment && (
<CodeBlock
code={attachment.content}
lang={attachment.language ?? undefined}
/>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -1,25 +1,73 @@
import { useState, type KeyboardEvent } from 'react'; import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react';
import { Send } from 'lucide-react'; import { Send } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { flattenToMessage, inferLanguage, type Attachment } from '@/lib/attachments';
import { AttachmentChip } from '@/components/AttachmentChip';
import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
import { FileMentionPopover } from '@/components/FileMentionPopover';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
interface Props { interface Props {
disabled?: boolean; disabled?: boolean;
projectId: string;
onSend: (content: string) => void | Promise<void>; onSend: (content: string) => void | Promise<void>;
onForceSend?: (content: string) => void | Promise<void>;
} }
export function ChatInput({ disabled, onSend }: Props) { export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [previewAttachment, setPreviewAttachment] = useState<Attachment | null>(null);
const [mentionState, setMentionState] = useState<{
open: boolean;
query: string;
atIdx: number;
anchorRect: { top: number; left: number };
} | null>(null);
const [fileIndex, setFileIndex] = useState<string[] | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
function addAttachment(a: Attachment) {
setAttachments(prev => {
if (prev.length >= 10) {
toast.error('Max 10 attachments per message');
return prev;
}
return [...prev, a];
});
}
const addAttachmentRef = useRef(addAttachment);
addAttachmentRef.current = addAttachment;
useEffect(() => {
return sessionEvents.subscribe((event) => {
if (event.type !== 'attach_chat_file') return;
addAttachmentRef.current({
id: crypto.randomUUID(),
...event.attachment,
});
});
}, []);
function removeAttachment(id: string) {
setAttachments(prev => prev.filter(a => a.id !== id));
}
async function submit() { async function submit() {
const text = value.trim(); const text = value.trim();
if (!text || disabled || busy) return; if (!text && attachments.length === 0) return;
if (disabled || busy) return;
setBusy(true); setBusy(true);
try { try {
await onSend(text); const body = flattenToMessage(attachments, text);
await onSend(body);
setValue(''); setValue('');
setAttachments([]);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to send'); toast.error(err instanceof Error ? err.message : 'failed to send');
} finally { } finally {
@@ -27,32 +75,196 @@ export function ChatInput({ disabled, onSend }: Props) {
} }
} }
function getCaretCoords(textarea: HTMLTextAreaElement): { top: number; left: number } {
const mirror = document.createElement('div');
const style = window.getComputedStyle(textarea);
const properties = [
'fontFamily', 'fontSize', 'fontWeight', 'fontStyle',
'letterSpacing', 'lineHeight', 'textTransform', 'wordSpacing',
'textIndent', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth',
'boxSizing', 'whiteSpace', 'overflowWrap',
] as const;
mirror.style.position = 'absolute';
mirror.style.visibility = 'hidden';
mirror.style.overflow = 'hidden';
mirror.style.width = style.width;
for (const prop of properties) {
mirror.style[prop] = style[prop];
}
mirror.style.whiteSpace = 'pre-wrap';
mirror.style.overflowWrap = 'break-word';
const textBefore = textarea.value.slice(0, textarea.selectionStart);
mirror.textContent = textBefore;
const span = document.createElement('span');
span.textContent = ''; // zero-width space
mirror.appendChild(span);
document.body.appendChild(mirror);
const taRect = textarea.getBoundingClientRect();
const spanRect = span.getBoundingClientRect();
const mirrorRect = mirror.getBoundingClientRect();
const top = taRect.top + (spanRect.top - mirrorRect.top) - textarea.scrollTop + span.offsetHeight;
const left = taRect.left + (spanRect.left - mirrorRect.left);
document.body.removeChild(mirror);
return { top, left };
}
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
const newValue = e.target.value;
setValue(newValue);
const ta = e.target;
const pos = ta.selectionStart;
// Check for @ trigger
if (pos > 0 && newValue[pos - 1] === '@') {
const charBefore = pos >= 2 ? newValue[pos - 2] : null;
if (charBefore === null || charBefore === ' ' || charBefore === '\n') {
const coords = getCaretCoords(ta);
setMentionState({ open: true, query: '', atIdx: pos - 1, anchorRect: coords });
if (!fileIndex) {
api.projects.files(projectId).then(r => setFileIndex(r.files)).catch(() => {});
}
return;
}
}
// Update query if popover is open — use stored atIdx
if (mentionState?.open) {
const { atIdx } = mentionState;
if (atIdx < pos && newValue[atIdx] === '@') {
const query = newValue.slice(atIdx + 1, pos);
setMentionState(prev => prev ? { ...prev, query } : null);
} else {
setMentionState(null);
}
}
}
async function handleMentionSelect(path: string) {
const atIdx = mentionState?.atIdx ?? -1;
const ta = textareaRef.current;
const caretPos = ta?.selectionStart ?? value.length;
setMentionState(null);
try {
const result = await api.projects.viewFile(projectId, path);
if (atIdx >= 0) {
const cleaned = value.slice(0, atIdx) + value.slice(caretPos);
setValue(cleaned);
if (ta) {
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = atIdx;
ta.focus();
});
}
}
addAttachment({
id: crypto.randomUUID(),
kind: 'file',
filename: path,
language: inferLanguage(path),
content: result.content,
source: '@',
});
} catch {
toast.error('Failed to load file');
}
}
const closeMention = useCallback(() => setMentionState(null), []);
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) { function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { if (mentionState?.open) return;
if (e.key === 'Enter' && e.shiftKey && (e.metaKey || e.ctrlKey) && onForceSend) {
e.preventDefault();
void forceSubmit();
return;
}
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
void submit();
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
void submit(); void submit();
} }
} }
async function forceSubmit() {
const text = value.trim();
if (!text || !onForceSend) return;
if (busy) return;
setBusy(true);
try {
const body = flattenToMessage(attachments, text);
await onForceSend(body);
setValue('');
setAttachments([]);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'force send failed');
} finally {
setBusy(false);
}
}
return ( return (
<div className="border-t px-4 py-3 flex items-end gap-2"> <div className="border-t">
{attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-4 pt-3">
{attachments.map(a => (
<AttachmentChip
key={a.id}
attachment={a}
onRemove={removeAttachment}
onPreview={setPreviewAttachment}
/>
))}
</div>
)}
<div className="px-4 py-3 flex items-end gap-2">
<Textarea <Textarea
ref={textareaRef}
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={handleChange}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
placeholder="Ask about this project. Cmd/Ctrl+Enter to send." placeholder="Ask about this project. Enter to send, Shift+Enter for newline."
disabled={disabled || busy} disabled={disabled || busy}
rows={3} rows={3}
className="resize-none min-h-[68px] max-h-[240px]" className="resize-none min-h-[68px] max-h-[240px]"
/> />
<Button <Button
onClick={() => void submit()} onClick={() => void submit()}
disabled={disabled || busy || !value.trim()} disabled={disabled || busy || (!value.trim() && attachments.length === 0)}
size="icon-lg" size="icon-lg"
aria-label="Send" aria-label="Send"
> >
<Send /> <Send />
</Button> </Button>
</div> </div>
<AttachmentPreviewModal
attachment={previewAttachment}
onClose={() => setPreviewAttachment(null)}
/>
{mentionState?.open && (
<FileMentionPopover
query={mentionState.query}
files={fileIndex ?? []}
anchorRect={mentionState.anchorRect}
onSelect={handleMentionSelect}
onClose={closeMention}
/>
)}
</div>
); );
} }

View File

@@ -0,0 +1,201 @@
import { useState } from 'react';
import { History, MessageSquare, Plus, X } from 'lucide-react';
import type { Chat, WorkspacePane } from '@/api/types';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface Props {
pane: WorkspacePane;
tabs: Chat[];
onSwitchTab: (tabIdx: number) => void;
onRemoveTab: (chatId: string) => void;
onNewChat: () => void;
onShowHistory: () => void;
onRename: (chatId: string, name: string) => Promise<void>;
onClose: (chatId: string) => Promise<void>;
onDelete: (chatId: string) => Promise<void>;
onRemovePane?: () => void;
}
export function ChatTabBar({
pane,
tabs,
onSwitchTab,
onRemoveTab,
onNewChat,
onShowHistory,
onRename,
onClose,
onDelete,
onRemovePane,
}: Props) {
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
function startRename(chatId: string, currentName: string | null) {
setRenamingId(chatId);
setRenameValue(currentName ?? '');
}
async function finishRename() {
if (renamingId && renameValue.trim()) {
await onRename(renamingId, renameValue.trim());
}
setRenamingId(null);
}
return (
<div className="flex items-center border-b border-border bg-muted/20 h-8 shrink-0 overflow-x-auto">
{/* Chat tabs */}
{tabs.map((chat, tabIdx) => {
const isActive = tabIdx === pane.activeChatIdx;
const label = chat.name ?? 'New chat';
return (
<ContextMenu key={chat.id}>
<ContextMenuTrigger asChild>
<div
onClick={() => onSwitchTab(tabIdx)}
className={cn(
'group flex items-center gap-1.5 px-3 py-1.5 text-xs border-r border-border cursor-default select-none shrink-0',
isActive
? 'bg-background text-foreground'
: 'bg-muted/30 text-muted-foreground hover:bg-muted/60'
)}
>
<MessageSquare size={12} className="shrink-0" />
{renamingId === chat.id ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => void finishRename()}
onKeyDown={(e) => {
if (e.key === 'Enter') void finishRename();
if (e.key === 'Escape') setRenamingId(null);
}}
onClick={(e) => e.stopPropagation()}
className="bg-transparent border-b border-border text-xs outline-none w-28"
/>
) : (
<span className="truncate max-w-[140px]" title={label}>
{label}
</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemoveTab(chat.id);
}}
className="p-0.5 hover:bg-muted rounded opacity-0 group-hover:opacity-60 hover:!opacity-100 shrink-0"
aria-label="Remove from tab bar"
>
<X size={10} />
</button>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => startRename(chat.id, chat.name)}>
Rename
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => void onClose(chat.id)}>
Close
</ContextMenuItem>
<ContextMenuItem
variant="destructive"
onSelect={() => setDeleteConfirm(chat.id)}
>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
})}
{/* Empty state label */}
{tabs.length === 0 && (
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground">
<History size={12} className="shrink-0" />
<span>Session</span>
</div>
)}
{/* Action buttons */}
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
<button
type="button"
onClick={onNewChat}
className="p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="New chat"
title="New chat"
>
<Plus size={12} />
</button>
<button
type="button"
onClick={onShowHistory}
className={cn(
'p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground',
pane.kind === 'empty' && 'text-foreground bg-muted/50'
)}
aria-label="Session history"
title="Session history"
>
<History size={12} />
</button>
{onRemovePane && (
<button
type="button"
onClick={onRemovePane}
className="p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Close pane"
title="Close pane"
>
<X size={12} />
</button>
)}
</div>
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) setDeleteConfirm(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete chat</DialogTitle>
<DialogDescription>
This will permanently delete this chat and all its messages. This cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
if (deleteConfirm) void onDelete(deleteConfirm);
setDeleteConfirm(null);
}}
>
Delete
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,145 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
interface Props {
query: string;
files: string[];
anchorRect: { top: number; left: number };
onSelect: (path: string) => void;
onClose: () => void;
}
function filterAndRank(files: string[], query: string): string[] {
const q = query.toLowerCase();
if (!q) {
return files.slice(0, 20);
}
const filenameMatches: string[] = [];
const pathOnlyMatches: string[] = [];
for (const file of files) {
const lower = file.toLowerCase();
if (!lower.includes(q)) continue;
const basename = file.split('/').pop() ?? file;
if (basename.toLowerCase().includes(q)) {
filenameMatches.push(file);
} else {
pathOnlyMatches.push(file);
}
}
filenameMatches.sort((a, b) => a.localeCompare(b));
pathOnlyMatches.sort((a, b) => a.localeCompare(b));
return [...filenameMatches, ...pathOnlyMatches].slice(0, 20);
}
export function FileMentionPopover({
query,
files,
anchorRect,
onSelect,
onClose,
}: Props) {
const [highlightIndex, setHighlightIndex] = useState(0);
const popoverRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => filterAndRank(files, query), [files, query]);
// Reset highlight when query changes
useEffect(() => {
setHighlightIndex(0);
}, [query]);
// Keyboard navigation
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightIndex(prev =>
prev < filtered.length - 1 ? prev + 1 : 0
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightIndex(prev =>
prev > 0 ? prev - 1 : filtered.length - 1
);
} else if (e.key === 'Enter') {
e.preventDefault();
if (filtered.length > 0) {
onSelect(filtered[highlightIndex] ?? filtered[0]!);
}
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [filtered, highlightIndex, onSelect, onClose]);
// Click outside to close
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
if (
popoverRef.current &&
!popoverRef.current.contains(e.target as Node)
) {
onClose();
}
}
document.addEventListener('mousedown', handleMouseDown);
return () => document.removeEventListener('mousedown', handleMouseDown);
}, [onClose]);
// Scroll highlighted item into view
useEffect(() => {
const el = popoverRef.current?.querySelector('[data-highlighted="true"]');
if (el) {
el.scrollIntoView({ block: 'nearest' });
}
}, [highlightIndex]);
if (filtered.length === 0) {
return (
<div
ref={popoverRef}
className="fixed z-50 bg-popover border border-border rounded-md shadow min-w-[260px] p-2"
style={{ top: anchorRect.top, left: anchorRect.left }}
>
<div className="text-xs text-muted-foreground px-2 py-1">
No matching files
</div>
</div>
);
}
return (
<div
ref={popoverRef}
className="fixed z-50 bg-popover border border-border rounded-md shadow min-w-[260px] max-h-[240px] overflow-y-auto"
style={{ top: anchorRect.top, left: anchorRect.left }}
>
{filtered.map((file, i) => (
<button
key={file}
type="button"
data-highlighted={i === highlightIndex}
className={cn(
'w-full text-left text-xs font-mono px-2 py-1.5 cursor-pointer',
i === highlightIndex && 'bg-muted'
)}
onMouseEnter={() => setHighlightIndex(i)}
onMouseDown={(e) => {
e.preventDefault();
onSelect(file);
}}
>
{file}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,241 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Check, Copy, X, Paperclip } from 'lucide-react';
import { codeToHtml } from 'shiki';
import { sessionEvents } from '@/hooks/sessionEvents';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface Props {
path: string;
content: string;
lang: string | null;
projectId: string;
onClose: () => void;
onNavigate: (path: string) => void;
}
const SHIKI_THEME = 'github-dark';
function splitShikiLines(html: string): string[] {
const match = html.match(/<code[^>]*>([\s\S]*)<\/code>/);
if (!match) return [];
const inner = match[1]!;
const lines = inner.split(/(?=<span class="line">)/);
return lines.filter(l => l.trim().length > 0);
}
function basename(path: string): string {
const parts = path.split('/');
return parts[parts.length - 1] ?? path;
}
export function FileViewerOverlay({ path, content, lang, onClose }: Props) {
const [copied, setCopied] = useState(false);
const [lineHtmls, setLineHtmls] = useState<string[] | null>(null);
const [selectedLines, setSelectedLines] = useState<Set<number>>(new Set());
const [showAttachPopover, setShowAttachPopover] = useState(false);
const draggingRef = useRef(false);
const dragStartRef = useRef<number | null>(null);
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setSelectedLines(new Set());
setShowAttachPopover(false);
if (!lang) { setLineHtmls(null); return; }
let cancelled = false;
(async () => {
try {
const result = await codeToHtml(content, { lang, theme: SHIKI_THEME });
if (!cancelled) {
const lines = splitShikiLines(result);
setLineHtmls(lines.length > 0 ? lines : null);
}
} catch { if (!cancelled) setLineHtmls(null); }
})();
return () => { cancelled = true; };
}, [content, lang]);
const plainLines = content.split('\n');
const totalLines = lineHtmls ? lineHtmls.length : plainLines.length;
async function copyAll() {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch { /* ignore */ }
}
function handleLineMouseDown(lineNo: number, e: React.MouseEvent) {
if (e.shiftKey && dragStartRef.current !== null) {
const start = dragStartRef.current;
const min = Math.min(start, lineNo);
const max = Math.max(start, lineNo);
const next = new Set<number>();
for (let i = min; i <= max; i++) next.add(i);
setSelectedLines(next);
setShowAttachPopover(true);
return;
}
draggingRef.current = true;
dragStartRef.current = lineNo;
setSelectedLines(new Set([lineNo]));
setShowAttachPopover(false);
}
function handleLineMouseEnter(lineNo: number) {
if (!draggingRef.current || dragStartRef.current === null) return;
const start = dragStartRef.current;
const min = Math.min(start, lineNo);
const max = Math.max(start, lineNo);
const next = new Set<number>();
for (let i = min; i <= max; i++) next.add(i);
setSelectedLines(next);
}
const handleMouseUp = useCallback(() => {
if (draggingRef.current) {
draggingRef.current = false;
if (selectedLines.size > 0) setShowAttachPopover(true);
}
}, [selectedLines.size]);
useEffect(() => {
document.addEventListener('mouseup', handleMouseUp);
return () => document.removeEventListener('mouseup', handleMouseUp);
}, [handleMouseUp]);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (overlayRef.current && !overlayRef.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [onClose]);
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [onClose]);
function getSelectionRange(): { min: number; max: number } | null {
if (selectedLines.size === 0) return null;
let min = Infinity;
let max = -Infinity;
for (const n of selectedLines) {
if (n < min) min = n;
if (n > max) max = n;
}
return { min, max };
}
function handleAttach() {
const range = getSelectionRange();
if (!range) return;
const lines = content.split('\n').slice(range.min - 1, range.max);
sessionEvents.emit({
type: 'attach_chat_file',
attachment: {
kind: 'lines',
filename: path,
language: lang,
content: lines.join('\n'),
range: [range.min, range.max],
source: 'line-select',
},
});
setSelectedLines(new Set());
setShowAttachPopover(false);
}
const range = getSelectionRange();
const attachLabel = range
? range.min === range.max
? `Attach line ${range.min} to chat`
: `Attach lines ${range.min}${range.max} to chat`
: '';
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-12 pb-12">
<div className="absolute inset-0 bg-black/40" />
<div
ref={overlayRef}
className="relative bg-background border rounded-lg shadow-xl flex flex-col w-[80vw] max-w-[1000px] max-h-[80vh] overflow-hidden"
>
<div className="flex items-center gap-2 px-4 py-2 border-b shrink-0">
<span className="text-sm font-medium truncate flex-1" title={path}>
{basename(path)}
</span>
<span className="text-xs text-muted-foreground truncate">{path}</span>
<button
type="button"
onClick={() => void copyAll()}
className="flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted"
>
{copied ? <Check size={12} /> : <Copy size={12} />}
{copied ? 'Copied' : 'Copy'}
</button>
<button
type="button"
onClick={onClose}
className="p-1 rounded hover:bg-muted"
aria-label="Close"
>
<X size={16} />
</button>
</div>
{/* Shiki-highlighted code lines are generated from source code files, not user content */}
<div className="flex-1 overflow-auto text-sm font-mono select-none">
{Array.from({ length: totalLines }, (_, i) => {
const lineNo = i + 1;
const isSelected = selectedLines.has(lineNo);
return (
<div
key={lineNo}
className={cn('flex', isSelected && 'bg-blue-500/10')}
onMouseDown={(e) => handleLineMouseDown(lineNo, e)}
onMouseEnter={() => handleLineMouseEnter(lineNo)}
>
<div
className="shrink-0 w-[3.5ch] text-right pr-2 text-xs text-muted-foreground select-none cursor-pointer hover:text-foreground"
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{lineNo}
</div>
{lineHtmls ? (
<div
className="flex-1 min-w-0 text-xs leading-relaxed [&>.line]:!bg-transparent"
dangerouslySetInnerHTML={{ __html: lineHtmls[i] ?? '' }}
/>
) : (
<span className="flex-1 min-w-0 text-xs leading-relaxed whitespace-pre">
{plainLines[i] ?? ''}
</span>
)}
</div>
);
})}
</div>
{showAttachPopover && range && (
<div className="sticky bottom-0 border-t bg-background px-4 py-2 flex items-center gap-2">
<Paperclip size={14} className="text-muted-foreground" />
<span className="text-xs flex-1">{attachLabel}</span>
<Button size="sm" onClick={handleAttach}>
Attach
</Button>
<Button size="sm" variant="ghost" onClick={() => { setSelectedLines(new Set()); setShowAttachPopover(false); }}>
Cancel
</Button>
</div>
)}
</div>
</div>
);
}

View File

@@ -2,9 +2,9 @@ import { Children, cloneElement, isValidElement, useState } from 'react';
import type { ReactElement, ReactNode } from 'react'; import type { ReactElement, ReactNode } from 'react';
import Markdown from 'react-markdown'; import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { Copy, RefreshCw, Check } from 'lucide-react'; import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Message } from '@/api/types'; import type { Chat, Message } from '@/api/types';
import { api } from '@/api/client'; import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
import { ToolCallCard } from './ToolCallCard'; import { ToolCallCard } from './ToolCallCard';
@@ -84,7 +84,7 @@ function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
interface Props { interface Props {
message: Message; message: Message;
sessionId: string; sessionChats?: Chat[];
} }
function MarkdownBody({ content }: { content: string }) { function MarkdownBody({ content }: { content: string }) {
@@ -193,10 +193,8 @@ function StatsLine({ message }: { message: Message }) {
function ActionRow({ function ActionRow({
message, message,
sessionId,
}: { }: {
message: Message; message: Message;
sessionId: string;
}) { }) {
const [justCopied, setJustCopied] = useState(false); const [justCopied, setJustCopied] = useState(false);
const [regenerating, setRegenerating] = useState(false); const [regenerating, setRegenerating] = useState(false);
@@ -215,7 +213,7 @@ function ActionRow({
if (regenerating || message.status === 'streaming') return; if (regenerating || message.status === 'streaming') return;
setRegenerating(true); setRegenerating(true);
try { try {
await api.messages.regenerate(sessionId, message.id); await api.messages.regenerate(message.chat_id, message.id);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'regenerate failed'); toast.error(err instanceof Error ? err.message : 'regenerate failed');
} finally { } finally {
@@ -253,7 +251,101 @@ function ActionRow({
); );
} }
export function MessageBubble({ message, sessionId }: Props) { function CompactCard({ message, sessionChats }: { message: Message; sessionChats?: Chat[] }) {
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const headerMatch = message.content.match(/^\[Context compacted — (\d+) messages summarized\]/);
const headerText = headerMatch ? headerMatch[0] : 'Context compacted';
const summaryText = headerMatch
? message.content.slice(headerMatch[0].length).trim()
: message.content;
async function handleCopy() {
try {
await navigator.clipboard.writeText(summaryText);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {
toast.error('Copy failed');
}
}
async function handleShareToChat(chatId: string) {
try {
await api.messages.send(chatId, summaryText);
toast.success('Summary sent to chat');
setShareOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to share');
}
}
const otherChats = (sessionChats ?? []).filter(
(c) => c.id !== message.chat_id && c.status === 'open'
);
return (
<div className="rounded-lg border bg-muted/30 text-sm">
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1.5 flex-1 min-w-0 text-left text-muted-foreground hover:text-foreground"
>
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span className="text-xs font-medium truncate">{headerText}</span>
</button>
<button
type="button"
onClick={() => void handleCopy()}
className="p-1 rounded hover:bg-muted text-muted-foreground"
aria-label="Copy summary"
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</button>
{otherChats.length > 0 && (
<div className="relative">
<button
type="button"
onClick={() => setShareOpen(!shareOpen)}
className="p-1 rounded hover:bg-muted text-muted-foreground"
aria-label="Send to chat"
>
<Share2 size={12} />
</button>
{shareOpen && (
<div className="absolute right-0 top-full mt-1 z-50 bg-popover border rounded-md shadow-md min-w-[160px] py-1">
{otherChats.map((c) => (
<button
key={c.id}
type="button"
onClick={() => void handleShareToChat(c.id)}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent truncate"
>
{c.name ?? 'New chat'}
</button>
))}
</div>
)}
</div>
)}
</div>
{expanded && (
<div className="px-3 pb-3 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap border-t pt-2">
{summaryText}
</div>
)}
</div>
);
}
export function MessageBubble({ message, sessionChats }: Props) {
if (message.kind === 'compact') {
return <CompactCard message={message} sessionChats={sessionChats} />;
}
if (message.role === 'tool') { if (message.role === 'tool') {
return <ToolCallCard message={message} />; return <ToolCallCard message={message} />;
} }
@@ -264,7 +356,7 @@ export function MessageBubble({ message, sessionId }: Props) {
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap"> <div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap">
{message.content} {message.content}
</div> </div>
<ActionRow message={message} sessionId={sessionId} /> <ActionRow message={message} />
</div> </div>
); );
} }
@@ -292,7 +384,7 @@ export function MessageBubble({ message, sessionId }: Props) {
)} )}
{!isStreaming && <StatsLine message={message} />} {!isStreaming && <StatsLine message={message} />}
{!isStreaming && (hasContent || hasToolCalls) && ( {!isStreaming && (hasContent || hasToolCalls) && (
<ActionRow message={message} sessionId={sessionId} /> <ActionRow message={message} />
)} )}
</div> </div>
); );

View File

@@ -1,13 +1,13 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import type { Message } from '@/api/types'; import type { Chat, Message } from '@/api/types';
import { MessageBubble } from './MessageBubble'; import { MessageBubble } from './MessageBubble';
interface Props { interface Props {
messages: Message[]; messages: Message[];
sessionId: string; sessionChats?: Chat[];
} }
export function MessageList({ messages, sessionId }: Props) { export function MessageList({ messages, sessionChats }: Props) {
const endRef = useRef<HTMLDivElement>(null); const endRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -25,7 +25,7 @@ export function MessageList({ messages, sessionId }: Props) {
return ( return (
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4"> <div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{messages.map((m) => ( {messages.map((m) => (
<MessageBubble key={m.id} message={m} sessionId={sessionId} /> <MessageBubble key={m.id} message={m} sessionChats={sessionChats} />
))} ))}
<div ref={endRef} /> <div ref={endRef} />
</div> </div>

View File

@@ -9,6 +9,13 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import { AddProjectModal } from './AddProjectModal'; import { AddProjectModal } from './AddProjectModal';
import { api } from '@/api/client'; import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
@@ -91,6 +98,8 @@ export function ProjectSidebar() {
useSidebar(); useSidebar();
const [addOpen, setAddOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
const [expanded, setExpanded] = useState<Set<string>>(() => readExpanded()); const [expanded, setExpanded] = useState<Set<string>>(() => readExpanded());
const [renamingSession, setRenamingSession] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const lastToastedError = useRef<string | null>(null); const lastToastedError = useRef<string | null>(null);
@@ -133,6 +142,28 @@ export function ProjectSidebar() {
} }
} }
async function handleArchiveSession(sessionId: string, projectId: string) {
try {
await api.sessions.archive(sessionId);
sessionEvents.emit({ type: 'session_archived', session_id: sessionId, project_id: projectId });
if (activeSession === sessionId) navigate(`/project/${projectId}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to archive session');
}
}
async function handleRenameSession(sessionId: string) {
const trimmed = renameValue.trim();
setRenamingSession(null);
if (!trimmed) return;
try {
await api.sessions.update(sessionId, { name: trimmed });
sessionEvents.emit({ type: 'session_renamed', session_id: sessionId, name: trimmed });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to rename session');
}
}
const rowCls = (active: boolean) => const rowCls = (active: boolean) =>
active ? 'bg-sidebar-accent text-sidebar-accent-foreground' : 'hover:bg-sidebar-accent/60'; active ? 'bg-sidebar-accent text-sidebar-accent-foreground' : 'hover:bg-sidebar-accent/60';
@@ -225,8 +256,25 @@ export function ProjectSidebar() {
{isExpanded && ( {isExpanded && (
<div className="ml-5 mt-0.5 space-y-0.5"> <div className="ml-5 mt-0.5 space-y-0.5">
{visible.map((s) => ( {visible.map((s) => (
<ContextMenu key={s.id}>
<ContextMenuTrigger asChild>
{renamingSession === s.id ? (
<div className={`flex items-center gap-2 rounded-md px-2 py-1 text-sm min-w-0 ${rowCls(activeSession === s.id)}`}>
<MessageSquare className="size-3.5 shrink-0 opacity-70" />
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => void handleRenameSession(s.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') void handleRenameSession(s.id);
if (e.key === 'Escape') setRenamingSession(null);
}}
className="bg-transparent border-b border-border text-sm outline-none flex-1 min-w-0"
/>
</div>
) : (
<NavLink <NavLink
key={s.id}
to={`/session/${s.id}`} to={`/session/${s.id}`}
className={`flex items-center gap-2 rounded-md px-2 py-1 text-sm min-w-0 ${rowCls(activeSession === s.id)}`} className={`flex items-center gap-2 rounded-md px-2 py-1 text-sm min-w-0 ${rowCls(activeSession === s.id)}`}
> >
@@ -236,6 +284,21 @@ export function ProjectSidebar() {
{relTime(s.updated_at)} {relTime(s.updated_at)}
</span> </span>
</NavLink> </NavLink>
)}
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => {
setRenamingSession(s.id);
setRenameValue(s.name);
}}>
Rename
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => void handleArchiveSession(s.id, p.id)}>
Archive
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))} ))}
{p.total_sessions > MAX_VISIBLE_SESSIONS && ( {p.total_sessions > MAX_VISIBLE_SESSIONS && (
<NavLink <NavLink

View File

@@ -0,0 +1,264 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ChevronRight, ChevronDown, FileText, Folder, PanelRightClose, PanelRightOpen } from 'lucide-react';
import { api } from '@/api/client';
import type { FileEntry } from '@/api/types';
import { inferLanguage } from '@/lib/attachments';
import { sessionEvents } from '@/hooks/sessionEvents';
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
import { Input } from '@/components/ui/input';
interface Props {
projectId: string;
}
const STORAGE_KEY = 'boocode.rightrail';
function basename(path: string): string {
if (!path) return '';
const parts = path.split('/');
return parts[parts.length - 1] ?? path;
}
function joinPath(parent: string, name: string): string {
if (!parent || parent === '.' || parent === '') return name;
return `${parent}/${name}`;
}
export function RightRail({ projectId }: Props) {
const [open, setOpen] = useState(() => {
try { return localStorage.getItem(`${STORAGE_KEY}.open`) !== 'false'; } catch { return true; }
});
const [filter, setFilter] = useState('');
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [cache, setCache] = useState<Map<string, FileEntry[]>>(new Map());
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
const [viewerFile, setViewerFile] = useState<{ path: string; content: string } | null>(null);
useEffect(() => {
try { localStorage.setItem(`${STORAGE_KEY}.open`, String(open)); } catch {}
}, [open]);
useEffect(() => {
let cancelled = false;
api.projects.files(projectId).then((r) => {
if (!cancelled) setFullFileList(r.files);
}).catch(() => {});
return () => { cancelled = true; };
}, [projectId]);
const loadDir = useCallback(async (dirPath: string) => {
const apiPath = dirPath === '' ? '.' : dirPath;
try {
const result = await api.projects.listDir(projectId, apiPath);
setCache((prev) => { const next = new Map(prev); next.set(dirPath, result.entries); return next; });
} catch { /* ignore */ }
}, [projectId]);
useEffect(() => {
if (!open) return;
if (!cache.has('')) void loadDir('');
}, [open, cache, loadDir]);
function toggleDir(dirPath: string) {
setExpandedDirs((prev) => {
const next = new Set(prev);
if (next.has(dirPath)) {
next.delete(dirPath);
} else {
next.add(dirPath);
if (!cache.has(dirPath)) void loadDir(dirPath);
}
return next;
});
}
async function openFile(path: string) {
try {
const result = await api.projects.viewFile(projectId, path);
setViewerFile({ path, content: result.content });
} catch { /* ignore */ }
}
// Filter results
const trimmed = filter.trim().toLowerCase();
const filterActive = trimmed.length > 0;
interface FilterResult { path: string; name: string; }
const filterResults = useMemo<FilterResult[]>(() => {
if (!filterActive) return [];
if (fullFileList) {
const filenameMatches: string[] = [];
const pathOnly: string[] = [];
for (const p of fullFileList) {
const lp = p.toLowerCase();
if (!lp.includes(trimmed)) continue;
if (basename(p).toLowerCase().includes(trimmed)) filenameMatches.push(p);
else pathOnly.push(p);
}
filenameMatches.sort((a, b) => a.localeCompare(b));
pathOnly.sort((a, b) => a.localeCompare(b));
return [...filenameMatches, ...pathOnly].slice(0, 50).map((p) => ({ path: p, name: basename(p) }));
}
return [];
}, [filterActive, trimmed, fullFileList]);
// Listen for open_file_in_browser events
useEffect(() => {
return sessionEvents.subscribe((event) => {
if (event.type !== 'open_file_in_browser') return;
if (!open) setOpen(true);
void openFile(event.path);
});
}, [open, projectId]);
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="shrink-0 border-l bg-sidebar p-2 hover:bg-muted"
aria-label="Open file browser"
>
<PanelRightOpen size={16} />
</button>
);
}
const rootEntries = cache.get('') ?? [];
return (
<>
<aside className="w-64 shrink-0 border-l bg-sidebar flex flex-col h-full overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b shrink-0">
<span className="text-xs font-medium flex-1">Files</span>
<button
type="button"
onClick={() => setOpen(false)}
className="p-1 rounded hover:bg-muted text-muted-foreground"
aria-label="Close file browser"
>
<PanelRightClose size={14} />
</button>
</div>
<div className="px-2 py-1.5 shrink-0">
<Input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter files..."
className="h-7 text-xs"
/>
</div>
<div className="flex-1 overflow-y-auto px-1 py-1">
{filterActive ? (
filterResults.length > 0 ? (
<ul className="list-none space-y-0.5">
{filterResults.map((r) => (
<li key={r.path}>
<button
type="button"
className="w-full flex items-center gap-1 px-2 py-1 text-xs rounded hover:bg-muted/60 text-left"
onClick={() => void openFile(r.path)}
>
<FileText size={12} className="text-muted-foreground shrink-0" />
<span className="font-bold truncate">{r.name}</span>
<span className="text-muted-foreground ml-1 truncate">{r.path}</span>
</button>
</li>
))}
</ul>
) : (
<div className="text-xs text-muted-foreground px-2 py-4 text-center">No matches</div>
)
) : (
<TreeLevel
parentPath=""
entries={rootEntries}
cache={cache}
expanded={expandedDirs}
depth={0}
onToggleDir={toggleDir}
onSelectFile={(path) => void openFile(path)}
/>
)}
</div>
</aside>
{viewerFile && (
<FileViewerOverlay
path={viewerFile.path}
content={viewerFile.content}
lang={inferLanguage(viewerFile.path)}
projectId={projectId}
onClose={() => setViewerFile(null)}
onNavigate={(path) => void openFile(path)}
/>
)}
</>
);
}
interface TreeLevelProps {
parentPath: string;
entries: FileEntry[];
cache: Map<string, FileEntry[]>;
expanded: Set<string>;
depth: number;
onToggleDir: (dirPath: string) => void;
onSelectFile: (path: string) => void;
}
function TreeLevel({ parentPath, entries, cache, expanded, depth, onToggleDir, onSelectFile }: TreeLevelProps) {
const sorted = useMemo(() => {
const copy = [...entries];
copy.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1;
return a.name.localeCompare(b.name);
});
return copy;
}, [entries]);
return (
<ul className="list-none">
{sorted.map((entry) => {
const fullPath = joinPath(parentPath, entry.name);
const isExpanded = entry.kind === 'dir' && expanded.has(fullPath);
return (
<li key={fullPath}>
<div
className="flex items-center gap-1 px-1 py-0.5 text-xs cursor-default rounded hover:bg-muted/60"
style={{ paddingLeft: 4 + depth * 12 }}
onClick={() => {
if (entry.kind === 'dir') onToggleDir(fullPath);
else onSelectFile(fullPath);
}}
>
{entry.kind === 'dir' ? (
isExpanded ? <ChevronDown size={10} className="shrink-0" /> : <ChevronRight size={10} className="shrink-0" />
) : (
<span className="w-[10px] shrink-0" />
)}
{entry.kind === 'dir' ? (
<Folder size={12} className="text-muted-foreground shrink-0" />
) : (
<FileText size={12} className="text-muted-foreground shrink-0" />
)}
<span className="truncate">{entry.name}</span>
</div>
{entry.kind === 'dir' && isExpanded && cache.has(fullPath) && (
<TreeLevel
parentPath={fullPath}
entries={cache.get(fullPath) ?? []}
cache={cache}
expanded={expanded}
depth={depth + 1}
onToggleDir={onToggleDir}
onSelectFile={onSelectFile}
/>
)}
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,155 @@
import { useState } from 'react';
import { MessageSquare, Send, ChevronDown, ChevronRight } from 'lucide-react';
import type { Chat } from '@/api/types';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
interface Props {
sessionId: string;
projectId: string;
chats: Chat[];
onOpenChat: (chatId: string) => void;
onSend: (content: string) => void;
onReopenChat: (chatId: string) => Promise<void>;
}
function relTime(iso: string): string {
const now = Date.now();
const t = Date.parse(iso);
if (Number.isNaN(t)) return '';
const sec = Math.max(0, Math.floor((now - t) / 1000));
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
return `${day}d ago`;
}
export function SessionLandingPage({
chats,
onOpenChat,
onSend,
onReopenChat,
}: Props) {
const [composerValue, setComposerValue] = useState('');
const [showClosed, setShowClosed] = useState(false);
const openChats = chats
.filter((c) => c.status === 'open')
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
const closedChats = chats
.filter((c) => c.status === 'closed')
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
function handleSend() {
const text = composerValue.trim();
if (!text) return;
onSend(text);
setComposerValue('');
}
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
{/* Open chats */}
{openChats.length > 0 && (
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-2">Open chats</h3>
<ul className="divide-y rounded-md border">
{openChats.map((chat) => (
<li key={chat.id}>
<button
type="button"
onClick={() => onOpenChat(chat.id)}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-muted/50 text-left"
>
<MessageSquare className="size-3.5 opacity-70 shrink-0" />
<span className="truncate text-sm flex-1">
{chat.name ?? 'New chat'}
</span>
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
{relTime(chat.updated_at)}
</span>
</button>
</li>
))}
</ul>
</div>
)}
{/* Closed chats */}
{closedChats.length > 0 && (
<div>
<button
type="button"
onClick={() => setShowClosed(!showClosed)}
className="flex items-center gap-1 text-xs font-medium text-muted-foreground mb-2 hover:text-foreground"
>
{showClosed ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
Closed chats ({closedChats.length})
</button>
{showClosed && (
<ul className="divide-y rounded-md border">
{closedChats.map((chat) => (
<li key={chat.id}>
<button
type="button"
onClick={() => void onReopenChat(chat.id)}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-muted/50 text-left"
>
<MessageSquare className="size-3.5 opacity-40 shrink-0" />
<span className="truncate text-sm flex-1 text-muted-foreground">
{chat.name ?? 'New chat'}
</span>
<span className="text-xs text-muted-foreground shrink-0">
Reopen
</span>
</button>
</li>
))}
</ul>
)}
</div>
)}
{openChats.length === 0 && closedChats.length === 0 && (
<div className="text-sm text-muted-foreground py-8 text-center">
No chats yet. Type below to start a conversation.
</div>
)}
</div>
{/* Composer */}
<div className="border-t px-4 py-3 flex items-end gap-2 shrink-0">
<Textarea
value={composerValue}
onChange={(e) => setComposerValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSend();
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder="Start a new chat..."
rows={2}
className="resize-none min-h-[52px] max-h-[160px]"
/>
<Button
onClick={handleSend}
disabled={!composerValue.trim()}
size="icon-lg"
aria-label="Send"
>
<Send />
</Button>
</div>
</div>
);
}

View File

@@ -1,13 +1,18 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import type { DragEvent } from 'react'; import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
import { Plus } from 'lucide-react'; import { toast } from 'sonner';
import { usePanes } from '@/hooks/usePanes'; import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
import type { FileBrowserPaneState, Pane, PaneKind } from '@/api/types'; import type { Chat, WorkspacePane } from '@/api/types';
import { PaneTab } from '@/components/PaneTab';
import { PaneShell } from '@/components/panes/PaneShell';
import { ChatPane } from '@/components/panes/ChatPane'; import { ChatPane } from '@/components/panes/ChatPane';
import { FileBrowserPane } from '@/components/panes/FileBrowserPane'; import { ChatTabBar } from '@/components/ChatTabBar';
import { SessionLandingPage } from '@/components/SessionLandingPage';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface Props { interface Props {
@@ -16,324 +21,341 @@ interface Props {
} }
const MAX_PANES = 5; const MAX_PANES = 5;
const STORAGE_KEY = 'boocode.workspace.panes';
function PaneSkeleton() { function generateId(): string {
return ( return crypto.randomUUID();
<div className="flex flex-col h-full">
<div className="flex items-center border-b border-border bg-muted/20 h-8" />
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">
Loading panes...
</div>
</div>
);
} }
function PaneError({ function emptyPane(): WorkspacePane {
message, return { id: generateId(), kind: 'empty', chatIds: [], activeChatIdx: -1 };
onRetry, }
}: {
message: string; function chatPane(chatId: string): WorkspacePane {
onRetry: () => void | Promise<void>; return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
}) { }
return (
<div className="flex flex-col h-full items-center justify-center gap-2 text-sm"> function loadPanes(sessionId: string): WorkspacePane[] | null {
<span className="text-destructive">{message}</span> try {
<button const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`);
type="button" if (!raw) return null;
onClick={() => void onRetry()} const parsed = JSON.parse(raw) as WorkspacePane[];
className="text-xs underline text-muted-foreground hover:text-foreground" if (!Array.isArray(parsed) || parsed.length === 0) return null;
> return parsed;
Retry } catch {
</button> return null;
</div> }
); }
function savePanes(sessionId: string, panes: WorkspacePane[]): void {
try {
localStorage.setItem(`${STORAGE_KEY}.${sessionId}`, JSON.stringify(panes));
} catch { /* quota or disabled */ }
} }
export function Workspace({ sessionId, projectId }: Props) { export function Workspace({ sessionId, projectId }: Props) {
const { panes, loading, error, create, update, remove, refresh } = const [panes, setPanes] = useState<WorkspacePane[]>(() => {
usePanes(sessionId); return loadPanes(sessionId) ?? [emptyPane()];
const [activeId, setActiveId] = useState<string | null>(null); });
const draggingIdRef = useRef<string | null>(null); const [activePaneIdx, setActivePaneIdx] = useState(0);
const [chats, setChats] = useState<Chat[]>([]);
const chatsRef = useRef<Chat[]>([]);
chatsRef.current = chats;
// Keep latest panes in a ref so the event-bus subscription doesn't need
// to re-subscribe whenever the list changes (which would race with rapid
// updates).
const panesRef = useRef<Pane[] | null>(null);
panesRef.current = panes;
// Default active: first pane (and reset if the active one disappears)
useEffect(() => { useEffect(() => {
if (!panes || panes.length === 0) { let cancelled = false;
if (activeId !== null) setActiveId(null); api.chats.listForSession(sessionId).then((list) => {
return; if (cancelled) return;
setChats(list);
const openChat = list.find((c) => c.status === 'open');
if (openChat) {
setPanes((prev) => {
if (prev.length === 1 && prev[0]!.kind === 'empty') {
return [chatPane(openChat.id)];
} }
if (!panes.some((p) => p.id === activeId)) { return prev;
setActiveId(panes[0]!.id); });
} }
}, [panes, activeId]); }).catch(() => {});
return () => { cancelled = true; };
}, [sessionId]);
// Tracks an in-flight create() call so rapid open_file_in_browser events useEffect(() => {
// don't race to each spawn a new file_browser pane. While a create is in savePanes(sessionId, panes);
// progress the subsequent events wait for it and update the same pane. }, [sessionId, panes]);
const creatingRef = useRef<{ id: string; promise: Promise<string> } | null>(
null
);
// Subscribe to open_file_in_browser events: focus an existing file_browser
// pane (updating its open_file) or spawn one if room is available.
useEffect(() => { useEffect(() => {
return sessionEvents.subscribe((event) => { return sessionEvents.subscribe((event) => {
if (event.type !== 'open_file_in_browser') return; if (event.type === 'chat_created' && event.session_id === sessionId) {
void (async () => { setChats((prev) => [event.chat, ...prev]);
// If a create is already in flight, wait for it to finish then update
// the newly-created pane rather than spawning a second one.
if (creatingRef.current) {
const { id: pendingId, promise } = creatingRef.current;
const resolvedId = await promise;
const targetId = resolvedId || pendingId;
const current = panesRef.current;
const fb = current?.find((p) => p.id === targetId);
const nextState: FileBrowserPaneState = {
...(fb?.kind === 'file_browser' ? fb.state : {}),
open_file: event.path,
};
await update(targetId, { state: nextState });
setActiveId(targetId);
return;
} }
if (event.type === 'chat_updated') {
const current = panesRef.current; setChats((prev) => prev.map((c) =>
if (!current) return; c.id === event.chat_id ? { ...c, name: event.name, updated_at: event.updated_at } : c
const fb = current.find( ));
(p): p is Pane & { kind: 'file_browser' } =>
p.kind === 'file_browser'
);
if (fb) {
const nextState: FileBrowserPaneState = {
...fb.state,
open_file: event.path,
};
await update(fb.id, { state: nextState });
setActiveId(fb.id);
} else if (current.length < MAX_PANES) {
// Reserve the slot immediately so concurrent events see the flag.
const createPromise = (async (): Promise<string> => {
const newPane = await create({ kind: 'file_browser' });
return newPane.id;
})();
// Use a stable object; id is filled in once resolved.
const entry: { id: string; promise: Promise<string> } = {
id: '',
promise: createPromise,
};
creatingRef.current = entry;
try {
const newId = await createPromise;
entry.id = newId;
const nextState: FileBrowserPaneState = {
open_file: event.path,
filter: '',
expanded_dirs: [],
};
await update(newId, { state: nextState });
setActiveId(newId);
} finally {
if (creatingRef.current === entry) {
creatingRef.current = null;
} }
if (event.type === 'chat_closed') {
setChats((prev) => prev.map((c) =>
c.id === event.chat_id ? { ...c, status: 'closed' as const } : c
));
removeChatFromPanes(event.chat_id);
} }
}
})();
}); });
}, [create, update]); }, [sessionId]);
const handleClose = useCallback( function removeChatFromPanes(chatId: string) {
async (id: string) => { setPanes((prev) => prev.map((p) => {
try { const idx = p.chatIds.indexOf(chatId);
await remove(id); if (idx < 0) return p;
} catch { const nextIds = p.chatIds.filter((id) => id !== chatId);
/* error surfaced via hook state */ if (nextIds.length === 0) {
return { ...p, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 };
}
const nextActiveIdx = Math.min(p.activeChatIdx, nextIds.length - 1);
return {
...p,
chatIds: nextIds,
activeChatIdx: nextActiveIdx,
chatId: nextIds[nextActiveIdx],
};
}));
} }
},
[remove]
);
const handleSplit = useCallback( const openChatInPane = useCallback((paneIdx: number, chatId: string) => {
async (afterIdx: number, kind: PaneKind) => { setPanes((prev) => {
const current = panesRef.current; const next = [...prev];
if (!current || current.length >= MAX_PANES) return; const pane = next[paneIdx]!;
try { const existing = pane.chatIds.indexOf(chatId);
const created = await create({ kind, position: afterIdx + 1 }); if (existing >= 0) {
setActiveId(created.id); next[paneIdx] = { ...pane, kind: 'chat', chatId, activeChatIdx: existing };
} catch { } else {
/* error surfaced via hook state */ const newIds = [...pane.chatIds, chatId];
next[paneIdx] = {
...pane,
kind: 'chat',
chatId,
chatIds: newIds,
activeChatIdx: newIds.length - 1,
};
} }
}, return next;
[create] });
); setActivePaneIdx(paneIdx);
const handleCloseOthers = useCallback(
async (id: string) => {
const current = panesRef.current;
if (!current) return;
const targets = current.filter((p) => p.id !== id).map((p) => p.id);
for (const targetId of targets) {
try {
await remove(targetId);
} catch {
// Stop on first failure to avoid cascading errors.
return;
}
}
},
[remove]
);
const handleCloseToRight = useCallback(
async (idx: number) => {
const current = panesRef.current;
if (!current) return;
const targets = current.slice(idx + 1).map((p) => p.id);
for (const targetId of targets) {
try {
await remove(targetId);
} catch {
return;
}
}
},
[remove]
);
const handleCloseAll = useCallback(async () => {
const current = panesRef.current;
if (!current) return;
const targets = current.map((p) => p.id);
for (const targetId of targets) {
try {
await remove(targetId);
} catch {
return;
}
}
}, [remove]);
const handleAdd = useCallback(async () => {
const current = panesRef.current;
if (current && current.length >= MAX_PANES) return;
try {
const created = await create({ kind: 'chat' });
setActiveId(created.id);
} catch {
/* error surfaced via hook state */
}
}, [create]);
const handleDragStart = useCallback(
(id: string) => (e: DragEvent<HTMLDivElement>) => {
draggingIdRef.current = id;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', id);
},
[]
);
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}, []); }, []);
const handleDrop = useCallback( const switchTab = useCallback((paneIdx: number, tabIdx: number) => {
(targetIdx: number) => async (e: DragEvent<HTMLDivElement>) => { setPanes((prev) => {
e.preventDefault(); const next = [...prev];
const draggedId = const pane = next[paneIdx]!;
draggingIdRef.current || e.dataTransfer.getData('text/plain'); const chatId = pane.chatIds[tabIdx];
draggingIdRef.current = null; if (!chatId) return prev;
if (!draggedId) return; next[paneIdx] = { ...pane, chatId, activeChatIdx: tabIdx };
const current = panesRef.current; return next;
if (!current) return; });
const draggedIdx = current.findIndex((p) => p.id === draggedId); }, []);
if (draggedIdx < 0 || draggedIdx === targetIdx) return;
try {
await update(draggedId, { position: targetIdx });
} catch {
/* error surfaced via hook state */
}
},
[update]
);
if (loading && !panes) return <PaneSkeleton />; const removeTab = useCallback((paneIdx: number, chatId: string) => {
if (error && !panes) return <PaneError message={error} onRetry={refresh} />; setPanes((prev) => {
if (!panes) return <PaneSkeleton />; const next = [...prev];
const pane = next[paneIdx]!;
const nextIds = pane.chatIds.filter((id) => id !== chatId);
if (nextIds.length === 0) {
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
} else {
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
next[paneIdx] = {
...pane,
chatIds: nextIds,
activeChatIdx: nextActiveIdx,
chatId: nextIds[nextActiveIdx],
};
}
return next;
});
}, []);
const createChat = useCallback(async (paneIdx: number) => {
try {
const chat = await api.chats.create(sessionId);
setChats((prev) => [chat, ...prev]);
sessionEvents.emit({ type: 'chat_created', chat, session_id: sessionId });
openChatInPane(paneIdx, chat.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create chat');
}
}, [sessionId, openChatInPane]);
const closeChat = useCallback(async (chatId: string) => {
try {
await api.chats.update(chatId, { status: 'closed' });
setChats((prev) => prev.map((c) =>
c.id === chatId ? { ...c, status: 'closed' as const } : c
));
removeChatFromPanes(chatId);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to close chat');
}
}, []);
const deleteChat = useCallback(async (chatId: string) => {
try {
await api.chats.remove(chatId);
setChats((prev) => prev.filter((c) => c.id !== chatId));
removeChatFromPanes(chatId);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete chat');
}
}, []);
const renameChat = useCallback(async (chatId: string, name: string) => {
try {
await api.chats.update(chatId, { name });
setChats((prev) => prev.map((c) =>
c.id === chatId ? { ...c, name } : c
));
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to rename chat');
}
}, []);
const showLandingPage = useCallback((paneIdx: number) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined };
return next;
});
}, []);
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => {
if (kind === 'terminal') {
toast('Terminal panes coming in BooTerm');
return;
}
if (kind === 'agent') {
toast('Agent panes coming in BooCoder');
return;
}
setPanes((prev) => {
if (prev.length >= MAX_PANES) {
toast.error(`Maximum ${MAX_PANES} panes`);
return prev;
}
const next = [...prev, emptyPane()];
setActivePaneIdx(next.length - 1);
return next;
});
}, []);
const removePane = useCallback((idx: number) => {
setPanes((prev) => {
if (prev.length <= 1) return prev;
const next = prev.filter((_, i) => i !== idx);
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
return next;
});
}, []);
const handleLandingSend = useCallback(async (paneIdx: number, content: string) => {
try {
const chat = await api.chats.create(sessionId);
setChats((prev) => [chat, ...prev]);
sessionEvents.emit({ type: 'chat_created', chat, session_id: sessionId });
openChatInPane(paneIdx, chat.id);
await api.messages.send(chat.id, content);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to send');
}
}, [sessionId, openChatInPane]);
function chatsForPane(pane: WorkspacePane): Chat[] {
return pane.chatIds
.map((id) => chats.find((c) => c.id === id))
.filter((c): c is Chat => c !== undefined);
}
return ( return (
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
<div <div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
className="flex items-center border-b border-border bg-muted/20" <DropdownMenu>
role="tablist" <DropdownMenuTrigger asChild>
>
{panes.map((pane, idx) => (
<PaneTab
key={pane.id}
pane={pane}
isActive={pane.id === activeId}
onClick={() => setActiveId(pane.id)}
onClose={() => void handleClose(pane.id)}
onSplit={(kind) => void handleSplit(idx, kind)}
onCloseOthers={() => void handleCloseOthers(pane.id)}
onCloseToRight={() => void handleCloseToRight(idx)}
onCloseAll={() => void handleCloseAll()}
onDragStart={handleDragStart(pane.id)}
onDragOver={handleDragOver}
onDrop={handleDrop(idx)}
/>
))}
<button <button
type="button" type="button"
onClick={() => void handleAdd()}
disabled={panes.length >= MAX_PANES} disabled={panes.length >= MAX_PANES}
className={cn( className={cn(
'p-1.5 ml-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground', 'flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted',
panes.length >= MAX_PANES && 'opacity-40 cursor-not-allowed hover:bg-transparent' panes.length >= MAX_PANES && 'opacity-40 cursor-not-allowed hover:bg-transparent'
)} )}
aria-label="Add pane"
> >
<Plus size={14} /> <PanelRight size={14} />
Split
</button> </button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={() => addSplitPane('chat')}>
<MessageSquare size={14} /> Chat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}>
<Terminal size={14} /> Terminal
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => addSplitPane('agent')}>
<Bot size={14} /> Agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
{panes.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">
No panes. Click + to add one.
</div>
) : (
<div <div
className="flex-1 grid min-h-0" className="flex-1 grid min-h-0"
style={{ style={{
gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))`, gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))`,
}} }}
> >
{panes.map((pane) => ( {panes.map((pane, idx) => (
<PaneShell <div
key={pane.id} key={pane.id}
pane={pane} className={cn(
onClose={() => void handleClose(pane.id)} 'flex flex-col h-full min-h-0 border-r border-border last:border-r-0',
idx === activePaneIdx && 'ring-1 ring-inset ring-ring/20'
)}
onClick={() => setActivePaneIdx(idx)}
> >
{pane.kind === 'chat' ? ( <ChatTabBar
<ChatPane sessionId={sessionId} />
) : (
<FileBrowserPane
pane={pane} pane={pane}
tabs={chatsForPane(pane)}
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
onRemoveTab={(chatId) => removeTab(idx, chatId)}
onNewChat={() => void createChat(idx)}
onShowHistory={() => showLandingPage(idx)}
onRename={renameChat}
onClose={closeChat}
onDelete={deleteChat}
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
/>
<div className="flex-1 min-h-0 overflow-hidden">
{pane.kind === 'chat' && pane.chatId ? (
<ChatPane sessionId={sessionId} chatId={pane.chatId} projectId={projectId} sessionChats={chats} />
) : (
<SessionLandingPage
sessionId={sessionId}
projectId={projectId} projectId={projectId}
onStateChange={(state) => chats={chats}
void update(pane.id, { state }) onOpenChat={(chatId) => openChatInPane(idx, chatId)}
} onSend={(content) => void handleLandingSend(idx, content)}
onReopenChat={async (chatId) => {
await api.chats.update(chatId, { status: 'open' });
setChats((prev) => prev.map((c) =>
c.id === chatId ? { ...c, status: 'open' as const } : c
));
openChatInPane(idx, chatId);
}}
/> />
)} )}
</PaneShell> </div>
</div>
))} ))}
</div> </div>
)}
</div> </div>
); );
} }

View File

@@ -1,19 +1,30 @@
import { useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronDown, Square, X } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api/client'; import { api } from '@/api/client';
import { useSessionStream } from '@/hooks/useSessionStream'; import { useSessionStream } from '@/hooks/useSessionStream';
import { MessageList } from '@/components/MessageList'; import { MessageList } from '@/components/MessageList';
import { ChatInput } from '@/components/ChatInput'; import { ChatInput } from '@/components/ChatInput';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface Props { interface Props {
sessionId: string; sessionId: string;
chatId: string;
projectId: string;
sessionChats?: import('@/api/types').Chat[];
} }
export function ChatPane({ sessionId }: Props) { export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props) {
const stream = useSessionStream(sessionId); const stream = useSessionStream(sessionId);
const lastErrorRef = useRef<string | null>(null); const lastErrorRef = useRef<string | null>(null);
const [queue, setQueue] = useState<string[]>([]);
const processingRef = useRef(false);
// Surface stream errors via toast — matches Session.tsx behavior.
useEffect(() => { useEffect(() => {
if (stream.error && stream.error !== lastErrorRef.current) { if (stream.error && stream.error !== lastErrorRef.current) {
lastErrorRef.current = stream.error; lastErrorRef.current = stream.error;
@@ -24,16 +35,130 @@ export function ChatPane({ sessionId }: Props) {
} }
}, [stream.error]); }, [stream.error]);
async function handleSend(content: string) { const chatMessages = stream.messages.filter((m) => m.chat_id === chatId);
await api.messages.send(sessionId, content); const streaming = chatMessages.some((m) => m.status === 'streaming');
// Auto-send next queued message when streaming completes
useEffect(() => {
if (streaming || queue.length === 0 || processingRef.current) return;
processingRef.current = true;
const next = queue[0]!;
setQueue((prev) => prev.slice(1));
api.messages.send(chatId, next)
.catch((err) => toast.error(err instanceof Error ? err.message : 'queue send failed'))
.finally(() => { processingRef.current = false; });
}, [streaming, queue, chatId]);
const handleSend = useCallback(async (content: string) => {
const trimmed = content.trim();
if (!trimmed) return;
if (trimmed === '/compact') {
try {
await api.chats.compact(chatId);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'compact failed');
}
return;
}
if (streaming) {
setQueue((prev) => [...prev, trimmed]);
return;
}
await api.messages.send(chatId, trimmed);
}, [chatId, streaming]);
async function handleStop() {
try {
await api.chats.stop(chatId);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'stop failed');
}
} }
const streaming = stream.messages.some((m) => m.status === 'streaming'); const handleForceSend = useCallback(async (content: string) => {
const trimmed = content.trim();
if (!trimmed) return;
try {
await api.chats.forceSend(chatId, trimmed);
setQueue([]);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'force send failed');
}
}, [chatId]);
function removeQueued(idx: number) {
setQueue((prev) => prev.filter((_, i) => i !== idx));
}
async function forceSendQueued(idx: number) {
const msg = queue[idx];
if (!msg) return;
setQueue((prev) => prev.filter((_, i) => i !== idx));
try {
await api.chats.forceSend(chatId, msg);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'force send failed');
}
}
return ( return (
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
<MessageList messages={stream.messages} sessionId={sessionId} /> <MessageList messages={chatMessages} sessionChats={sessionChats} />
<ChatInput disabled={streaming} onSend={handleSend} />
{/* Queued messages */}
{queue.length > 0 && (
<div className="px-4 py-1 border-t space-y-1">
{queue.map((msg, i) => (
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/30 rounded px-2 py-1">
<span className="font-medium shrink-0">Queued:</span>
<span className="truncate flex-1">{msg}</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-0.5 hover:bg-muted rounded shrink-0"
aria-label="Queued message options"
>
<ChevronDown size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => { /* default: queued, nothing to do */ }}>
Send when done
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void forceSendQueued(i)}>
Force send now
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
type="button"
onClick={() => removeQueued(i)}
className="p-0.5 hover:bg-muted rounded shrink-0"
aria-label="Cancel queued message"
>
<X size={12} />
</button>
</div>
))}
</div>
)}
{/* Stop button when streaming */}
{streaming && (
<div className="flex justify-center py-1 border-t">
<button
type="button"
onClick={() => void handleStop()}
className="flex items-center gap-1.5 text-xs px-3 py-1 rounded-full border hover:bg-muted text-muted-foreground hover:text-foreground"
>
<Square size={10} className="fill-current" />
Stop generating
</button>
</div>
)}
<ChatInput disabled={false} projectId={projectId} onSend={handleSend} onForceSend={streaming ? handleForceSend : undefined} />
</div> </div>
); );
} }

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { KeyboardEvent } from 'react'; import type { KeyboardEvent } from 'react';
import { ChevronRight, ChevronDown, FileText, Folder, X } from 'lucide-react'; import { Check, ChevronRight, ChevronDown, Copy, FileText, Folder, X } from 'lucide-react';
import { codeToHtml } from 'shiki';
import { api, ApiError } from '@/api/client'; import { api, ApiError } from '@/api/client';
import type { import type {
FileBrowserPaneState, FileBrowserPaneState,
@@ -8,7 +9,8 @@ import type {
Pane, Pane,
ViewFileResult, ViewFileResult,
} from '@/api/types'; } from '@/api/types';
import { CodeBlock } from '@/components/CodeBlock'; import { inferLanguage } from '@/lib/attachments';
import { sessionEvents } from '@/hooks/sessionEvents';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface Props { interface Props {
@@ -17,49 +19,113 @@ interface Props {
onStateChange: (state: FileBrowserPaneState) => void; onStateChange: (state: FileBrowserPaneState) => void;
} }
const LANG_BY_EXT: Record<string, string> = { const SHIKI_THEME = 'github-dark';
ts: 'typescript',
tsx: 'tsx',
js: 'javascript',
jsx: 'jsx',
mjs: 'javascript',
cjs: 'javascript',
py: 'python',
go: 'go',
rs: 'rust',
rb: 'ruby',
java: 'java',
c: 'c',
h: 'c',
cpp: 'cpp',
hpp: 'cpp',
cs: 'csharp',
php: 'php',
sh: 'bash',
bash: 'bash',
zsh: 'bash',
yaml: 'yaml',
yml: 'yaml',
json: 'json',
toml: 'toml',
md: 'markdown',
markdown: 'markdown',
sql: 'sql',
dockerfile: 'dockerfile',
html: 'html',
htm: 'html',
css: 'css',
scss: 'scss',
};
function deriveLang(filePath: string): string | undefined { function splitShikiLines(html: string): string[] {
// basename const match = html.match(/<code[^>]*>([\s\S]*)<\/code>/);
const base = filePath.split('/').pop() ?? filePath; if (!match) return [];
if (base.toLowerCase() === 'dockerfile') return 'dockerfile'; const inner = match[1]!;
const dot = base.lastIndexOf('.'); const lines = inner.split(/(?=<span class="line">)/);
if (dot < 0 || dot === base.length - 1) return undefined; return lines.filter(l => l.trim().length > 0);
const ext = base.slice(dot + 1).toLowerCase(); }
return LANG_BY_EXT[ext];
interface FileViewerProps {
code: string;
lang: string | null;
selectedLines: Set<number>;
onLineClick: (lineNo: number, shiftKey: boolean) => void;
}
function FileViewer({ code, lang, selectedLines, onLineClick }: FileViewerProps) {
const [copied, setCopied] = useState(false);
const [lineHtmls, setLineHtmls] = useState<string[] | null>(null);
useEffect(() => {
let cancelled = false;
if (!lang) {
setLineHtmls(null);
return;
}
(async () => {
try {
const result = await codeToHtml(code, { lang, theme: SHIKI_THEME });
if (cancelled) return;
const lines = splitShikiLines(result);
setLineHtmls(lines.length > 0 ? lines : null);
} catch (err) {
console.warn('shiki failed', err);
if (!cancelled) setLineHtmls(null);
}
})();
return () => {
cancelled = true;
};
}, [code, lang]);
async function copy() {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {
/* ignore */
}
}
const plainLines = code.split('\n');
const totalLines = lineHtmls ? lineHtmls.length : plainLines.length;
return (
<div className="text-sm font-mono">
<div className="flex items-center justify-between px-2 py-1 border-b text-xs text-muted-foreground">
<span className="font-mono">{lang || 'code'}</span>
<button
type="button"
onClick={() => void copy()}
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground"
aria-label="Copy code"
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
<div className="overflow-x-auto">
{Array.from({ length: totalLines }, (_, i) => {
const lineNo = i + 1;
const isSelected = selectedLines.has(lineNo);
return (
<div
key={lineNo}
className={cn(
'flex',
isSelected && 'bg-blue-500/10'
)}
>
<button
type="button"
className="shrink-0 w-[3ch] text-right pr-2 text-xs text-muted-foreground select-none cursor-pointer hover:text-foreground"
style={{ fontVariantNumeric: 'tabular-nums' }}
onClick={(e) => onLineClick(lineNo, e.shiftKey)}
>
{lineNo}
</button>
{lineHtmls ? (
<div
className="flex-1 min-w-0 text-xs leading-relaxed [&>.line]:!bg-transparent"
// eslint-disable-next-line react/no-danger -- Shiki generates sanitized HTML spans, not user content
dangerouslySetInnerHTML={{ __html: lineHtmls[i] ?? '' }}
/>
) : (
<span className="flex-1 min-w-0 text-xs leading-relaxed whitespace-pre">
{plainLines[i] ?? ''}
</span>
)}
</div>
);
})}
</div>
</div>
);
} }
function basename(path: string): string { function basename(path: string): string {
@@ -230,6 +296,26 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
}; };
}, []); }, []);
// Full file list fetched once on mount for filter mode (covers unexpanded dirs)
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const result = await api.projects.files(projectId);
if (!cancelled) setFullFileList(result.files);
} catch {
// Silently ignore; filter will fall back to cache-based list
}
})();
return () => {
cancelled = true;
};
// Intentionally run once per mount (projectId is stable per pane)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
// Directory cache: dirPath -> entries // Directory cache: dirPath -> entries
const [cache, setCache] = useState<Map<string, FileEntry[]>>(new Map()); const [cache, setCache] = useState<Map<string, FileEntry[]>>(new Map());
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set()); const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set());
@@ -380,11 +466,43 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
const trimmedFilter = filterDraft.trim(); const trimmedFilter = filterDraft.trim();
const filterActive = trimmedFilter.length > 0; const filterActive = trimmedFilter.length > 0;
const filterResults = useMemo<FlatEntry[]>(() => {
interface FilterResult {
path: string;
name: string;
}
const filterResults = useMemo<FilterResult[]>(() => {
if (!filterActive) return []; if (!filterActive) return [];
const needle = trimmedFilter.toLowerCase(); const needle = trimmedFilter.toLowerCase();
return flattenedAll.filter((e) => e.path.toLowerCase().includes(needle));
}, [filterActive, trimmedFilter, flattenedAll]); if (fullFileList !== null) {
// Use complete file list from API; rank filename matches above path-only matches
const filenameMatches: string[] = [];
const pathOnlyMatches: string[] = [];
for (const p of fullFileList) {
const lp = p.toLowerCase();
if (!lp.includes(needle)) continue;
const bn = basename(p).toLowerCase();
if (bn.includes(needle)) {
filenameMatches.push(p);
} else {
pathOnlyMatches.push(p);
}
}
filenameMatches.sort((a, b) => a.localeCompare(b));
pathOnlyMatches.sort((a, b) => a.localeCompare(b));
return [...filenameMatches, ...pathOnlyMatches]
.slice(0, 50)
.map((p) => ({ path: p, name: basename(p) }));
}
// Fallback: use cache-based flat list (only loaded directories, files only)
return flattenedAll
.filter((e) => e.kind === 'file' && e.path.toLowerCase().includes(needle))
.slice(0, 50)
.map((e) => ({ path: e.path, name: e.name }));
}, [filterActive, trimmedFilter, fullFileList, flattenedAll]);
// Keyboard navigation // Keyboard navigation
const [highlightedPath, setHighlightedPath] = useState<string | null>(null); const [highlightedPath, setHighlightedPath] = useState<string | null>(null);
@@ -401,7 +519,38 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
}, [highlightedPath, filterActive, filterResults, flattenedVisible]); }, [highlightedPath, filterActive, filterResults, flattenedVisible]);
function onTreeKeyDown(e: KeyboardEvent<HTMLDivElement>) { function onTreeKeyDown(e: KeyboardEvent<HTMLDivElement>) {
const list = filterActive ? filterResults : flattenedVisible; if (filterActive) {
if (filterResults.length === 0) return;
const idx = highlightedPath
? filterResults.findIndex((entry) => entry.path === highlightedPath)
: -1;
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = idx < 0 ? 0 : Math.min(filterResults.length - 1, idx + 1);
const target = filterResults[next];
if (target) setHighlightedPath(target.path);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
const next = idx <= 0 ? 0 : idx - 1;
const target = filterResults[next];
if (target) setHighlightedPath(target.path);
return;
}
if (e.key === 'Enter') {
if (idx < 0) return;
const target = filterResults[idx];
if (!target) return;
e.preventDefault();
// Filter results are always files (API returns only files)
selectFile(target.path);
}
return;
}
// Tree mode: use flattenedVisible which has kind info
const list = flattenedVisible;
if (list.length === 0) return; if (list.length === 0) return;
const idx = highlightedPath const idx = highlightedPath
? list.findIndex((entry) => entry.path === highlightedPath) ? list.findIndex((entry) => entry.path === highlightedPath)
@@ -434,6 +583,31 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
} }
} }
// Line selection state
const [selectedLines, setSelectedLines] = useState<Set<number>>(new Set());
const [selectionAnchor, setSelectionAnchor] = useState<number | null>(null);
function handleLineClick(lineNo: number, shiftKey: boolean) {
if (shiftKey && selectionAnchor !== null) {
const start = Math.min(selectionAnchor, lineNo);
const end = Math.max(selectionAnchor, lineNo);
const range = new Set<number>();
for (let i = start; i <= end; i++) range.add(i);
setSelectedLines(range);
} else {
setSelectedLines(prev => {
const next = new Set(prev);
if (next.has(lineNo)) {
next.delete(lineNo);
} else {
next.add(lineNo);
}
return next;
});
setSelectionAnchor(lineNo);
}
}
// Viewer state // Viewer state
const [viewer, setViewer] = useState<{ const [viewer, setViewer] = useState<{
path: string; path: string;
@@ -490,6 +664,45 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
}; };
}, [openFile, projectId]); }, [openFile, projectId]);
// Clear line selection when open file changes
useEffect(() => {
setSelectedLines(new Set());
setSelectionAnchor(null);
}, [openFile]);
// Compute selection range for the floating action bar (loop avoids call-stack limit on spread)
let selectionMin = 0;
let selectionMax = 0;
if (selectedLines.size > 0) {
for (const n of selectedLines) {
if (selectionMin === 0 || n < selectionMin) selectionMin = n;
if (n > selectionMax) selectionMax = n;
}
}
function handleAttachLines() {
if (!openFile || !viewer?.result || selectedLines.size === 0) return;
const min = selectionMin;
const max = selectionMax;
const selectedContent = viewer.result.content
.split('\n')
.slice(min - 1, max)
.join('\n');
sessionEvents.emit({
type: 'attach_chat_file',
attachment: {
kind: 'lines',
filename: openFile,
language: inferLanguage(openFile) ?? null,
content: selectedContent,
range: [min, max],
source: 'line-select',
},
});
setSelectedLines(new Set());
setSelectionAnchor(null);
}
// Root errors / loading // Root errors / loading
const rootEntries = cache.get(''); const rootEntries = cache.get('');
const rootLoading = loadingDirs.has('') && !rootEntries; const rootLoading = loadingDirs.has('') && !rootEntries;
@@ -534,8 +747,7 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
</li> </li>
) : ( ) : (
filterResults.map((entry) => { filterResults.map((entry) => {
const isActive = const isActive = openFile === entry.path;
entry.kind === 'file' && openFile === entry.path;
const isHighlight = highlightedPath === entry.path; const isHighlight = highlightedPath === entry.path;
return ( return (
<li key={entry.path}> <li key={entry.path}>
@@ -547,19 +759,14 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
)} )}
onClick={() => { onClick={() => {
setHighlightedPath(entry.path); setHighlightedPath(entry.path);
if (entry.kind === 'dir') {
toggleDir(entry.path);
} else {
selectFile(entry.path); selectFile(entry.path);
}
}} }}
> >
{entry.kind === 'dir' ? (
<Folder size={12} className="text-muted-foreground shrink-0" />
) : (
<FileText size={12} className="text-muted-foreground shrink-0" /> <FileText size={12} className="text-muted-foreground shrink-0" />
)} <span className="truncate">
<span className="truncate">{entry.path}</span> <span className="font-bold">{entry.name}</span>
<span className="text-muted-foreground ml-1">{entry.path}</span>
</span>
</div> </div>
</li> </li>
); );
@@ -606,7 +813,7 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
<X size={12} /> <X size={12} />
</button> </button>
</div> </div>
<div className="flex-1 min-h-0 overflow-y-auto"> <div className="flex-1 min-h-0 overflow-y-auto relative">
{viewer?.state === 'loading' && ( {viewer?.state === 'loading' && (
<div className="text-xs text-muted-foreground px-2 py-1.5"> <div className="text-xs text-muted-foreground px-2 py-1.5">
Loading... Loading...
@@ -619,12 +826,33 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
)} )}
{viewer?.state === 'ready' && viewer.result && ( {viewer?.state === 'ready' && viewer.result && (
<div className="p-2"> <div className="p-2">
{selectedLines.size > 0 && (
<div className="sticky top-0 z-10 bg-muted border-b border-border flex items-center justify-between px-2 py-1 mb-2 rounded-t">
<span className="text-xs text-muted-foreground">
{selectedLines.size === 1
? `Attach line ${selectionMin} to chat`
: `Attach lines ${selectionMin}${selectionMax} to chat`}
</span>
<button
type="button"
className="text-xs font-medium text-primary hover:underline"
onClick={handleAttachLines}
>
Attach
</button>
</div>
)}
{viewer.result.truncated && ( {viewer.result.truncated && (
<div className="text-[11px] text-muted-foreground mb-1 px-2 py-1 rounded bg-muted/40 border border-border"> <div className="text-[11px] text-muted-foreground mb-1 px-2 py-1 rounded bg-muted/40 border border-border">
Showing first {viewer.result.bytes_returned} bytes; file is {viewer.result.total_bytes} bytes total. Showing first {viewer.result.bytes_returned} bytes; file is {viewer.result.total_bytes} bytes total.
</div> </div>
)} )}
<CodeBlock code={viewer.result.content} lang={deriveLang(openFile)} /> <FileViewer
code={viewer.result.content}
lang={inferLanguage(openFile)}
selectedLines={selectedLines}
onLineClick={handleLineClick}
/>
</div> </div>
)} )}
</div> </div>

View File

@@ -2,7 +2,8 @@
// across hooks (e.g. AI rename arriving via WS in the session view needs to // across hooks (e.g. AI rename arriving via WS in the session view needs to
// also refresh the sidebar's session list). // also refresh the sidebar's session list).
import type { Project, Session } from '@/api/types'; import type { Chat, Project, Session } from '@/api/types';
import type { Attachment } from '@/lib/attachments';
export interface SessionRenamedEvent { export interface SessionRenamedEvent {
type: 'session_renamed'; type: 'session_renamed';
@@ -51,6 +52,37 @@ export interface OpenFileInBrowserEvent {
path: string; // project-relative path: string; // project-relative
} }
export interface AttachChatFileEvent {
type: 'attach_chat_file';
attachment: Omit<Attachment, 'id'>;
}
export interface SessionArchivedEvent {
type: 'session_archived';
session_id: string;
project_id: string;
}
export interface ChatCreatedEvent {
type: 'chat_created';
chat: Chat;
session_id: string;
}
export interface ChatUpdatedEvent {
type: 'chat_updated';
chat_id: string;
session_id: string;
name: string | null;
updated_at: string;
}
export interface ChatClosedEvent {
type: 'chat_closed';
chat_id: string;
session_id: string;
}
export type SessionEvent = export type SessionEvent =
| SessionRenamedEvent | SessionRenamedEvent
| ProjectCreatedEvent | ProjectCreatedEvent
@@ -59,7 +91,12 @@ export type SessionEvent =
| SessionDeletedEvent | SessionDeletedEvent
| SessionUpdatedEvent | SessionUpdatedEvent
| SessionLoadedEvent | SessionLoadedEvent
| OpenFileInBrowserEvent; | OpenFileInBrowserEvent
| AttachChatFileEvent
| SessionArchivedEvent
| ChatCreatedEvent
| ChatUpdatedEvent
| ChatClosedEvent;
type Listener = (event: SessionEvent) => void; type Listener = (event: SessionEvent) => void;
const listeners = new Set<Listener>(); const listeners = new Set<Listener>();

View File

@@ -1,149 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '@/api/client';
import type { Pane, PaneCreateRequest, PaneState, PaneUpdateRequest } from '@/api/types';
export function usePanes(sessionId: string | undefined): {
panes: Pane[] | null;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
create: (body: PaneCreateRequest) => Promise<Pane>;
update: (id: string, body: PaneUpdateRequest) => Promise<void>;
remove: (id: string) => Promise<void>;
} {
const [panes, setPanes] = useState<Pane[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Pending debounced state PATCHes: pane id -> latest PaneState
const pendingState = useRef<Map<string, PaneState>>(new Map());
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const refresh = useCallback(async () => {
if (!sessionId) {
setPanes(null);
return;
}
setLoading(true);
try {
const { panes: list } = await api.panes.getForSession(sessionId);
setPanes(list);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'pane operation failed');
} finally {
setLoading(false);
}
}, [sessionId]);
const flushPendingState = useCallback(async () => {
if (debounceTimer.current !== null) {
clearTimeout(debounceTimer.current);
debounceTimer.current = null;
}
const updates = Array.from(pendingState.current.entries());
pendingState.current.clear();
if (updates.length === 0) return;
try {
await Promise.all(updates.map(([id, state]) => api.panes.update(id, { state })));
} catch (err) {
setError(err instanceof Error ? err.message : 'pane state PATCH failed');
// server truth may diverge from optimistic local state; resync
void refresh();
}
}, [refresh]);
// Fetch on mount / sessionId change; preserve previous list while reloading
// (loading=true but panes stays non-null after first fetch to avoid flash)
useEffect(() => {
void refresh();
}, [refresh]);
// Flush debounced PATCHes on unmount
useEffect(() => {
return () => {
flushPendingState();
};
}, [flushPendingState]);
const create = useCallback(
async (body: PaneCreateRequest): Promise<Pane> => {
if (!sessionId) throw new Error('no session');
const created = await api.panes.create(sessionId, body);
await refresh();
return created;
},
[sessionId, refresh]
);
const update = useCallback(
async (id: string, body: PaneUpdateRequest): Promise<void> => {
if (body.state !== undefined && body.position === undefined) {
const nextState = body.state;
// Optimistic local update
setPanes((prev) => {
if (!prev) return prev;
let changed = false;
const next = prev.map((pane) => {
if (pane.id !== id) return pane;
changed = true;
// Narrow via discriminated union to satisfy TypeScript
if (pane.kind === 'chat') {
return { ...pane, state: nextState as typeof pane.state };
}
if (pane.kind === 'file_browser') {
return { ...pane, state: nextState as typeof pane.state };
}
return pane;
});
return changed ? next : prev;
});
// Coalesce: last state wins within debounce window
pendingState.current.set(id, nextState);
if (debounceTimer.current !== null) {
clearTimeout(debounceTimer.current);
}
debounceTimer.current = setTimeout(() => {
debounceTimer.current = null;
flushPendingState();
}, 300);
} else {
// position involved — fire immediately
try {
await api.panes.update(id, body);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'pane operation failed');
throw err;
}
}
},
[refresh, flushPendingState]
);
const remove = useCallback(
async (id: string): Promise<void> => {
// Optimistic remove — capture snapshot inside functional updater to avoid stale closure
let snapshot: Pane[] | null = null;
setPanes((prev) => {
snapshot = prev;
return prev ? prev.filter((p) => p.id !== id) : prev;
});
try {
await api.panes.remove(id);
await refresh();
} catch (err) {
// Rollback to the truly-most-recent value captured above
setPanes(snapshot);
setError(err instanceof Error ? err.message : 'pane operation failed');
throw err;
}
},
[refresh]
);
return { panes, loading, error, refresh, create, update, remove };
}

View File

@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { api } from '@/api/client'; import { api } from '@/api/client';
import type { Project } from '@/api/types'; import type { Project } from '@/api/types';
import { sessionEvents } from './sessionEvents';
export function useProjects() { export function useProjects() {
const [projects, setProjects] = useState<Project[] | null>(null); const [projects, setProjects] = useState<Project[] | null>(null);
@@ -33,7 +32,6 @@ export function useProjects() {
const remove = useCallback( const remove = useCallback(
async (id: string) => { async (id: string) => {
await api.projects.remove(id); await api.projects.remove(id);
sessionEvents.emit({ type: 'project_deleted', project_id: id });
await refresh(); await refresh();
}, },
[refresh] [refresh]

View File

@@ -19,8 +19,10 @@ function applyFrame(state: State, frame: WsFrame): State {
const newMsg: Message = { const newMsg: Message = {
id: frame.message_id, id: frame.message_id,
session_id: '', session_id: '',
chat_id: frame.chat_id ?? '',
role: frame.role, role: frame.role,
content: '', content: '',
kind: 'message',
tool_calls: null, tool_calls: null,
tool_results: null, tool_results: null,
status: 'streaming', status: 'streaming',
@@ -71,8 +73,10 @@ function applyFrame(state: State, frame: WsFrame): State {
const newMsg: Message = { const newMsg: Message = {
id: frame.tool_message_id, id: frame.tool_message_id,
session_id: '', session_id: '',
chat_id: frame.chat_id ?? '',
role: 'tool', role: 'tool',
content: '', content: '',
kind: 'message',
tool_calls: null, tool_calls: null,
tool_results: { tool_results: {
tool_call_id: frame.tool_call_id, tool_call_id: frame.tool_call_id,
@@ -115,7 +119,6 @@ function applyFrame(state: State, frame: WsFrame): State {
}; };
} }
case 'session_renamed': { case 'session_renamed': {
// Side-effect, not state — dispatch via event bus to other hooks.
sessionEvents.emit({ sessionEvents.emit({
type: 'session_renamed', type: 'session_renamed',
session_id: frame.session_id, session_id: frame.session_id,
@@ -123,6 +126,16 @@ function applyFrame(state: State, frame: WsFrame): State {
}); });
return state; return state;
} }
case 'chat_renamed': {
sessionEvents.emit({
type: 'chat_updated',
chat_id: frame.chat_id,
session_id: '',
name: frame.name,
updated_at: new Date().toISOString(),
});
return state;
}
case 'error': { case 'error': {
const next = frame.message_id const next = frame.message_id
? state.messages.map((m) => ? state.messages.map((m) =>

View File

@@ -140,15 +140,38 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
case 'open_file_in_browser': case 'open_file_in_browser':
// Consumed by Workspace (T7); no sidebar state change needed. // Consumed by Workspace (T7); no sidebar state change needed.
return prev; return prev;
case 'attach_chat_file':
return prev;
case 'session_archived': {
let changed = false;
const projects = prev.projects.map((p) => {
if (p.id !== event.project_id) return p;
const recent = p.recent_sessions.filter((s) => s.id !== event.session_id);
if (recent.length === p.recent_sessions.length) return p;
changed = true;
return {
...p,
recent_sessions: recent,
total_sessions: Math.max(0, p.total_sessions - 1),
};
});
return changed ? { ...prev, projects } : prev;
}
case 'chat_created':
case 'chat_updated':
case 'chat_closed':
return prev;
} }
} }
// One bus subscription for the lifetime of the module. Events arriving // One bus subscription for the lifetime of the module. Events arriving
// before the initial fetch resolves are dropped; the eventual fetch // before the initial fetch resolves are dropped; the eventual fetch
// result is the source of truth. // result is the source of truth.
// Guard prevents duplicate listeners during Vite HMR reloads.
const G = globalThis as Record<string, unknown>;
if (!G.__boocode_sidebar_subscribed) {
G.__boocode_sidebar_subscribed = true;
sessionEvents.subscribe((event) => { sessionEvents.subscribe((event) => {
// session_loaded updates activeSessionProjectId regardless of whether
// sharedData is populated yet — notify so subscribers can re-read.
if (event.type === 'session_loaded') { if (event.type === 'session_loaded') {
activeSession = { session_id: event.session_id, project_id: event.project_id }; activeSession = { session_id: event.session_id, project_id: event.project_id };
notify(); notify();
@@ -160,6 +183,7 @@ sessionEvents.subscribe((event) => {
sharedData = next; sharedData = next;
notify(); notify();
}); });
}
interface Snapshot { interface Snapshot {
data: SidebarResponse | null; data: SidebarResponse | null;

View File

@@ -0,0 +1,44 @@
export type Attachment = {
id: string;
kind: 'file' | 'lines' | 'paste';
filename: string;
language: string | null;
content: string;
range?: [number, number];
source: '@' | 'line-select' | 'drop' | 'paste';
};
export const LANG_MAP: Record<string, string> = {
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
mjs: 'javascript', cjs: 'javascript',
py: 'python', go: 'go', rs: 'rust', rb: 'ruby', java: 'java',
c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', hpp: 'cpp', cs: 'csharp',
php: 'php', sh: 'bash', bash: 'bash', zsh: 'bash',
yml: 'yaml', yaml: 'yaml', json: 'json', toml: 'toml',
md: 'markdown', markdown: 'markdown', sql: 'sql', dockerfile: 'dockerfile',
html: 'html', htm: 'html', css: 'css', scss: 'scss',
};
export function inferLanguage(filename: string): string | null {
const base = filename.split('/').pop() ?? filename;
if (base.toLowerCase() === 'dockerfile') return 'dockerfile';
const m = base.match(/\.([^.]+)$/);
return m ? (LANG_MAP[m[1]!.toLowerCase()] ?? null) : null;
}
export function flattenToMessage(attachments: Attachment[], text: string): string {
if (attachments.length === 0) return text;
const blocks = attachments.map(a => {
const fence = '```' + (a.language ?? '');
let header: string;
if (a.kind === 'lines') {
header = `// from: ${a.filename}:${a.range?.[0] ?? '?'}-${a.range?.[1] ?? '?'}`;
} else if (a.kind === 'paste') {
header = `// from: pasted text (${a.content.split('\n').length} lines)`;
} else {
header = `// from: ${a.filename}`;
}
return `${fence}\n${header}\n${a.content}\n\`\`\``;
});
return [...blocks, text].filter(Boolean).join('\n\n');
}

View File

@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom'; import { Link, useNavigate, useParams } from 'react-router-dom';
import { Plus, MessageSquare, Trash2 } from 'lucide-react'; import { Plus, MessageSquare, Trash2, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api/client'; import { api } from '@/api/client';
import type { Project as ProjectType } from '@/api/types'; import type { Project as ProjectType, Session } from '@/api/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
import { useSessions } from '@/hooks/useSessions'; import { useSessions } from '@/hooks/useSessions';
@@ -14,6 +14,8 @@ export function Project() {
const { sessions, create, remove } = useSessions(id); const { sessions, create, remove } = useSessions(id);
const [project, setProject] = useState<ProjectType | null>(null); const [project, setProject] = useState<ProjectType | null>(null);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [archivedSessions, setArchivedSessions] = useState<Session[] | null>(null);
const [showArchived, setShowArchived] = useState(false);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
@@ -23,6 +25,26 @@ export function Project() {
.catch(() => {}); .catch(() => {});
}, [id]); }, [id]);
useEffect(() => {
if (!id) return;
api.sessions.listForProject(id, 'archived')
.then(setArchivedSessions)
.catch(() => {});
}, [id]);
useEffect(() => {
return sessionEvents.subscribe((event) => {
if (event.type === 'session_archived' && event.project_id === id) {
setArchivedSessions((prev) => {
if (!prev) return prev;
const session = sessions?.find((s) => s.id === event.session_id);
if (!session) return prev;
return [{ ...session, status: 'archived' as const }, ...prev];
});
}
});
}, [id, sessions]);
async function handleNew() { async function handleNew() {
if (!id || creating) return; if (!id || creating) return;
setCreating(true); setCreating(true);
@@ -35,6 +57,17 @@ export function Project() {
} }
} }
async function handleUnarchive(sessionId: string) {
try {
await api.sessions.unarchive(sessionId);
setArchivedSessions((prev) =>
prev ? prev.filter((s) => s.id !== sessionId) : prev
);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to unarchive');
}
}
return ( return (
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<header className="border-b px-6 py-3 flex items-center justify-between"> <header className="border-b px-6 py-3 flex items-center justify-between">
@@ -52,7 +85,7 @@ export function Project() {
</Button> </Button>
</header> </header>
<div className="flex-1 overflow-y-auto px-6 py-4"> <div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
{sessions === null && ( {sessions === null && (
<div className="text-sm text-muted-foreground">Loading</div> <div className="text-sm text-muted-foreground">Loading</div>
)} )}
@@ -97,6 +130,61 @@ export function Project() {
))} ))}
</ul> </ul>
)} )}
{/* Archived sessions */}
{archivedSessions && archivedSessions.length > 0 && (
<div>
<button
type="button"
onClick={() => setShowArchived(!showArchived)}
className="flex items-center gap-1 text-xs font-medium text-muted-foreground mb-2 hover:text-foreground"
>
{showArchived ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
Closed sessions ({archivedSessions.length})
</button>
{showArchived && (
<ul className="divide-y rounded-md border">
{archivedSessions.map((s) => (
<li key={s.id} className="flex items-center justify-between px-3 py-2 hover:bg-muted/50">
<div className="flex-1 flex items-center gap-2 min-w-0">
<MessageSquare className="size-3.5 opacity-40 shrink-0" />
<span className="truncate text-sm text-muted-foreground">{s.name}</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label="Reopen session"
onClick={() => void handleUnarchive(s.id)}
>
<RotateCcw size={14} />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Delete session permanently"
onClick={async () => {
try {
await api.sessions.remove(s.id);
setArchivedSessions((prev) =>
prev ? prev.filter((a) => a.id !== s.id) : prev
);
} catch (err) {
toast.error(
err instanceof Error ? err.message : 'failed to delete'
);
}
}}
>
<Trash2 />
</Button>
</div>
</li>
))}
</ul>
)}
</div>
)}
</div> </div>
</div> </div>
); );