Compare commits
3 Commits
v1.3.0-cha
...
v1.5.0-ref
| Author | SHA1 | Date | |
|---|---|---|---|
| 9436a81b5f | |||
| 59fe6f0522 | |||
| eabef7671e |
15
CLAUDE.md
15
CLAUDE.md
@@ -62,8 +62,7 @@ Key patterns:
|
|||||||
- **`hooks/sessionEvents.ts`** — Module-singleton event bus (Set of listeners). Used for cross-component communication: session renames, file-open events, attachment dispatch. 9 event types in the discriminated union. When adding a new event type to the `SessionEvent` union, you must also add a case to the `applyEvent` switch in `useSidebar.ts` (even if it's a no-op `return prev`).
|
- **`hooks/sessionEvents.ts`** — Module-singleton event bus (Set of listeners). Used for cross-component communication: session renames, file-open events, attachment dispatch. 9 event types in the discriminated union. When adding a new event type to the `SessionEvent` union, you must also add a case to the `applyEvent` switch in `useSidebar.ts` (even if it's a no-op `return prev`).
|
||||||
- **`hooks/useSessionStream.ts`** — WebSocket per session, `applyFrame` reducer builds message list from streaming frames.
|
- **`hooks/useSessionStream.ts`** — WebSocket per session, `applyFrame` reducer builds message list from streaming frames.
|
||||||
- **`hooks/useUserEvents.ts`** — Single app-level WS to `/api/ws/user` with exponential backoff reconnect. Forwards frames onto the sessionEvents bus.
|
- **`hooks/useUserEvents.ts`** — Single app-level WS to `/api/ws/user` with exponential backoff reconnect. Forwards frames onto the sessionEvents bus.
|
||||||
- **`hooks/usePanes.ts`** — Per-session pane CRUD with 300ms debounced state PATCH (Map-based coalescing for last-write-wins).
|
- **`hooks/useSidebar.ts`** — Module-singleton with Set<setState> subscriber pattern; one bus subscription guarded by `globalThis.__boocode_sidebar_subscribed` for HMR safety. Every new `SessionEvent` type needs a `case` in the `applyEvent` switch (no-op `return prev` is fine).
|
||||||
- **`hooks/useSidebar.ts`** — Module-singleton with Set<setState> subscriber pattern. Handles all sessionEvent types to keep sidebar in sync.
|
|
||||||
- **`api/client.ts`** — Centralized typed fetch wrapper. All endpoints under `api.*` namespace.
|
- **`api/client.ts`** — Centralized typed fetch wrapper. All endpoints under `api.*` namespace.
|
||||||
|
|
||||||
### Data flow for chat
|
### Data flow for chat
|
||||||
@@ -77,13 +76,15 @@ Key patterns:
|
|||||||
|
|
||||||
### Multi-pane workspace
|
### Multi-pane workspace
|
||||||
|
|
||||||
Sessions hold 1–5 panes (chat or file_browser). `Workspace.tsx` renders tab strip + CSS grid layout. Pane state persisted in `session_panes` table (position + JSONB state). Tab reorder via native HTML5 drag events.
|
Sessions hold 1–5 panes (chat / empty / placeholder terminal+agent). Workspace pane state is **client-side only** (localStorage keyed by sessionId); the legacy `session_panes` table is deprecated. Each chat lives in at most one pane; tab strip is per-pane and tracks `chatIds[]` + `activeChatIdx`. Sessions 1:N chats; chats own messages. Tab reorder via native HTML5 drag events.
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
PostgreSQL 16. Tables: `projects`, `sessions`, `messages`, `settings`, `session_panes`. Schema applied idempotently on startup via `applySchema()`. Use `clock_timestamp()` (not `NOW()`) inside transactions for accurate per-statement timestamps.
|
PostgreSQL 16. Tables: `projects`, `sessions`, `chats`, `messages`, `settings`, `session_panes` (deprecated). Schema applied idempotently on startup via `applySchema()`. Use `clock_timestamp()` (not `NOW()`) inside transactions. CHECK constraints in place: `projects_status_chk` ('open'|'archived'), `sessions_status_chk` (same), `chats_status_chk` (same), `messages_role_chk`, `messages_status_chk` — keep in sync with the `*_STATUSES` const arrays in `apps/server/src/types/api.ts`.
|
||||||
|
|
||||||
Position-shift pattern for panes: negate-and-restore to avoid UNIQUE(session_id, position) collisions during reorder/insert/delete. Sentinel value -100 for the moving pane.
|
Schema CHECK migration order when renaming allowed values: (1) `ALTER TABLE ... DROP CONSTRAINT IF EXISTS <system_name>` (inline `CREATE TABLE` checks get `<table>_<column>_check`), (2) `UPDATE` rows to new values, (3) wrap new constraint ADD in `DO $$ ... pg_constraint` guard — that block is the only way to get `ADD CONSTRAINT IF NOT EXISTS`.
|
||||||
|
|
||||||
|
Position-shift pattern for panes (legacy `session_panes` table): negate-and-restore to avoid UNIQUE(session_id, position) collisions during reorder/insert/delete. Sentinel value -100 for the moving pane.
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
@@ -92,8 +93,10 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
|
|||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
- Sam reviews all diffs and commits manually. Do not commit unless explicitly asked.
|
- Sam reviews all diffs and commits manually. Do not commit unless explicitly asked.
|
||||||
- Deploy: `cd /opt/boocode && docker compose build --no-cache boocode && docker compose up -d`
|
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
|
||||||
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
||||||
|
- Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client.
|
||||||
|
- Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present).
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ async function main() {
|
|||||||
cancelInference: async (sessionId, chatId) => {
|
cancelInference: async (sessionId, chatId) => {
|
||||||
return inference.cancel(sessionId, chatId);
|
return inference.cancel(sessionId, chatId);
|
||||||
},
|
},
|
||||||
|
hasActiveInference: (chatId) => inference.hasActive(chatId),
|
||||||
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
|
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
|
||||||
broker.publish(sessionId, {
|
broker.publish(sessionId, {
|
||||||
type: 'message_started',
|
type: 'message_started',
|
||||||
@@ -144,6 +145,9 @@ async function main() {
|
|||||||
process.on('SIGINT', () => void shutdown('SIGINT'));
|
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||||
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||||
|
|
||||||
|
// Bound to 0.0.0.0 intentionally. Public access goes through Caddy → Authelia.
|
||||||
|
// Direct Tailscale access (100.114.205.53:9500) is unauthenticated by design;
|
||||||
|
// the threat model treats Tailnet membership as the trust boundary.
|
||||||
await app.listen({ port: config.PORT, host: config.HOST });
|
await app.listen({ port: config.PORT, host: config.HOST });
|
||||||
app.log.info(`boocode server listening on http://${config.HOST}:${config.PORT}`);
|
app.log.info(`boocode server listening on http://${config.HOST}:${config.PORT}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ const PatchBody = z.object({
|
|||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ForkBody = z.object({
|
||||||
|
message_id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(200).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export function registerChatRoutes(
|
export function registerChatRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
sql: Sql,
|
sql: Sql,
|
||||||
@@ -181,6 +186,78 @@ export function registerChatRoutes(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/chats/:id/fork',
|
||||||
|
async (req, reply) => {
|
||||||
|
const parsed = ForkBody.safeParse(req.body ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceRows = await sql<Chat[]>`
|
||||||
|
SELECT id, session_id, name, status, created_at, updated_at
|
||||||
|
FROM chats WHERE id = ${req.params.id}
|
||||||
|
`;
|
||||||
|
if (sourceRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'chat not found' };
|
||||||
|
}
|
||||||
|
const source = sourceRows[0]!;
|
||||||
|
|
||||||
|
const targetRows = await sql<{ created_at: string; status: string }[]>`
|
||||||
|
SELECT created_at, status FROM messages
|
||||||
|
WHERE chat_id = ${source.id} AND id = ${parsed.data.message_id}
|
||||||
|
`;
|
||||||
|
if (targetRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'message not found in chat' };
|
||||||
|
}
|
||||||
|
const target = targetRows[0]!;
|
||||||
|
if (target.status !== 'complete') {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'can only fork from completed messages' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const newName = parsed.data.name ?? `${source.name ?? 'Chat'} (fork)`;
|
||||||
|
|
||||||
|
const newChat = await sql.begin(async (tx) => {
|
||||||
|
const [chat] = await tx<Chat[]>`
|
||||||
|
INSERT INTO chats (session_id, name, status)
|
||||||
|
VALUES (${source.session_id}, ${newName}, 'open')
|
||||||
|
RETURNING id, session_id, name, status, created_at, updated_at
|
||||||
|
`;
|
||||||
|
await tx`
|
||||||
|
INSERT INTO messages (
|
||||||
|
session_id, chat_id, role, content, kind, tool_calls, tool_results,
|
||||||
|
status, tokens_used, ctx_used, ctx_max, started_at, finished_at,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
${source.session_id}, ${chat!.id}, role, content, kind,
|
||||||
|
tool_calls, tool_results, status,
|
||||||
|
tokens_used, ctx_used, ctx_max, started_at, finished_at,
|
||||||
|
clock_timestamp() + (
|
||||||
|
ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) * INTERVAL '1 microsecond'
|
||||||
|
)
|
||||||
|
FROM messages
|
||||||
|
WHERE chat_id = ${source.id}
|
||||||
|
AND created_at <= ${target.created_at}::timestamptz
|
||||||
|
AND status = 'complete'
|
||||||
|
`;
|
||||||
|
return chat!;
|
||||||
|
});
|
||||||
|
|
||||||
|
broker.publishUser('default', {
|
||||||
|
type: 'chat_created',
|
||||||
|
chat: newChat,
|
||||||
|
session_id: source.session_id,
|
||||||
|
});
|
||||||
|
reply.code(201);
|
||||||
|
return newChat;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
app.get<{ Params: { id: string } }>(
|
app.get<{ Params: { id: string } }>(
|
||||||
'/api/chats/:id/messages',
|
'/api/chats/:id/messages',
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface MessageHandlers {
|
|||||||
) => void;
|
) => void;
|
||||||
publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void;
|
publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void;
|
||||||
cancelInference: (sessionId: string, chatId: string) => Promise<boolean>;
|
cancelInference: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||||
|
hasActiveInference: (chatId: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerMessageRoutes(
|
export function registerMessageRoutes(
|
||||||
@@ -156,6 +157,53 @@ export function registerMessageRoutes(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string; message_id: string } }>(
|
||||||
|
'/api/chats/:id/messages/:message_id',
|
||||||
|
async (req, reply) => {
|
||||||
|
const { id: chatId, message_id: messageId } = req.params;
|
||||||
|
|
||||||
|
const chatRows = await sql<Chat[]>`
|
||||||
|
SELECT id, session_id FROM chats WHERE id = ${chatId}
|
||||||
|
`;
|
||||||
|
if (chatRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'chat not found' };
|
||||||
|
}
|
||||||
|
const chat = chatRows[0]!;
|
||||||
|
|
||||||
|
if (handlers.hasActiveInference(chatId)) {
|
||||||
|
reply.code(409);
|
||||||
|
return { error: 'chat is currently streaming; stop it first' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedIds = await sql.begin(async (tx) => {
|
||||||
|
const deletedRows = await tx<{ id: string }[]>`
|
||||||
|
DELETE FROM messages
|
||||||
|
WHERE chat_id = ${chatId}
|
||||||
|
AND created_at >= (
|
||||||
|
SELECT created_at FROM messages
|
||||||
|
WHERE id = ${messageId} AND chat_id = ${chatId}
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
if (deletedRows.length > 0) {
|
||||||
|
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
|
||||||
|
}
|
||||||
|
return deletedRows.map((r) => r.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deletedIds.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'message not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers.publishMessagesDeleted(chat.session_id, chatId, deletedIds);
|
||||||
|
|
||||||
|
reply.code(204);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
app.post<{ Params: { id: string } }>(
|
app.post<{ Params: { id: string } }>(
|
||||||
'/api/chats/:id/compact',
|
'/api/chats/:id/compact',
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
|
|||||||
@@ -134,7 +134,15 @@ export function registerSessionRoutes(
|
|||||||
reply.code(404);
|
reply.code(404);
|
||||||
return { error: 'session not found' };
|
return { error: 'session not found' };
|
||||||
}
|
}
|
||||||
return rows[0];
|
const session = rows[0]!;
|
||||||
|
if (name !== undefined) {
|
||||||
|
broker.publishUser('default', {
|
||||||
|
type: 'session_renamed',
|
||||||
|
session_id: session.id,
|
||||||
|
name: session.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return session;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS projects (
|
|||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
path TEXT NOT NULL UNIQUE,
|
path TEXT NOT NULL UNIQUE,
|
||||||
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
added_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||||
last_session_id UUID
|
last_session_id UUID
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -12,8 +12,8 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
model TEXT NOT NULL,
|
model TEXT NOT NULL,
|
||||||
system_prompt TEXT NOT NULL DEFAULT '',
|
system_prompt TEXT NOT NULL DEFAULT '',
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id, updated_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id, updated_at DESC);
|
||||||
@@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
|||||||
tool_results JSONB,
|
tool_results JSONB,
|
||||||
status TEXT NOT NULL DEFAULT 'complete',
|
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 clock_timestamp()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
|
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
|
||||||
@@ -60,14 +60,9 @@ CREATE TABLE IF NOT EXISTS session_panes (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_session_panes_session ON session_panes (session_id);
|
CREATE INDEX IF NOT EXISTS idx_session_panes_session ON session_panes (session_id);
|
||||||
|
|
||||||
-- Backfill: ensure every session has at least one pane (default Chat).
|
-- v1.4: backfill removed. Pane layout is client-side (localStorage) since v1.2-batch4.
|
||||||
-- Idempotent: skipped on subsequent runs because session_panes rows already exist.
|
-- The CREATE TABLE above is retained for additive-schema discipline; drop is a
|
||||||
INSERT INTO session_panes (session_id, position, kind, state)
|
-- future destructive migration.
|
||||||
SELECT s.id, 0, 'chat', '{}'::jsonb
|
|
||||||
FROM sessions s
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM session_panes p WHERE p.session_id = s.id
|
|
||||||
);
|
|
||||||
|
|
||||||
-- v1.2: sessions.status (open | archived)
|
-- v1.2: sessions.status (open | archived)
|
||||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
|
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
|
||||||
|
|||||||
@@ -348,39 +348,27 @@ async function executeToolCall(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAssistantTurn(
|
interface TurnArgs {
|
||||||
ctx: InferenceContext,
|
sessionId: string;
|
||||||
sessionId: string,
|
chatId: string;
|
||||||
chatId: string,
|
assistantMessageId: string;
|
||||||
assistantMessageId: string,
|
depth: number;
|
||||||
depth: number,
|
signal: AbortSignal | undefined;
|
||||||
signal?: AbortSignal
|
}
|
||||||
): Promise<void> {
|
|
||||||
if (depth > MAX_TOOL_LOOP_DEPTH) {
|
|
||||||
await ctx.sql`
|
|
||||||
UPDATE messages
|
|
||||||
SET status = 'failed',
|
|
||||||
content = ${'tool loop depth exceeded'},
|
|
||||||
finished_at = clock_timestamp()
|
|
||||||
WHERE id = ${assistantMessageId}
|
|
||||||
`;
|
|
||||||
ctx.publish(sessionId, {
|
|
||||||
type: 'error',
|
|
||||||
message_id: assistantMessageId,
|
|
||||||
chat_id: chatId,
|
|
||||||
error: 'tool loop depth exceeded',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
interface StreamPhaseState {
|
||||||
if (!loaded) {
|
accumulated: string;
|
||||||
ctx.log.warn({ sessionId }, 'inference: session or project missing');
|
startedAt: string | null;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
const { session, project, history } = loaded;
|
async function executeStreamPhase(
|
||||||
const projectRoot = await resolveProjectRoot(project.path);
|
ctx: InferenceContext,
|
||||||
const messages = buildMessagesPayload(session, project, history);
|
args: TurnArgs,
|
||||||
|
session: Session,
|
||||||
|
messages: OpenAiMessage[],
|
||||||
|
state: StreamPhaseState
|
||||||
|
): Promise<StreamResult> {
|
||||||
|
const { sessionId, chatId, assistantMessageId, signal } = args;
|
||||||
|
|
||||||
const startedRow = await ctx.sql<{ started_at: string }[]>`
|
const startedRow = await ctx.sql<{ started_at: string }[]>`
|
||||||
UPDATE messages
|
UPDATE messages
|
||||||
@@ -388,7 +376,7 @@ async function runAssistantTurn(
|
|||||||
WHERE id = ${assistantMessageId}
|
WHERE id = ${assistantMessageId}
|
||||||
RETURNING started_at
|
RETURNING started_at
|
||||||
`;
|
`;
|
||||||
const startedAt = startedRow[0]?.started_at ?? null;
|
state.startedAt = startedRow[0]?.started_at ?? null;
|
||||||
|
|
||||||
ctx.publish(sessionId, {
|
ctx.publish(sessionId, {
|
||||||
type: 'message_started',
|
type: 'message_started',
|
||||||
@@ -397,7 +385,6 @@ async function runAssistantTurn(
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
});
|
});
|
||||||
|
|
||||||
let accumulated = '';
|
|
||||||
let pendingFlushTimer: NodeJS.Timeout | null = null;
|
let pendingFlushTimer: NodeJS.Timeout | null = null;
|
||||||
let flushPromise: Promise<unknown> = Promise.resolve();
|
let flushPromise: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
@@ -406,7 +393,7 @@ async function runAssistantTurn(
|
|||||||
clearTimeout(pendingFlushTimer);
|
clearTimeout(pendingFlushTimer);
|
||||||
pendingFlushTimer = null;
|
pendingFlushTimer = null;
|
||||||
}
|
}
|
||||||
const snapshot = accumulated;
|
const snapshot = state.accumulated;
|
||||||
flushPromise = flushPromise.then(() =>
|
flushPromise = flushPromise.then(() =>
|
||||||
ctx.sql`UPDATE messages SET content = ${snapshot} WHERE id = ${assistantMessageId}`
|
ctx.sql`UPDATE messages SET content = ${snapshot} WHERE id = ${assistantMessageId}`
|
||||||
);
|
);
|
||||||
@@ -420,15 +407,14 @@ async function runAssistantTurn(
|
|||||||
}, DB_FLUSH_INTERVAL_MS);
|
}, DB_FLUSH_INTERVAL_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
let result: StreamResult;
|
|
||||||
try {
|
try {
|
||||||
result = await streamCompletion(
|
return await streamCompletion(
|
||||||
ctx,
|
ctx,
|
||||||
session.model,
|
session.model,
|
||||||
messages,
|
messages,
|
||||||
true,
|
true,
|
||||||
(delta) => {
|
(delta) => {
|
||||||
accumulated += delta;
|
state.accumulated += delta;
|
||||||
ctx.publish(sessionId, {
|
ctx.publish(sessionId, {
|
||||||
type: 'delta',
|
type: 'delta',
|
||||||
message_id: assistantMessageId,
|
message_id: assistantMessageId,
|
||||||
@@ -440,12 +426,22 @@ async function runAssistantTurn(
|
|||||||
},
|
},
|
||||||
signal
|
signal
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} finally {
|
||||||
if (pendingFlushTimer) {
|
if (pendingFlushTimer) {
|
||||||
clearTimeout(pendingFlushTimer);
|
clearTimeout(pendingFlushTimer);
|
||||||
pendingFlushTimer = null;
|
pendingFlushTimer = null;
|
||||||
}
|
}
|
||||||
await flushPromise;
|
await flushPromise;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAbortOrError(
|
||||||
|
ctx: InferenceContext,
|
||||||
|
args: TurnArgs,
|
||||||
|
accumulated: string,
|
||||||
|
err: unknown
|
||||||
|
): Promise<void> {
|
||||||
|
const { sessionId, chatId, assistantMessageId } = args;
|
||||||
const isAbort = err instanceof Error && err.name === 'AbortError';
|
const isAbort = err instanceof Error && err.name === 'AbortError';
|
||||||
const finalStatus = isAbort ? 'cancelled' : 'failed';
|
const finalStatus = isAbort ? 'cancelled' : 'failed';
|
||||||
await ctx.sql`
|
await ctx.sql`
|
||||||
@@ -478,18 +474,19 @@ async function runAssistantTurn(
|
|||||||
});
|
});
|
||||||
ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed');
|
ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed');
|
||||||
}
|
}
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (pendingFlushTimer) {
|
async function executeToolPhase(
|
||||||
clearTimeout(pendingFlushTimer);
|
ctx: InferenceContext,
|
||||||
pendingFlushTimer = null;
|
args: TurnArgs,
|
||||||
}
|
result: StreamResult,
|
||||||
await flushPromise;
|
startedAt: string | null,
|
||||||
|
session: Session,
|
||||||
|
projectRoot: string
|
||||||
|
): Promise<void> {
|
||||||
|
const { sessionId, chatId, assistantMessageId, depth, signal } = args;
|
||||||
|
const { content, toolCalls, promptTokens, completionTokens, nCtx } = result;
|
||||||
|
|
||||||
const { content, finishReason, toolCalls, promptTokens, completionTokens, nCtx } = result;
|
|
||||||
|
|
||||||
if (toolCalls.length > 0) {
|
|
||||||
const [updated] = await ctx.sql<
|
const [updated] = await ctx.sql<
|
||||||
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
|
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
|
||||||
>`
|
>`
|
||||||
@@ -567,9 +564,24 @@ async function runAssistantTurn(
|
|||||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
await runAssistantTurn(ctx, sessionId, chatId, nextAssistant!.id, depth + 1, signal);
|
await runAssistantTurn(ctx, {
|
||||||
return;
|
sessionId,
|
||||||
}
|
chatId,
|
||||||
|
assistantMessageId: nextAssistant!.id,
|
||||||
|
depth: depth + 1,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finalizeCompletion(
|
||||||
|
ctx: InferenceContext,
|
||||||
|
args: TurnArgs,
|
||||||
|
result: StreamResult,
|
||||||
|
startedAt: string | null,
|
||||||
|
session: Session
|
||||||
|
): Promise<void> {
|
||||||
|
const { sessionId, chatId, assistantMessageId } = args;
|
||||||
|
const { content, finishReason, promptTokens, completionTokens, nCtx } = result;
|
||||||
|
|
||||||
const [updated] = await ctx.sql<
|
const [updated] = await ctx.sql<
|
||||||
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
|
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
|
||||||
@@ -615,6 +627,55 @@ async function runAssistantTurn(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runAssistantTurn(
|
||||||
|
ctx: InferenceContext,
|
||||||
|
args: TurnArgs,
|
||||||
|
): Promise<void> {
|
||||||
|
const { sessionId, chatId, assistantMessageId, depth } = args;
|
||||||
|
|
||||||
|
if (depth > MAX_TOOL_LOOP_DEPTH) {
|
||||||
|
await ctx.sql`
|
||||||
|
UPDATE messages
|
||||||
|
SET status = 'failed',
|
||||||
|
content = ${'tool loop depth exceeded'},
|
||||||
|
finished_at = clock_timestamp()
|
||||||
|
WHERE id = ${assistantMessageId}
|
||||||
|
`;
|
||||||
|
ctx.publish(sessionId, {
|
||||||
|
type: 'error',
|
||||||
|
message_id: assistantMessageId,
|
||||||
|
chat_id: chatId,
|
||||||
|
error: 'tool loop depth exceeded',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||||
|
if (!loaded) {
|
||||||
|
ctx.log.warn({ sessionId }, 'inference: session or project missing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { session, project, history } = loaded;
|
||||||
|
const projectRoot = await resolveProjectRoot(project.path);
|
||||||
|
const messages = buildMessagesPayload(session, project, history);
|
||||||
|
|
||||||
|
const state: StreamPhaseState = { accumulated: '', startedAt: null };
|
||||||
|
let result: StreamResult;
|
||||||
|
try {
|
||||||
|
result = await executeStreamPhase(ctx, args, session, messages, state);
|
||||||
|
} catch (err) {
|
||||||
|
await handleAbortOrError(ctx, args, state.accumulated, err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.toolCalls.length > 0) {
|
||||||
|
await executeToolPhase(ctx, args, result, state.startedAt, session, projectRoot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await finalizeCompletion(ctx, args, result, state.startedAt, session);
|
||||||
|
}
|
||||||
|
|
||||||
export async function runInference(
|
export async function runInference(
|
||||||
ctx: InferenceContext,
|
ctx: InferenceContext,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -622,7 +683,7 @@ export async function runInference(
|
|||||||
assistantMessageId: string,
|
assistantMessageId: string,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return runAssistantTurn(ctx, sessionId, chatId, assistantMessageId, 0, signal);
|
return runAssistantTurn(ctx, { sessionId, chatId, assistantMessageId, depth: 0, signal });
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMPACT_SYSTEM_PROMPT =
|
const COMPACT_SYSTEM_PROMPT =
|
||||||
@@ -764,6 +825,10 @@ export function createInferenceRunner(
|
|||||||
await reg.completed.catch(() => {});
|
await reg.completed.catch(() => {});
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
hasActive(chatId: string): boolean {
|
||||||
|
return registry.has(chatId);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,11 @@ export interface SessionUpdatedFrame {
|
|||||||
name: string;
|
name: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
export interface SessionRenamedFrame {
|
||||||
|
type: 'session_renamed';
|
||||||
|
session_id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
export interface SessionArchivedFrame {
|
export interface SessionArchivedFrame {
|
||||||
type: 'session_archived';
|
type: 'session_archived';
|
||||||
session_id: string;
|
session_id: string;
|
||||||
@@ -226,6 +231,7 @@ export type UserStreamFrame =
|
|||||||
| SessionCreatedFrame
|
| SessionCreatedFrame
|
||||||
| SessionDeletedFrame
|
| SessionDeletedFrame
|
||||||
| SessionUpdatedFrame
|
| SessionUpdatedFrame
|
||||||
|
| SessionRenamedFrame
|
||||||
| SessionArchivedFrame
|
| SessionArchivedFrame
|
||||||
| ChatCreatedFrame
|
| ChatCreatedFrame
|
||||||
| ChatUpdatedFrame
|
| ChatUpdatedFrame
|
||||||
|
|||||||
@@ -148,6 +148,11 @@ export const api = {
|
|||||||
`/api/chats/${chatId}/force_send`,
|
`/api/chats/${chatId}/force_send`,
|
||||||
{ method: 'POST', body: JSON.stringify({ content }) }
|
{ method: 'POST', body: JSON.stringify({ content }) }
|
||||||
),
|
),
|
||||||
|
fork: (chatId: string, body: { messageId: string; name?: string }) =>
|
||||||
|
request<Chat>(`/api/chats/${chatId}/fork`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ message_id: body.messageId, name: body.name }),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
messages: {
|
messages: {
|
||||||
@@ -166,6 +171,10 @@ export const api = {
|
|||||||
`/api/chats/${chatId}/messages/${messageId}/regenerate`,
|
`/api/chats/${chatId}/messages/${messageId}/regenerate`,
|
||||||
{ method: 'POST' }
|
{ method: 'POST' }
|
||||||
),
|
),
|
||||||
|
remove: (chatId: string, messageId: string) =>
|
||||||
|
request<void>(`/api/chats/${chatId}/messages/${messageId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
models: () => request<ModelInfo[]>('/api/models'),
|
models: () => request<ModelInfo[]>('/api/models'),
|
||||||
|
|||||||
@@ -2,13 +2,22 @@ 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 { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw } from 'lucide-react';
|
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { Chat, 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';
|
||||||
import { CodeBlock } from './CodeBlock';
|
import { CodeBlock } from './CodeBlock';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
// Match path-shaped substrings ending in `.ext`. Additionally require a `/`
|
// Match path-shaped substrings ending in `.ext`. Additionally require a `/`
|
||||||
// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't
|
// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't
|
||||||
@@ -198,6 +207,9 @@ function ActionRow({
|
|||||||
}) {
|
}) {
|
||||||
const [justCopied, setJustCopied] = useState(false);
|
const [justCopied, setJustCopied] = useState(false);
|
||||||
const [regenerating, setRegenerating] = useState(false);
|
const [regenerating, setRegenerating] = useState(false);
|
||||||
|
const [forking, setForking] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
async function copy() {
|
async function copy() {
|
||||||
try {
|
try {
|
||||||
@@ -221,10 +233,39 @@ function ActionRow({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fork() {
|
||||||
|
if (forking || message.status !== 'complete') return;
|
||||||
|
setForking(true);
|
||||||
|
try {
|
||||||
|
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
|
||||||
|
sessionEvents.emit({ type: 'open_chat_in_active_pane', chat_id: chat.id });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'fork failed');
|
||||||
|
} finally {
|
||||||
|
setForking(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (deleting) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await api.messages.remove(message.chat_id, message.id);
|
||||||
|
setDeleteOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'delete failed');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isAssistant = message.role === 'assistant';
|
const isAssistant = message.role === 'assistant';
|
||||||
const canRegen = isAssistant && message.status !== 'streaming';
|
const canRegen = isAssistant && message.status !== 'streaming';
|
||||||
|
const canFork = message.status === 'complete';
|
||||||
|
const canDelete = message.status !== 'streaming';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -247,7 +288,59 @@ function ActionRow({
|
|||||||
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void fork()}
|
||||||
|
disabled={!canFork || forking}
|
||||||
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
aria-label="Fork from here"
|
||||||
|
title="Fork from here"
|
||||||
|
>
|
||||||
|
<GitFork className="size-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
disabled={!canDelete}
|
||||||
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
aria-label="Delete message"
|
||||||
|
title="Delete message"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<Dialog
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!deleting) setDeleteOpen(open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete this message and all messages after it?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This removes the selected message and every later message in this chat. This cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDeleteOpen(false)}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => void confirmDelete()}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting…' : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
import type { DragEvent } from 'react';
|
|
||||||
import { FolderOpen, MessageSquare, X } from 'lucide-react';
|
|
||||||
import type { Pane, PaneKind } from '@/api/types';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import {
|
|
||||||
ContextMenu,
|
|
||||||
ContextMenuContent,
|
|
||||||
ContextMenuItem,
|
|
||||||
ContextMenuSeparator,
|
|
||||||
ContextMenuSub,
|
|
||||||
ContextMenuSubContent,
|
|
||||||
ContextMenuSubTrigger,
|
|
||||||
ContextMenuTrigger,
|
|
||||||
} from '@/components/ui/context-menu';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
pane: Pane;
|
|
||||||
isActive: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
onSplit: (kind: PaneKind) => void;
|
|
||||||
onCloseOthers: () => void;
|
|
||||||
onCloseToRight: () => void;
|
|
||||||
onCloseAll: () => void;
|
|
||||||
onDragStart: (e: DragEvent<HTMLDivElement>) => void;
|
|
||||||
onDragOver: (e: DragEvent<HTMLDivElement>) => void;
|
|
||||||
onDrop: (e: DragEvent<HTMLDivElement>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function basename(path: string): string {
|
|
||||||
if (!path) return '';
|
|
||||||
const parts = path.split('/');
|
|
||||||
return parts[parts.length - 1] ?? path;
|
|
||||||
}
|
|
||||||
|
|
||||||
function labelFor(pane: Pane): string {
|
|
||||||
if (pane.kind === 'chat') return 'Chat';
|
|
||||||
const openFile = pane.state.open_file;
|
|
||||||
if (openFile) return basename(openFile);
|
|
||||||
return 'Files';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PaneTab({
|
|
||||||
pane,
|
|
||||||
isActive,
|
|
||||||
onClick,
|
|
||||||
onClose,
|
|
||||||
onSplit,
|
|
||||||
onCloseOthers,
|
|
||||||
onCloseToRight,
|
|
||||||
onCloseAll,
|
|
||||||
onDragStart,
|
|
||||||
onDragOver,
|
|
||||||
onDrop,
|
|
||||||
}: Props) {
|
|
||||||
const Icon = pane.kind === 'chat' ? MessageSquare : FolderOpen;
|
|
||||||
const label = labelFor(pane);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextMenu>
|
|
||||||
<ContextMenuTrigger asChild>
|
|
||||||
<div
|
|
||||||
draggable
|
|
||||||
onDragStart={onDragStart}
|
|
||||||
onDragOver={onDragOver}
|
|
||||||
onDrop={onDrop}
|
|
||||||
onClick={onClick}
|
|
||||||
className={cn(
|
|
||||||
'group flex items-center gap-1.5 px-3 py-1.5 text-xs border-r border-border cursor-default select-none',
|
|
||||||
isActive
|
|
||||||
? 'bg-background text-foreground'
|
|
||||||
: 'bg-muted/30 text-muted-foreground hover:bg-muted/60'
|
|
||||||
)}
|
|
||||||
role="tab"
|
|
||||||
aria-selected={isActive}
|
|
||||||
>
|
|
||||||
<Icon size={12} className="shrink-0" />
|
|
||||||
<span className="truncate max-w-[160px]" title={label}>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
className="p-0.5 hover:bg-muted rounded opacity-60 hover:opacity-100 shrink-0"
|
|
||||||
aria-label="Close tab"
|
|
||||||
>
|
|
||||||
<X size={10} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</ContextMenuTrigger>
|
|
||||||
<ContextMenuContent>
|
|
||||||
<ContextMenuSub>
|
|
||||||
<ContextMenuSubTrigger>Split</ContextMenuSubTrigger>
|
|
||||||
<ContextMenuSubContent>
|
|
||||||
<ContextMenuItem onSelect={() => onSplit('chat')}>
|
|
||||||
<MessageSquare /> Chat
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem onSelect={() => onSplit('file_browser')}>
|
|
||||||
<FolderOpen /> File Browser
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuSubContent>
|
|
||||||
</ContextMenuSub>
|
|
||||||
<ContextMenuSeparator />
|
|
||||||
<ContextMenuItem onSelect={onClose}>Close</ContextMenuItem>
|
|
||||||
<ContextMenuItem onSelect={onCloseOthers}>Close others</ContextMenuItem>
|
|
||||||
<ContextMenuItem onSelect={onCloseToRight}>
|
|
||||||
Close to the right
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem onSelect={onCloseAll}>Close all</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback } from 'react';
|
||||||
import type { DragEvent } from 'react';
|
|
||||||
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
|
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { api } from '@/api/client';
|
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
|
||||||
import type { Chat, WorkspacePane } from '@/api/types';
|
import type { Chat, WorkspacePane } from '@/api/types';
|
||||||
|
import { useWorkspacePanes, MAX_PANES } from '@/hooks/useWorkspacePanes';
|
||||||
|
import { useSessionChats } from '@/hooks/useSessionChats';
|
||||||
import { ChatPane } from '@/components/panes/ChatPane';
|
import { ChatPane } from '@/components/panes/ChatPane';
|
||||||
import { ChatTabBar } from '@/components/ChatTabBar';
|
import { ChatTabBar } from '@/components/ChatTabBar';
|
||||||
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
||||||
@@ -21,376 +19,53 @@ interface Props {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_PANES = 5;
|
|
||||||
const STORAGE_KEY = 'boocode.workspace.panes';
|
|
||||||
|
|
||||||
function generateId(): string {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyPane(): WorkspacePane {
|
|
||||||
return { id: generateId(), kind: 'empty', chatIds: [], activeChatIdx: -1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function chatPane(chatId: string): WorkspacePane {
|
|
||||||
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadPanes(sessionId: string): WorkspacePane[] | null {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`);
|
|
||||||
if (!raw) return null;
|
|
||||||
const parsed = JSON.parse(raw) as WorkspacePane[];
|
|
||||||
if (!Array.isArray(parsed) || parsed.length === 0) return null;
|
|
||||||
return parsed;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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, setPanes] = useState<WorkspacePane[]>(() => {
|
const {
|
||||||
return loadPanes(sessionId) ?? [emptyPane()];
|
panes,
|
||||||
});
|
activePaneIdx,
|
||||||
const [activePaneIdx, setActivePaneIdx] = useState(0);
|
setActivePaneIdx,
|
||||||
const [chats, setChats] = useState<Chat[]>([]);
|
activePaneIdxRef,
|
||||||
const chatsRef = useRef<Chat[]>([]);
|
openChatInPane,
|
||||||
chatsRef.current = chats;
|
switchTab,
|
||||||
const draggingIdxRef = useRef<number | null>(null);
|
removeTab,
|
||||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
closeOtherTabs,
|
||||||
|
closeTabsToRight,
|
||||||
|
closeAllTabs,
|
||||||
|
showLandingPage,
|
||||||
|
addSplitPane,
|
||||||
|
removePane,
|
||||||
|
removeChatFromPanes,
|
||||||
|
initializeFirstChatIfEmpty,
|
||||||
|
handlePaneDragStart,
|
||||||
|
handlePaneDragOver,
|
||||||
|
handlePaneDragLeave,
|
||||||
|
handlePaneDrop,
|
||||||
|
handlePaneDragEnd,
|
||||||
|
dragOverIdx,
|
||||||
|
draggingIdxRef,
|
||||||
|
} = useWorkspacePanes(sessionId);
|
||||||
|
|
||||||
useEffect(() => {
|
// Thin wrapper so useSessionChats can route open_chat_in_active_pane events
|
||||||
let cancelled = false;
|
// without knowing about pane indexing.
|
||||||
api.chats.listForSession(sessionId).then((list) => {
|
const openChatInActivePane = useCallback(
|
||||||
if (cancelled) return;
|
(chatId: string) => openChatInPane(activePaneIdxRef.current, chatId),
|
||||||
setChats(list);
|
[openChatInPane, activePaneIdxRef],
|
||||||
const openChat = list.find((c) => c.status === 'open');
|
|
||||||
if (openChat) {
|
|
||||||
setPanes((prev) => {
|
|
||||||
if (prev.length === 1 && prev[0]!.kind === 'empty') {
|
|
||||||
return [chatPane(openChat.id)];
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}).catch(() => {});
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [sessionId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
savePanes(sessionId, panes);
|
|
||||||
}, [sessionId, panes]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return sessionEvents.subscribe((event) => {
|
|
||||||
if (event.type === 'chat_created' && event.session_id === sessionId) {
|
|
||||||
setChats((prev) => {
|
|
||||||
if (prev.some((c) => c.id === event.chat.id)) return prev;
|
|
||||||
return [event.chat, ...prev];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (event.type === 'chat_updated') {
|
|
||||||
setChats((prev) => prev.map((c) =>
|
|
||||||
c.id === event.chat_id ? { ...c, name: event.name, updated_at: event.updated_at } : c
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if (event.type === 'chat_archived') {
|
|
||||||
setChats((prev) => prev.map((c) =>
|
|
||||||
c.id === event.chat_id ? { ...c, status: 'archived' as const } : c
|
|
||||||
));
|
|
||||||
removeChatFromPanes(event.chat_id);
|
|
||||||
}
|
|
||||||
if (event.type === 'chat_unarchived') {
|
|
||||||
setChats((prev) => {
|
|
||||||
if (prev.some((c) => c.id === event.chat.id)) {
|
|
||||||
return prev.map((c) => c.id === event.chat.id ? { ...c, status: 'open' as const } : c);
|
|
||||||
}
|
|
||||||
return [event.chat, ...prev];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (event.type === 'chat_deleted') {
|
|
||||||
setChats((prev) => prev.filter((c) => c.id !== event.chat_id));
|
|
||||||
removeChatFromPanes(event.chat_id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [sessionId]);
|
|
||||||
|
|
||||||
function removeChatFromPanes(chatId: string) {
|
|
||||||
setPanes((prev) => prev.map((p) => {
|
|
||||||
const idx = p.chatIds.indexOf(chatId);
|
|
||||||
if (idx < 0) return p;
|
|
||||||
const nextIds = p.chatIds.filter((id) => id !== chatId);
|
|
||||||
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],
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const openChatInPane = useCallback((paneIdx: number, chatId: string) => {
|
|
||||||
setPanes((prev) => {
|
|
||||||
const next = [...prev];
|
|
||||||
const pane = next[paneIdx]!;
|
|
||||||
const existing = pane.chatIds.indexOf(chatId);
|
|
||||||
if (existing >= 0) {
|
|
||||||
next[paneIdx] = { ...pane, kind: 'chat', chatId, activeChatIdx: existing };
|
|
||||||
} else {
|
|
||||||
const newIds = [...pane.chatIds, chatId];
|
|
||||||
next[paneIdx] = {
|
|
||||||
...pane,
|
|
||||||
kind: 'chat',
|
|
||||||
chatId,
|
|
||||||
chatIds: newIds,
|
|
||||||
activeChatIdx: newIds.length - 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setActivePaneIdx(paneIdx);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const switchTab = useCallback((paneIdx: number, tabIdx: number) => {
|
|
||||||
setPanes((prev) => {
|
|
||||||
const next = [...prev];
|
|
||||||
const pane = next[paneIdx]!;
|
|
||||||
const chatId = pane.chatIds[tabIdx];
|
|
||||||
if (!chatId) return prev;
|
|
||||||
next[paneIdx] = { ...pane, chatId, activeChatIdx: tabIdx };
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const removeTab = useCallback((paneIdx: number, chatId: string) => {
|
|
||||||
setPanes((prev) => {
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Keep only the right-clicked tab open in this pane.
|
|
||||||
const closeOtherTabs = useCallback((paneIdx: number, keepChatId: string) => {
|
|
||||||
setPanes((prev) => {
|
|
||||||
const next = [...prev];
|
|
||||||
const pane = next[paneIdx]!;
|
|
||||||
const keepIdx = pane.chatIds.indexOf(keepChatId);
|
|
||||||
if (keepIdx < 0) return prev;
|
|
||||||
next[paneIdx] = {
|
|
||||||
...pane,
|
|
||||||
kind: 'chat',
|
|
||||||
chatId: keepChatId,
|
|
||||||
chatIds: [keepChatId],
|
|
||||||
activeChatIdx: 0,
|
|
||||||
};
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Close every tab to the right of the right-clicked one.
|
|
||||||
const closeTabsToRight = useCallback((paneIdx: number, pivotChatId: string) => {
|
|
||||||
setPanes((prev) => {
|
|
||||||
const next = [...prev];
|
|
||||||
const pane = next[paneIdx]!;
|
|
||||||
const pivotIdx = pane.chatIds.indexOf(pivotChatId);
|
|
||||||
if (pivotIdx < 0 || pivotIdx === pane.chatIds.length - 1) return prev;
|
|
||||||
const nextIds = pane.chatIds.slice(0, pivotIdx + 1);
|
|
||||||
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
|
|
||||||
next[paneIdx] = {
|
|
||||||
...pane,
|
|
||||||
chatIds: nextIds,
|
|
||||||
activeChatIdx: nextActiveIdx,
|
|
||||||
chatId: nextIds[nextActiveIdx],
|
|
||||||
};
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Close every tab in this pane; land on landing page.
|
|
||||||
const closeAllTabs = useCallback((paneIdx: number) => {
|
|
||||||
setPanes((prev) => {
|
|
||||||
const next = [...prev];
|
|
||||||
const pane = next[paneIdx]!;
|
|
||||||
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const createChat = useCallback(async (paneIdx: number) => {
|
|
||||||
try {
|
|
||||||
const chat = await api.chats.create(sessionId);
|
|
||||||
// Optimistic local insert; the WS chat_created echo will be deduped by id.
|
|
||||||
setChats((prev) => {
|
|
||||||
if (prev.some((c) => c.id === chat.id)) return prev;
|
|
||||||
return [chat, ...prev];
|
|
||||||
});
|
|
||||||
openChatInPane(paneIdx, chat.id);
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : 'Failed to create chat');
|
|
||||||
}
|
|
||||||
}, [sessionId, openChatInPane]);
|
|
||||||
|
|
||||||
const archiveChat = useCallback(async (chatId: string) => {
|
|
||||||
try {
|
|
||||||
await api.chats.archive(chatId);
|
|
||||||
// Server publishes chat_archived; bus forwarder updates state.
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : 'Failed to archive chat');
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const unarchiveChat = useCallback(async (chatId: string) => {
|
|
||||||
try {
|
|
||||||
await api.chats.unarchive(chatId);
|
|
||||||
// Server publishes chat_unarchived.
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : 'Failed to restore 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 handlePaneDragStart = useCallback(
|
|
||||||
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
|
|
||||||
draggingIdxRef.current = idx;
|
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
|
||||||
e.dataTransfer.setData('text/plain', String(idx));
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePaneDragOver = useCallback(
|
const {
|
||||||
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
|
chats,
|
||||||
if (draggingIdxRef.current === null) return;
|
createChat,
|
||||||
e.preventDefault();
|
archiveChat,
|
||||||
e.dataTransfer.dropEffect = 'move';
|
unarchiveChat,
|
||||||
if (dragOverIdx !== idx) setDragOverIdx(idx);
|
deleteChat,
|
||||||
},
|
renameChat,
|
||||||
[dragOverIdx]
|
handleLandingSend,
|
||||||
);
|
} = useSessionChats(sessionId, {
|
||||||
|
removeChatFromPanes,
|
||||||
const handlePaneDragLeave = useCallback(() => {
|
openChatInPane,
|
||||||
setDragOverIdx(null);
|
openChatInActivePane,
|
||||||
}, []);
|
initializeFirstChatIfEmpty,
|
||||||
|
|
||||||
const handlePaneDrop = useCallback(
|
|
||||||
(targetIdx: number) => (e: DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const fromIdx = draggingIdxRef.current;
|
|
||||||
draggingIdxRef.current = null;
|
|
||||||
setDragOverIdx(null);
|
|
||||||
if (fromIdx === null || fromIdx === targetIdx) return;
|
|
||||||
setPanes((prev) => {
|
|
||||||
const next = [...prev];
|
|
||||||
const [moved] = next.splice(fromIdx, 1);
|
|
||||||
if (!moved) return prev;
|
|
||||||
next.splice(targetIdx, 0, moved);
|
|
||||||
// Keep active selection on the same logical pane (the one being dragged).
|
|
||||||
setActivePaneIdx(targetIdx);
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePaneDragEnd = useCallback(() => {
|
|
||||||
draggingIdxRef.current = null;
|
|
||||||
setDragOverIdx(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLandingSend = useCallback(async (paneIdx: number, content: string) => {
|
|
||||||
try {
|
|
||||||
const chat = await api.chats.create(sessionId);
|
|
||||||
setChats((prev) => {
|
|
||||||
if (prev.some((c) => c.id === chat.id)) return prev;
|
|
||||||
return [chat, ...prev];
|
|
||||||
});
|
|
||||||
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[] {
|
function chatsForPane(pane: WorkspacePane): Chat[] {
|
||||||
return pane.chatIds
|
return pane.chatIds
|
||||||
|
|||||||
@@ -1,865 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import type { KeyboardEvent } from 'react';
|
|
||||||
import { Check, ChevronRight, ChevronDown, Copy, FileText, Folder, X } from 'lucide-react';
|
|
||||||
import { codeToHtml } from 'shiki';
|
|
||||||
import { api, ApiError } from '@/api/client';
|
|
||||||
import type {
|
|
||||||
FileBrowserPaneState,
|
|
||||||
FileEntry,
|
|
||||||
Pane,
|
|
||||||
ViewFileResult,
|
|
||||||
} from '@/api/types';
|
|
||||||
import { inferLanguage } from '@/lib/attachments';
|
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
pane: Pane & { kind: 'file_browser' };
|
|
||||||
projectId: string;
|
|
||||||
onStateChange: (state: FileBrowserPaneState) => 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TreeNodeProps {
|
|
||||||
parentPath: string; // '' for root children
|
|
||||||
entries: FileEntry[];
|
|
||||||
cache: Map<string, FileEntry[]>;
|
|
||||||
expanded: Set<string>;
|
|
||||||
openFile: string | null;
|
|
||||||
highlightedPath: string | null;
|
|
||||||
depth: number;
|
|
||||||
onToggleDir: (dirPath: string) => void;
|
|
||||||
onSelectFile: (path: string) => void;
|
|
||||||
setHighlightedPath: (p: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TreeNode({
|
|
||||||
parentPath,
|
|
||||||
entries,
|
|
||||||
cache,
|
|
||||||
expanded,
|
|
||||||
openFile,
|
|
||||||
highlightedPath,
|
|
||||||
depth,
|
|
||||||
onToggleDir,
|
|
||||||
onSelectFile,
|
|
||||||
setHighlightedPath,
|
|
||||||
}: TreeNodeProps) {
|
|
||||||
// Sort: dirs first, then files; alphabetical within each.
|
|
||||||
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);
|
|
||||||
const isActive = entry.kind === 'file' && openFile === fullPath;
|
|
||||||
const isHighlight = highlightedPath === fullPath;
|
|
||||||
return (
|
|
||||||
<li key={fullPath}>
|
|
||||||
<div
|
|
||||||
data-path={fullPath}
|
|
||||||
data-kind={entry.kind}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1 px-1 py-0.5 text-xs cursor-default rounded hover:bg-muted/60',
|
|
||||||
isActive && 'bg-muted',
|
|
||||||
isHighlight && 'ring-1 ring-ring/40'
|
|
||||||
)}
|
|
||||||
style={{ paddingLeft: 4 + depth * 12 }}
|
|
||||||
onClick={() => {
|
|
||||||
setHighlightedPath(fullPath);
|
|
||||||
if (entry.kind === 'dir') {
|
|
||||||
onToggleDir(fullPath);
|
|
||||||
} else {
|
|
||||||
onSelectFile(fullPath);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{entry.kind === 'dir' ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
|
||||||
className="p-0.5 hover:bg-muted rounded shrink-0"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setHighlightedPath(fullPath);
|
|
||||||
onToggleDir(fullPath);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronDown size={10} />
|
|
||||||
) : (
|
|
||||||
<ChevronRight size={10} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="w-[16px] 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) && (
|
|
||||||
<TreeNode
|
|
||||||
parentPath={fullPath}
|
|
||||||
entries={cache.get(fullPath) ?? []}
|
|
||||||
cache={cache}
|
|
||||||
expanded={expanded}
|
|
||||||
openFile={openFile}
|
|
||||||
highlightedPath={highlightedPath}
|
|
||||||
depth={depth + 1}
|
|
||||||
onToggleDir={onToggleDir}
|
|
||||||
onSelectFile={onSelectFile}
|
|
||||||
setHighlightedPath={setHighlightedPath}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
|
|
||||||
const openFile = pane.state.open_file ?? null;
|
|
||||||
const filter = pane.state.filter ?? '';
|
|
||||||
const expandedDirs = useMemo(
|
|
||||||
() => pane.state.expanded_dirs ?? [],
|
|
||||||
[pane.state.expanded_dirs]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Local filter (debounced 100ms before pushing to onStateChange)
|
|
||||||
const [filterDraft, setFilterDraft] = useState(filter);
|
|
||||||
const filterDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
// Track previous external filter so we can sync local draft when the
|
|
||||||
// canonical state changes from outside (e.g. server snapshot, other tab).
|
|
||||||
const lastExternalFilter = useRef(filter);
|
|
||||||
useEffect(() => {
|
|
||||||
if (filter !== lastExternalFilter.current) {
|
|
||||||
lastExternalFilter.current = filter;
|
|
||||||
setFilterDraft(filter);
|
|
||||||
}
|
|
||||||
}, [filter]);
|
|
||||||
|
|
||||||
function onFilterInput(value: string) {
|
|
||||||
setFilterDraft(value);
|
|
||||||
if (filterDebounceRef.current !== null) {
|
|
||||||
clearTimeout(filterDebounceRef.current);
|
|
||||||
}
|
|
||||||
filterDebounceRef.current = setTimeout(() => {
|
|
||||||
filterDebounceRef.current = null;
|
|
||||||
lastExternalFilter.current = value;
|
|
||||||
onStateChange({
|
|
||||||
...pane.state,
|
|
||||||
filter: value,
|
|
||||||
open_file: openFile,
|
|
||||||
expanded_dirs: expandedDirs,
|
|
||||||
});
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (filterDebounceRef.current !== null) {
|
|
||||||
clearTimeout(filterDebounceRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 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
|
|
||||||
const [cache, setCache] = useState<Map<string, FileEntry[]>>(new Map());
|
|
||||||
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set());
|
|
||||||
const [dirErrors, setDirErrors] = useState<Map<string, string>>(new Map());
|
|
||||||
|
|
||||||
const loadDir = useCallback(
|
|
||||||
async (dirPath: string) => {
|
|
||||||
// dirPath '' is root; server expects '.'
|
|
||||||
const apiPath = dirPath === '' ? '.' : dirPath;
|
|
||||||
setLoadingDirs((prev) => {
|
|
||||||
if (prev.has(dirPath)) return prev;
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.add(dirPath);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const result = await api.projects.listDir(projectId, apiPath);
|
|
||||||
setCache((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(dirPath, result.entries);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setDirErrors((prev) => {
|
|
||||||
if (!prev.has(dirPath)) return prev;
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.delete(dirPath);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : 'failed to list directory';
|
|
||||||
setDirErrors((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(dirPath, msg);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoadingDirs((prev) => {
|
|
||||||
if (!prev.has(dirPath)) return prev;
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(dirPath);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[projectId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Load root on mount + any expanded dirs from server state.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!cache.has('')) {
|
|
||||||
void loadDir('');
|
|
||||||
}
|
|
||||||
for (const dir of expandedDirs) {
|
|
||||||
if (!cache.has(dir)) {
|
|
||||||
void loadDir(dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [projectId]);
|
|
||||||
|
|
||||||
// When expandedDirs grows (e.g. user expands), ensure new dir is loaded.
|
|
||||||
useEffect(() => {
|
|
||||||
for (const dir of expandedDirs) {
|
|
||||||
if (!cache.has(dir) && !loadingDirs.has(dir)) {
|
|
||||||
void loadDir(dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [expandedDirs, cache, loadingDirs, loadDir]);
|
|
||||||
|
|
||||||
const expandedSet = useMemo(() => new Set(expandedDirs), [expandedDirs]);
|
|
||||||
|
|
||||||
function toggleDir(dirPath: string) {
|
|
||||||
let nextDirs: string[];
|
|
||||||
if (expandedSet.has(dirPath)) {
|
|
||||||
nextDirs = expandedDirs.filter((d) => d !== dirPath);
|
|
||||||
} else {
|
|
||||||
nextDirs = [...expandedDirs, dirPath];
|
|
||||||
}
|
|
||||||
onStateChange({
|
|
||||||
...pane.state,
|
|
||||||
open_file: openFile,
|
|
||||||
filter: filterDraft,
|
|
||||||
expanded_dirs: nextDirs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectFile(path: string) {
|
|
||||||
onStateChange({
|
|
||||||
...pane.state,
|
|
||||||
open_file: path,
|
|
||||||
filter: filterDraft,
|
|
||||||
expanded_dirs: expandedDirs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeOpenFile() {
|
|
||||||
onStateChange({
|
|
||||||
...pane.state,
|
|
||||||
open_file: null,
|
|
||||||
filter: filterDraft,
|
|
||||||
expanded_dirs: expandedDirs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a flat list of all entries reachable through the loaded cache,
|
|
||||||
// for filter results and keyboard navigation.
|
|
||||||
interface FlatEntry {
|
|
||||||
path: string;
|
|
||||||
name: string;
|
|
||||||
kind: 'file' | 'dir';
|
|
||||||
}
|
|
||||||
|
|
||||||
const flattenedVisible = useMemo<FlatEntry[]>(() => {
|
|
||||||
const result: FlatEntry[] = [];
|
|
||||||
function walk(dirPath: string) {
|
|
||||||
const entries = cache.get(dirPath);
|
|
||||||
if (!entries) return;
|
|
||||||
const sorted = [...entries].sort((a, b) => {
|
|
||||||
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
for (const e of sorted) {
|
|
||||||
const full = joinPath(dirPath, e.name);
|
|
||||||
result.push({ path: full, name: e.name, kind: e.kind });
|
|
||||||
if (e.kind === 'dir' && expandedSet.has(full)) {
|
|
||||||
walk(full);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walk('');
|
|
||||||
return result;
|
|
||||||
}, [cache, expandedSet]);
|
|
||||||
|
|
||||||
const flattenedAll = useMemo<FlatEntry[]>(() => {
|
|
||||||
const result: FlatEntry[] = [];
|
|
||||||
function walk(dirPath: string) {
|
|
||||||
const entries = cache.get(dirPath);
|
|
||||||
if (!entries) return;
|
|
||||||
for (const e of entries) {
|
|
||||||
const full = joinPath(dirPath, e.name);
|
|
||||||
result.push({ path: full, name: e.name, kind: e.kind });
|
|
||||||
if (e.kind === 'dir') walk(full);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walk('');
|
|
||||||
return result;
|
|
||||||
}, [cache]);
|
|
||||||
|
|
||||||
const trimmedFilter = filterDraft.trim();
|
|
||||||
const filterActive = trimmedFilter.length > 0;
|
|
||||||
|
|
||||||
interface FilterResult {
|
|
||||||
path: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filterResults = useMemo<FilterResult[]>(() => {
|
|
||||||
if (!filterActive) return [];
|
|
||||||
const needle = trimmedFilter.toLowerCase();
|
|
||||||
|
|
||||||
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
|
|
||||||
const [highlightedPath, setHighlightedPath] = useState<string | null>(null);
|
|
||||||
const treeRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
// Reset highlight if it falls out of the current list (e.g. when filter
|
|
||||||
// changes or dirs collapse).
|
|
||||||
useEffect(() => {
|
|
||||||
if (!highlightedPath) return;
|
|
||||||
const list = filterActive ? filterResults : flattenedVisible;
|
|
||||||
if (!list.some((e) => e.path === highlightedPath)) {
|
|
||||||
setHighlightedPath(null);
|
|
||||||
}
|
|
||||||
}, [highlightedPath, filterActive, filterResults, flattenedVisible]);
|
|
||||||
|
|
||||||
function onTreeKeyDown(e: KeyboardEvent<HTMLDivElement>) {
|
|
||||||
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;
|
|
||||||
const idx = highlightedPath
|
|
||||||
? list.findIndex((entry) => entry.path === highlightedPath)
|
|
||||||
: -1;
|
|
||||||
|
|
||||||
if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
|
||||||
const next = idx < 0 ? 0 : Math.min(list.length - 1, idx + 1);
|
|
||||||
const target = list[next];
|
|
||||||
if (target) setHighlightedPath(target.path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
const next = idx <= 0 ? 0 : idx - 1;
|
|
||||||
const target = list[next];
|
|
||||||
if (target) setHighlightedPath(target.path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
if (idx < 0) return;
|
|
||||||
const target = list[idx];
|
|
||||||
if (!target) return;
|
|
||||||
e.preventDefault();
|
|
||||||
if (target.kind === 'dir') {
|
|
||||||
toggleDir(target.path);
|
|
||||||
} else {
|
|
||||||
selectFile(target.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
const [viewer, setViewer] = useState<{
|
|
||||||
path: string;
|
|
||||||
state: 'loading' | 'ready' | 'error';
|
|
||||||
result?: ViewFileResult;
|
|
||||||
error?: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!openFile) {
|
|
||||||
setViewer(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
|
||||||
setViewer({ path: openFile, state: 'loading' });
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const result = await api.projects.viewFile(projectId, openFile);
|
|
||||||
if (cancelled) return;
|
|
||||||
setViewer({ path: openFile, state: 'ready', result });
|
|
||||||
} catch (err) {
|
|
||||||
if (cancelled) return;
|
|
||||||
let message: string;
|
|
||||||
if (err instanceof ApiError) {
|
|
||||||
const apiMsg =
|
|
||||||
typeof err.body === 'object' &&
|
|
||||||
err.body !== null &&
|
|
||||||
'error' in err.body
|
|
||||||
? String((err.body as { error: unknown }).error)
|
|
||||||
: err.message;
|
|
||||||
if (err.status === 404) {
|
|
||||||
message = 'File not found';
|
|
||||||
} else if (apiMsg.toLowerCase().includes('too large')) {
|
|
||||||
message = 'File too large to view';
|
|
||||||
} else if (
|
|
||||||
apiMsg.toLowerCase().includes('outside') ||
|
|
||||||
apiMsg.toLowerCase().includes('not a file') ||
|
|
||||||
apiMsg.toLowerCase().includes('path')
|
|
||||||
) {
|
|
||||||
message = 'Cannot view files outside project';
|
|
||||||
} else {
|
|
||||||
message = apiMsg;
|
|
||||||
}
|
|
||||||
} else if (err instanceof Error) {
|
|
||||||
message = err.message;
|
|
||||||
} else {
|
|
||||||
message = 'Failed to load file';
|
|
||||||
}
|
|
||||||
setViewer({ path: openFile, state: 'error', error: message });
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [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
|
|
||||||
const rootEntries = cache.get('');
|
|
||||||
const rootLoading = loadingDirs.has('') && !rootEntries;
|
|
||||||
const rootError = dirErrors.get('');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full min-h-0">
|
|
||||||
<div className="px-2 py-1.5 border-b border-border bg-muted/20">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={filterDraft}
|
|
||||||
onChange={(e) => onFilterInput(e.target.value)}
|
|
||||||
placeholder="Filter files..."
|
|
||||||
className="w-full px-2 py-1 text-xs bg-background border border-border rounded outline-none focus:border-ring"
|
|
||||||
aria-label="Filter files"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-h-0 grid grid-cols-[minmax(0,260px)_1fr]">
|
|
||||||
<div
|
|
||||||
ref={treeRef}
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={onTreeKeyDown}
|
|
||||||
className="overflow-y-auto border-r border-border outline-none focus:ring-1 focus:ring-inset focus:ring-ring/40"
|
|
||||||
role="tree"
|
|
||||||
aria-label="Project files"
|
|
||||||
>
|
|
||||||
{rootLoading && (
|
|
||||||
<div className="text-xs text-muted-foreground px-2 py-1.5">
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{rootError && (
|
|
||||||
<div className="text-xs text-destructive px-2 py-1.5">
|
|
||||||
{rootError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!rootLoading && !rootError && filterActive && (
|
|
||||||
<ul className="list-none">
|
|
||||||
{filterResults.length === 0 ? (
|
|
||||||
<li className="text-xs text-muted-foreground px-2 py-1.5">
|
|
||||||
No matches
|
|
||||||
</li>
|
|
||||||
) : (
|
|
||||||
filterResults.map((entry) => {
|
|
||||||
const isActive = openFile === entry.path;
|
|
||||||
const isHighlight = highlightedPath === entry.path;
|
|
||||||
return (
|
|
||||||
<li key={entry.path}>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1 px-2 py-0.5 text-xs cursor-default rounded hover:bg-muted/60',
|
|
||||||
isActive && 'bg-muted',
|
|
||||||
isHighlight && 'ring-1 ring-ring/40'
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
setHighlightedPath(entry.path);
|
|
||||||
selectFile(entry.path);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FileText size={12} className="text-muted-foreground shrink-0" />
|
|
||||||
<span className="truncate">
|
|
||||||
<span className="font-bold">{entry.name}</span>
|
|
||||||
<span className="text-muted-foreground ml-1">{entry.path}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
{!rootLoading && !rootError && !filterActive && rootEntries && (
|
|
||||||
<TreeNode
|
|
||||||
parentPath=""
|
|
||||||
entries={rootEntries}
|
|
||||||
cache={cache}
|
|
||||||
expanded={expandedSet}
|
|
||||||
openFile={openFile}
|
|
||||||
highlightedPath={highlightedPath}
|
|
||||||
depth={0}
|
|
||||||
onToggleDir={toggleDir}
|
|
||||||
onSelectFile={selectFile}
|
|
||||||
setHighlightedPath={setHighlightedPath}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col min-h-0">
|
|
||||||
{!openFile && (
|
|
||||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">
|
|
||||||
Select a file to view
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{openFile && (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center justify-between px-2 py-1 border-b border-border bg-muted/20 shrink-0">
|
|
||||||
<span
|
|
||||||
className="text-xs font-mono truncate"
|
|
||||||
title={openFile}
|
|
||||||
>
|
|
||||||
{basename(openFile)}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={closeOpenFile}
|
|
||||||
className="p-0.5 hover:bg-muted rounded shrink-0"
|
|
||||||
aria-label="Close file"
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto relative">
|
|
||||||
{viewer?.state === 'loading' && (
|
|
||||||
<div className="text-xs text-muted-foreground px-2 py-1.5">
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{viewer?.state === 'error' && (
|
|
||||||
<div className="text-xs text-destructive px-2 py-1.5">
|
|
||||||
{viewer.error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{viewer?.state === 'ready' && viewer.result && (
|
|
||||||
<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 && (
|
|
||||||
<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.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<FileViewer
|
|
||||||
code={viewer.result.content}
|
|
||||||
lang={inferLanguage(openFile)}
|
|
||||||
selectedLines={selectedLines}
|
|
||||||
onLineClick={handleLineClick}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import type { Pane } from '@/api/types';
|
|
||||||
import { X } from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
pane: Pane;
|
|
||||||
onClose: () => void;
|
|
||||||
className?: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PaneShell({ pane, onClose, className, children }: Props) {
|
|
||||||
const label = pane.kind === 'chat' ? 'Chat' : 'Files';
|
|
||||||
return (
|
|
||||||
<div className={cn('flex flex-col h-full min-h-0 border-r border-border last:border-r-0', className)}>
|
|
||||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-muted/30">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-0.5 hover:bg-muted rounded"
|
|
||||||
aria-label="Close pane"
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -57,6 +57,11 @@ export interface AttachChatFileEvent {
|
|||||||
attachment: Omit<Attachment, 'id'>;
|
attachment: Omit<Attachment, 'id'>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OpenChatInActivePaneEvent {
|
||||||
|
type: 'open_chat_in_active_pane';
|
||||||
|
chat_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionArchivedEvent {
|
export interface SessionArchivedEvent {
|
||||||
type: 'session_archived';
|
type: 'session_archived';
|
||||||
session_id: string;
|
session_id: string;
|
||||||
@@ -120,6 +125,7 @@ export type SessionEvent =
|
|||||||
| SessionLoadedEvent
|
| SessionLoadedEvent
|
||||||
| OpenFileInBrowserEvent
|
| OpenFileInBrowserEvent
|
||||||
| AttachChatFileEvent
|
| AttachChatFileEvent
|
||||||
|
| OpenChatInActivePaneEvent
|
||||||
| SessionArchivedEvent
|
| SessionArchivedEvent
|
||||||
| ChatCreatedEvent
|
| ChatCreatedEvent
|
||||||
| ChatUpdatedEvent
|
| ChatUpdatedEvent
|
||||||
|
|||||||
61
apps/web/src/hooks/useActivePane.ts
Normal file
61
apps/web/src/hooks/useActivePane.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { WorkspacePaneKind } from '@/api/types';
|
||||||
|
|
||||||
|
export interface ActivePaneSnapshot {
|
||||||
|
sessionId: string | null;
|
||||||
|
paneId: string | null;
|
||||||
|
kind: WorkspacePaneKind | null;
|
||||||
|
activeFile: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: ActivePaneSnapshot = {
|
||||||
|
sessionId: null,
|
||||||
|
paneId: null,
|
||||||
|
kind: null,
|
||||||
|
activeFile: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let current: ActivePaneSnapshot = EMPTY;
|
||||||
|
const subs = new Set<() => void>();
|
||||||
|
|
||||||
|
function notify(): void {
|
||||||
|
for (const sub of subs) {
|
||||||
|
try {
|
||||||
|
sub();
|
||||||
|
} catch {
|
||||||
|
// swallow — one bad listener shouldn't break others
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSame(a: ActivePaneSnapshot, b: ActivePaneSnapshot): boolean {
|
||||||
|
return (
|
||||||
|
a.sessionId === b.sessionId &&
|
||||||
|
a.paneId === b.paneId &&
|
||||||
|
a.kind === b.kind &&
|
||||||
|
a.activeFile === b.activeFile
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setActivePaneInfo(next: ActivePaneSnapshot): void {
|
||||||
|
if (isSame(current, next)) return;
|
||||||
|
current = next;
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearActivePane(): void {
|
||||||
|
setActivePaneInfo(EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActivePane(): ActivePaneSnapshot {
|
||||||
|
const [snap, setSnap] = useState<ActivePaneSnapshot>(current);
|
||||||
|
useEffect(() => {
|
||||||
|
const sub = () => setSnap(current);
|
||||||
|
subs.add(sub);
|
||||||
|
sub();
|
||||||
|
return () => {
|
||||||
|
subs.delete(sub);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return snap;
|
||||||
|
}
|
||||||
175
apps/web/src/hooks/useSessionChats.ts
Normal file
175
apps/web/src/hooks/useSessionChats.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import type { Chat } from '@/api/types';
|
||||||
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
|
|
||||||
|
export interface UseSessionChatsOpts {
|
||||||
|
removeChatFromPanes: (chatId: string) => void;
|
||||||
|
openChatInPane: (paneIdx: number, chatId: string) => void;
|
||||||
|
// Thin wrapper around openChatInPane(activePaneIdxRef.current, chatId);
|
||||||
|
// built by Workspace and passed in so this hook doesn't need to know
|
||||||
|
// about pane indexing.
|
||||||
|
openChatInActivePane: (chatId: string) => void;
|
||||||
|
initializeFirstChatIfEmpty: (chatId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseSessionChatsResult {
|
||||||
|
chats: Chat[];
|
||||||
|
setChats: React.Dispatch<React.SetStateAction<Chat[]>>;
|
||||||
|
createChat: (paneIdx: number) => Promise<void>;
|
||||||
|
archiveChat: (chatId: string) => Promise<void>;
|
||||||
|
unarchiveChat: (chatId: string) => Promise<void>;
|
||||||
|
deleteChat: (chatId: string) => Promise<void>;
|
||||||
|
renameChat: (chatId: string, name: string) => Promise<void>;
|
||||||
|
handleLandingSend: (paneIdx: number, content: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionChats(
|
||||||
|
sessionId: string,
|
||||||
|
opts: UseSessionChatsOpts,
|
||||||
|
): UseSessionChatsResult {
|
||||||
|
const [chats, setChats] = useState<Chat[]>([]);
|
||||||
|
const chatsRef = useRef<Chat[]>([]);
|
||||||
|
chatsRef.current = chats;
|
||||||
|
|
||||||
|
// Stable refs to opts callbacks so the subscription effect — which only
|
||||||
|
// re-runs on sessionId change — always sees the latest closures without
|
||||||
|
// unsubscribe/resubscribe churn.
|
||||||
|
const removeChatFromPanesRef = useRef(opts.removeChatFromPanes);
|
||||||
|
removeChatFromPanesRef.current = opts.removeChatFromPanes;
|
||||||
|
const openChatInPaneRef = useRef(opts.openChatInPane);
|
||||||
|
openChatInPaneRef.current = opts.openChatInPane;
|
||||||
|
const openChatInActivePaneRef = useRef(opts.openChatInActivePane);
|
||||||
|
openChatInActivePaneRef.current = opts.openChatInActivePane;
|
||||||
|
const initializeFirstChatIfEmptyRef = useRef(opts.initializeFirstChatIfEmpty);
|
||||||
|
initializeFirstChatIfEmptyRef.current = opts.initializeFirstChatIfEmpty;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
api.chats.listForSession(sessionId).then((list) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setChats(list);
|
||||||
|
const openChat = list.find((c) => c.status === 'open');
|
||||||
|
if (openChat) {
|
||||||
|
initializeFirstChatIfEmptyRef.current(openChat.id);
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return sessionEvents.subscribe((event) => {
|
||||||
|
if (event.type === 'chat_created' && event.session_id === sessionId) {
|
||||||
|
setChats((prev) => {
|
||||||
|
if (prev.some((c) => c.id === event.chat.id)) return prev;
|
||||||
|
return [event.chat, ...prev];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (event.type === 'chat_updated') {
|
||||||
|
setChats((prev) => prev.map((c) =>
|
||||||
|
c.id === event.chat_id ? { ...c, name: event.name, updated_at: event.updated_at } : c
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (event.type === 'chat_archived') {
|
||||||
|
setChats((prev) => prev.map((c) =>
|
||||||
|
c.id === event.chat_id ? { ...c, status: 'archived' as const } : c
|
||||||
|
));
|
||||||
|
removeChatFromPanesRef.current(event.chat_id);
|
||||||
|
}
|
||||||
|
if (event.type === 'chat_unarchived') {
|
||||||
|
setChats((prev) => {
|
||||||
|
if (prev.some((c) => c.id === event.chat.id)) {
|
||||||
|
return prev.map((c) => c.id === event.chat.id ? { ...c, status: 'open' as const } : c);
|
||||||
|
}
|
||||||
|
return [event.chat, ...prev];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (event.type === 'chat_deleted') {
|
||||||
|
setChats((prev) => prev.filter((c) => c.id !== event.chat_id));
|
||||||
|
removeChatFromPanesRef.current(event.chat_id);
|
||||||
|
}
|
||||||
|
if (event.type === 'open_chat_in_active_pane') {
|
||||||
|
openChatInActivePaneRef.current(event.chat_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
const createChat = useCallback(async (paneIdx: number) => {
|
||||||
|
try {
|
||||||
|
const chat = await api.chats.create(sessionId);
|
||||||
|
// Optimistic local insert; the WS chat_created echo will be deduped by id.
|
||||||
|
setChats((prev) => {
|
||||||
|
if (prev.some((c) => c.id === chat.id)) return prev;
|
||||||
|
return [chat, ...prev];
|
||||||
|
});
|
||||||
|
openChatInPaneRef.current(paneIdx, chat.id);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to create chat');
|
||||||
|
}
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
const archiveChat = useCallback(async (chatId: string) => {
|
||||||
|
try {
|
||||||
|
await api.chats.archive(chatId);
|
||||||
|
// Server publishes chat_archived; bus forwarder updates state.
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to archive chat');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const unarchiveChat = useCallback(async (chatId: string) => {
|
||||||
|
try {
|
||||||
|
await api.chats.unarchive(chatId);
|
||||||
|
// Server publishes chat_unarchived.
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to restore chat');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteChat = useCallback(async (chatId: string) => {
|
||||||
|
try {
|
||||||
|
await api.chats.remove(chatId);
|
||||||
|
setChats((prev) => prev.filter((c) => c.id !== chatId));
|
||||||
|
removeChatFromPanesRef.current(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 handleLandingSend = useCallback(async (paneIdx: number, content: string) => {
|
||||||
|
try {
|
||||||
|
const chat = await api.chats.create(sessionId);
|
||||||
|
setChats((prev) => {
|
||||||
|
if (prev.some((c) => c.id === chat.id)) return prev;
|
||||||
|
return [chat, ...prev];
|
||||||
|
});
|
||||||
|
openChatInPaneRef.current(paneIdx, chat.id);
|
||||||
|
await api.messages.send(chat.id, content);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to send');
|
||||||
|
}
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
chats,
|
||||||
|
setChats,
|
||||||
|
createChat,
|
||||||
|
archiveChat,
|
||||||
|
unarchiveChat,
|
||||||
|
deleteChat,
|
||||||
|
renameChat,
|
||||||
|
handleLandingSend,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -148,6 +148,9 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
|||||||
return prev;
|
return prev;
|
||||||
case 'attach_chat_file':
|
case 'attach_chat_file':
|
||||||
return prev;
|
return prev;
|
||||||
|
case 'open_chat_in_active_pane':
|
||||||
|
// Consumed by Workspace; sidebar has no business with pane state.
|
||||||
|
return prev;
|
||||||
case 'session_archived': {
|
case 'session_archived': {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const projects = prev.projects.map((p) => {
|
const projects = prev.projects.map((p) => {
|
||||||
|
|||||||
339
apps/web/src/hooks/useWorkspacePanes.ts
Normal file
339
apps/web/src/hooks/useWorkspacePanes.ts
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import type { DragEvent } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { WorkspacePane } from '@/api/types';
|
||||||
|
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
|
||||||
|
|
||||||
|
export const MAX_PANES = 5;
|
||||||
|
const STORAGE_KEY = 'boocode.workspace.panes';
|
||||||
|
|
||||||
|
function generateId(): string {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyPane(): WorkspacePane {
|
||||||
|
return { id: generateId(), kind: 'empty', chatIds: [], activeChatIdx: -1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatPane(chatId: string): WorkspacePane {
|
||||||
|
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPanes(sessionId: string): WorkspacePane[] | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as WorkspacePane[];
|
||||||
|
if (!Array.isArray(parsed) || parsed.length === 0) return null;
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePanes(sessionId: string, panes: WorkspacePane[]): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`${STORAGE_KEY}.${sessionId}`, JSON.stringify(panes));
|
||||||
|
} catch { /* quota or disabled */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseWorkspacePanesResult {
|
||||||
|
panes: WorkspacePane[];
|
||||||
|
activePaneIdx: number;
|
||||||
|
setActivePaneIdx: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
activePaneIdxRef: React.MutableRefObject<number>;
|
||||||
|
openChatInPane: (paneIdx: number, chatId: string) => void;
|
||||||
|
switchTab: (paneIdx: number, tabIdx: number) => void;
|
||||||
|
removeTab: (paneIdx: number, chatId: string) => void;
|
||||||
|
closeOtherTabs: (paneIdx: number, keepChatId: string) => void;
|
||||||
|
closeTabsToRight: (paneIdx: number, pivotChatId: string) => void;
|
||||||
|
closeAllTabs: (paneIdx: number) => void;
|
||||||
|
showLandingPage: (paneIdx: number) => void;
|
||||||
|
addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => void;
|
||||||
|
removePane: (idx: number) => void;
|
||||||
|
removeChatFromPanes: (chatId: string) => void;
|
||||||
|
initializeFirstChatIfEmpty: (chatId: string) => void;
|
||||||
|
handlePaneDragStart: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
|
||||||
|
handlePaneDragOver: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
|
||||||
|
handlePaneDragLeave: () => void;
|
||||||
|
handlePaneDrop: (targetIdx: number) => (e: DragEvent<HTMLDivElement>) => void;
|
||||||
|
handlePaneDragEnd: () => void;
|
||||||
|
dragOverIdx: number | null;
|
||||||
|
draggingIdxRef: React.MutableRefObject<number | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||||
|
const [panes, setPanes] = useState<WorkspacePane[]>(() => {
|
||||||
|
return loadPanes(sessionId) ?? [emptyPane()];
|
||||||
|
});
|
||||||
|
const [activePaneIdx, setActivePaneIdx] = useState(0);
|
||||||
|
const draggingIdxRef = useRef<number | null>(null);
|
||||||
|
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
savePanes(sessionId, panes);
|
||||||
|
}, [sessionId, panes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const active = panes[activePaneIdx];
|
||||||
|
if (!active) {
|
||||||
|
clearActivePane();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActivePaneInfo({
|
||||||
|
sessionId,
|
||||||
|
paneId: active.id,
|
||||||
|
kind: active.kind,
|
||||||
|
activeFile: null,
|
||||||
|
});
|
||||||
|
}, [sessionId, panes, activePaneIdx]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearActivePane();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activePaneIdxRef = useRef(activePaneIdx);
|
||||||
|
activePaneIdxRef.current = activePaneIdx;
|
||||||
|
|
||||||
|
const openChatInPane = useCallback((paneIdx: number, chatId: string) => {
|
||||||
|
setPanes((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
const pane = next[paneIdx]!;
|
||||||
|
const existing = pane.chatIds.indexOf(chatId);
|
||||||
|
if (existing >= 0) {
|
||||||
|
next[paneIdx] = { ...pane, kind: 'chat', chatId, activeChatIdx: existing };
|
||||||
|
} else {
|
||||||
|
const newIds = [...pane.chatIds, chatId];
|
||||||
|
next[paneIdx] = {
|
||||||
|
...pane,
|
||||||
|
kind: 'chat',
|
||||||
|
chatId,
|
||||||
|
chatIds: newIds,
|
||||||
|
activeChatIdx: newIds.length - 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setActivePaneIdx(paneIdx);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const switchTab = useCallback((paneIdx: number, tabIdx: number) => {
|
||||||
|
setPanes((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
const pane = next[paneIdx]!;
|
||||||
|
const chatId = pane.chatIds[tabIdx];
|
||||||
|
if (!chatId) return prev;
|
||||||
|
next[paneIdx] = { ...pane, chatId, activeChatIdx: tabIdx };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeTab = useCallback((paneIdx: number, chatId: string) => {
|
||||||
|
setPanes((prev) => {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keep only the right-clicked tab open in this pane.
|
||||||
|
const closeOtherTabs = useCallback((paneIdx: number, keepChatId: string) => {
|
||||||
|
setPanes((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
const pane = next[paneIdx]!;
|
||||||
|
const keepIdx = pane.chatIds.indexOf(keepChatId);
|
||||||
|
if (keepIdx < 0) return prev;
|
||||||
|
next[paneIdx] = {
|
||||||
|
...pane,
|
||||||
|
kind: 'chat',
|
||||||
|
chatId: keepChatId,
|
||||||
|
chatIds: [keepChatId],
|
||||||
|
activeChatIdx: 0,
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close every tab to the right of the right-clicked one.
|
||||||
|
const closeTabsToRight = useCallback((paneIdx: number, pivotChatId: string) => {
|
||||||
|
setPanes((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
const pane = next[paneIdx]!;
|
||||||
|
const pivotIdx = pane.chatIds.indexOf(pivotChatId);
|
||||||
|
if (pivotIdx < 0 || pivotIdx === pane.chatIds.length - 1) return prev;
|
||||||
|
const nextIds = pane.chatIds.slice(0, pivotIdx + 1);
|
||||||
|
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
|
||||||
|
next[paneIdx] = {
|
||||||
|
...pane,
|
||||||
|
chatIds: nextIds,
|
||||||
|
activeChatIdx: nextActiveIdx,
|
||||||
|
chatId: nextIds[nextActiveIdx],
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close every tab in this pane; land on landing page.
|
||||||
|
const closeAllTabs = useCallback((paneIdx: number) => {
|
||||||
|
setPanes((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
const pane = next[paneIdx]!;
|
||||||
|
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Replaces a single empty default pane with a chat pane. Used by the initial
|
||||||
|
// chat fetch to land on the most-recent open chat if no saved pane state.
|
||||||
|
const initializeFirstChatIfEmpty = useCallback((chatId: string) => {
|
||||||
|
setPanes((prev) => {
|
||||||
|
if (prev.length === 1 && prev[0]!.kind === 'empty') {
|
||||||
|
return [chatPane(chatId)];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeChatFromPanes = useCallback((chatId: string) => {
|
||||||
|
setPanes((prev) => prev.map((p) => {
|
||||||
|
const idx = p.chatIds.indexOf(chatId);
|
||||||
|
if (idx < 0) return p;
|
||||||
|
const nextIds = p.chatIds.filter((id) => id !== chatId);
|
||||||
|
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],
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePaneDragStart = useCallback(
|
||||||
|
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
draggingIdxRef.current = idx;
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', String(idx));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePaneDragOver = useCallback(
|
||||||
|
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
if (draggingIdxRef.current === null) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
if (dragOverIdx !== idx) setDragOverIdx(idx);
|
||||||
|
},
|
||||||
|
[dragOverIdx]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePaneDragLeave = useCallback(() => {
|
||||||
|
setDragOverIdx(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePaneDrop = useCallback(
|
||||||
|
(targetIdx: number) => (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const fromIdx = draggingIdxRef.current;
|
||||||
|
draggingIdxRef.current = null;
|
||||||
|
setDragOverIdx(null);
|
||||||
|
if (fromIdx === null || fromIdx === targetIdx) return;
|
||||||
|
setPanes((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
const [moved] = next.splice(fromIdx, 1);
|
||||||
|
if (!moved) return prev;
|
||||||
|
next.splice(targetIdx, 0, moved);
|
||||||
|
// Keep active selection on the same logical pane (the one being dragged).
|
||||||
|
setActivePaneIdx(targetIdx);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePaneDragEnd = useCallback(() => {
|
||||||
|
draggingIdxRef.current = null;
|
||||||
|
setDragOverIdx(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
panes,
|
||||||
|
activePaneIdx,
|
||||||
|
setActivePaneIdx,
|
||||||
|
activePaneIdxRef,
|
||||||
|
openChatInPane,
|
||||||
|
switchTab,
|
||||||
|
removeTab,
|
||||||
|
closeOtherTabs,
|
||||||
|
closeTabsToRight,
|
||||||
|
closeAllTabs,
|
||||||
|
showLandingPage,
|
||||||
|
addSplitPane,
|
||||||
|
removePane,
|
||||||
|
removeChatFromPanes,
|
||||||
|
initializeFirstChatIfEmpty,
|
||||||
|
handlePaneDragStart,
|
||||||
|
handlePaneDragOver,
|
||||||
|
handlePaneDragLeave,
|
||||||
|
handlePaneDrop,
|
||||||
|
handlePaneDragEnd,
|
||||||
|
dragOverIdx,
|
||||||
|
draggingIdxRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
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 { ChevronLeft } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { Session as SessionType } from '@/api/types';
|
import type { Project, Session as SessionType } from '@/api/types';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
|
import { useActivePane } from '@/hooks/useActivePane';
|
||||||
import { Workspace } from '@/components/Workspace';
|
import { Workspace } from '@/components/Workspace';
|
||||||
import { ModelPicker } from '@/components/ModelPicker';
|
import { ModelPicker } from '@/components/ModelPicker';
|
||||||
|
|
||||||
@@ -11,12 +12,15 @@ export function Session() {
|
|||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [session, setSession] = useState<SessionType | null>(null);
|
const [session, setSession] = useState<SessionType | null>(null);
|
||||||
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [editingName, setEditingName] = useState(false);
|
const [editingName, setEditingName] = useState(false);
|
||||||
|
const active = useActivePane();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
setSession(null);
|
setSession(null);
|
||||||
|
setProject(null);
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
api.sessions
|
api.sessions
|
||||||
.get(id)
|
.get(id)
|
||||||
@@ -24,16 +28,17 @@ export function Session() {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setSession(s);
|
setSession(s);
|
||||||
setName(s.name);
|
setName(s.name);
|
||||||
// Emit unconditionally — the sidebar's session_loaded handler
|
|
||||||
// updates activeSession; redundant when the session is already in
|
|
||||||
// the recent_sessions cache but harmless. This lets the sidebar
|
|
||||||
// highlight the parent project for deep-linked sessions that
|
|
||||||
// aren't in the cache.
|
|
||||||
sessionEvents.emit({
|
sessionEvents.emit({
|
||||||
type: 'session_loaded',
|
type: 'session_loaded',
|
||||||
session_id: id,
|
session_id: id,
|
||||||
project_id: s.project_id,
|
project_id: s.project_id,
|
||||||
});
|
});
|
||||||
|
// Load project for breadcrumb. Listing is fine — small N, cached by client.
|
||||||
|
api.projects.list().then((projects) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const p = projects.find((x) => x.id === s.project_id);
|
||||||
|
if (p) setProject(p);
|
||||||
|
}).catch(() => {});
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return () => {
|
return () => {
|
||||||
@@ -68,26 +73,33 @@ export function Session() {
|
|||||||
}
|
}
|
||||||
const updated = await api.sessions.update(id, { name: trimmed });
|
const updated = await api.sessions.update(id, { name: trimmed });
|
||||||
setSession(updated);
|
setSession(updated);
|
||||||
sessionEvents.emit({
|
|
||||||
type: 'session_renamed',
|
|
||||||
session_id: id,
|
|
||||||
name: trimmed,
|
|
||||||
});
|
|
||||||
setEditingName(false);
|
setEditingName(false);
|
||||||
|
// Server publishes session_renamed via broker.publishUser; no local emit needed.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Workspace only sets activeFile for file-browser panes; checking it alone
|
||||||
|
// suffices and is forward-compatible with future pane kinds.
|
||||||
|
const showActiveFile = active.sessionId === id && !!active.activeFile;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
<header className="border-b px-4 py-2 flex items-center gap-2 shrink-0">
|
<header className="border-b px-4 py-2 flex items-center gap-1.5 shrink-0 text-sm">
|
||||||
{session && (
|
<Link to="/" className="text-muted-foreground hover:text-foreground">
|
||||||
<Link
|
Projects
|
||||||
to={`/project/${session.project_id}`}
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
aria-label="Back to project"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="size-4" />
|
|
||||||
</Link>
|
</Link>
|
||||||
|
<ChevronRight className="size-3 text-muted-foreground/60" />
|
||||||
|
{project ? (
|
||||||
|
<Link
|
||||||
|
to={`/project/${project.id}`}
|
||||||
|
className="text-muted-foreground hover:text-foreground truncate max-w-[200px]"
|
||||||
|
title={project.name}
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/60">…</span>
|
||||||
)}
|
)}
|
||||||
|
<ChevronRight className="size-3 text-muted-foreground/60" />
|
||||||
{editingName ? (
|
{editingName ? (
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -106,14 +118,27 @@ export function Session() {
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-sm font-medium hover:underline"
|
className="text-sm font-medium hover:underline truncate max-w-[280px]"
|
||||||
onClick={() => setEditingName(true)}
|
onClick={() => setEditingName(true)}
|
||||||
|
title={session?.name ?? ''}
|
||||||
>
|
>
|
||||||
{session?.name ?? '…'}
|
{session?.name ?? '…'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{showActiveFile && active.activeFile && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground/40 mx-1">·</span>
|
||||||
|
<span
|
||||||
|
className="text-xs font-mono text-muted-foreground truncate max-w-[320px]"
|
||||||
|
title={active.activeFile}
|
||||||
|
>
|
||||||
|
{active.activeFile}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
{session && (
|
{session && (
|
||||||
|
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1">
|
||||||
<ModelPicker
|
<ModelPicker
|
||||||
value={session.model}
|
value={session.model}
|
||||||
onChange={async (model) => {
|
onChange={async (model) => {
|
||||||
@@ -121,6 +146,7 @@ export function Session() {
|
|||||||
setSession(updated);
|
setSession(updated);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
Reference in New Issue
Block a user