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:
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
147
apps/server/src/routes/chats.ts
Normal file
147
apps/server/src/routes/chats.ts
Normal 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;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 $$;
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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' }),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
41
apps/web/src/components/AttachmentChip.tsx
Normal file
41
apps/web/src/components/AttachmentChip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
apps/web/src/components/AttachmentPreviewModal.tsx
Normal file
37
apps/web/src/components/AttachmentPreviewModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
201
apps/web/src/components/ChatTabBar.tsx
Normal file
201
apps/web/src/components/ChatTabBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
apps/web/src/components/FileMentionPopover.tsx
Normal file
145
apps/web/src/components/FileMentionPopover.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
241
apps/web/src/components/FileViewerOverlay.tsx
Normal file
241
apps/web/src/components/FileViewerOverlay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
264
apps/web/src/components/RightRail.tsx
Normal file
264
apps/web/src/components/RightRail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
apps/web/src/components/SessionLandingPage.tsx
Normal file
155
apps/web/src/components/SessionLandingPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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.
|
||||||
sessionEvents.subscribe((event) => {
|
// Guard prevents duplicate listeners during Vite HMR reloads.
|
||||||
// session_loaded updates activeSessionProjectId regardless of whether
|
const G = globalThis as Record<string, unknown>;
|
||||||
// sharedData is populated yet — notify so subscribers can re-read.
|
if (!G.__boocode_sidebar_subscribed) {
|
||||||
|
G.__boocode_sidebar_subscribed = true;
|
||||||
|
sessionEvents.subscribe((event) => {
|
||||||
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();
|
||||||
@@ -159,7 +182,8 @@ sessionEvents.subscribe((event) => {
|
|||||||
if (next === sharedData) return;
|
if (next === sharedData) return;
|
||||||
sharedData = next;
|
sharedData = next;
|
||||||
notify();
|
notify();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
interface Snapshot {
|
interface Snapshot {
|
||||||
data: SidebarResponse | null;
|
data: SidebarResponse | null;
|
||||||
|
|||||||
44
apps/web/src/lib/attachments.ts
Normal file
44
apps/web/src/lib/attachments.ts
Normal 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');
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user