Compare commits

..

20 Commits

Author SHA1 Message Date
6d9515b8a5 batch3 final: backfill default chat pane for pre-batch3 sessions
Sessions created before Batch 3 have no rows in session_panes, so the
Workspace renders "No panes" on first open. Idempotent INSERT inserts
a default chat pane at position 0 for any session without one. NOT
EXISTS guard makes the statement a no-op after the first run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:02:51 +00:00
89d685105a batch3 T8 review fix: preserve cloneElement key in linkifyChildren
cloneElement does not carry el.key through unless explicitly passed.
Without it, react-markdown's inline-element siblings (strong/em/text)
lose their reconciler keys on every render, causing potential diffing
churn. Pass el.key (with fallback) explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:58:45 +00:00
eca4aa8382 batch3 T8: chat->file click, Session.tsx rewires to Workspace, sidebar polish
- MessageBubble & ToolCallCard: detect path-like strings in rendered text
  via regex requiring slash+extension; clicks dispatch open_file_in_browser
- Session.tsx: now renders <Workspace sessionId projectId>; on mount,
  emits session_loaded so sidebar can highlight even deep-linked sessions
  not in the recent_sessions cache
- ProjectSidebar: active project's chevron visually disabled (50% opacity,
  cursor-not-allowed) and click no-op; activeSession from useSidebar used
  as fallback when active session isn't in cache

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:55:52 +00:00
60a0036850 batch3 T7 review fix: serialize open_file_in_browser to avoid double pane
When no file_browser pane exists, two rapid open_file_in_browser events
could both trigger create() since the ref check happens before the first
create resolves. Add a creating flag/promise so the second event waits
for the first create then updates the newly-created pane's state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:50:30 +00:00
fb31e63d10 batch3 T7: pane components — PaneShell, ChatPane, FileBrowserPane, PaneTab, Workspace
- PaneShell: per-pane chrome (kind label + close)
- ChatPane: extracts message+input rendering, subscribes to useSessionStream
- FileBrowserPane: tree + filter (debounced 100ms) + inline viewer via Shiki
- PaneTab: tab with kind icon + context menu (Split, Close, Close others,
  Close to right, Close all) via shadcn ContextMenu
- Workspace: tab strip + pane grid (CSS grid repeat(N,1fr)), native HTML5
  drag-to-reorder, "+" button (disabled at 5), subscribes to
  open_file_in_browser (focus existing file-browser pane or spawn one)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:46:14 +00:00
2de67fe091 batch3 T6 typing fix: inline narrowing for body.state guard
Boolean indirection (const stateOnly = ...; if (stateOnly)) didn't carry
the narrowing to body.state inside the branch. Inline the check so
TypeScript narrows correctly and the explicit cast on pendingState
becomes unnecessary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:37:02 +00:00
0a7e52326c batch3 T6 review fixes: remove rollback closure, flush-error resync
- remove: capture snapshot inside setPanes functional updater to avoid
  stale-closure rollback under concurrent renders
- flushPendingState: call refresh() on PATCH failure so server truth and
  optimistic local state can't silently diverge
- Drop body.state! non-null assertion via narrowed local

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:35:27 +00:00
b29555ee28 batch3 T6: usePanes hook + Shiki integration in CodeBlock
- hooks/usePanes: per-session panes CRUD; debounced (300ms) state PATCH;
  immediate position-change PATCH with refresh
- CodeBlock: shiki async highlighting via codeToHtml + github-dark theme;
  LANG_MAP for ts/tsx/js/jsx/py/go/rs/rb/java/c/cpp/cs/php/sh/yaml/json/
  toml/md/sql/dockerfile/html/css; falls back to plain pre on unknown lang
  or async failure
- package.json: + shiki

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:32:04 +00:00
e82e5670ee batch3 T5 review fixes: backoff off-by-one + activeSession shape + headers
- useUserEvents: double delay before scheduling, producing 1/2/4/8/16/30s
- useSidebar: activeSessionProjectId -> activeSession {session_id,project_id}
  so consumers can verify URL match and ignore stale values
- api.panes.create/update: drop redundant Content-Type (request helper sets)
- useUserEvents: minimal type guard on incoming WS frame before emit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:28:11 +00:00
8f0e1245d8 batch3 T5: frontend foundation — Pane types, panes API, user-events WS
- Mirror Pane/PaneState/UserStream types
- api.panes.* CRUD methods
- sessionEvents adds session_updated, session_loaded, open_file_in_browser
- useUserEvents hook: single app-level WS to /api/ws/user with reconnect
- useSidebar handles session_updated (in-place patch + re-sort) and
  session_loaded (active-project highlight gap fix); open_file_in_browser
  is a deliberate no-op here, consumed by Workspace later
- App.tsx mounts useUserEvents once

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:24:25 +00:00
015350b2e7 batch3 T4 review polish: drop Stats hack, document cache race + total counting
- Drop unused Stats type import and its no-op suppression expression
- Comment getProjectFiles concurrent-miss race (benign, accepted)
- Comment findFiles deliberate post-limit counting (differs from grep)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:20:45 +00:00
89f1b7e862 batch3 T4 review fixes: harmonize find_files cap; delegate to file_ops
- file_ops.MAX_FIND_RESULTS: 1000 -> 200 to match existing tool cap and
  preserve LLM behavior
- tools.find_files now delegates to file_ops.findFiles (parallels how
  grep already delegates); drops ~50 LOC of duplicated path resolution
  and rg subprocess
- Drop unused basename import in file_ops

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:18:44 +00:00
890d229875 batch3 T4: file_ops + file_index services; UI endpoints; tools refactor
- services/file_ops.ts: shared listDir/viewFile/grep/findFiles core
- services/file_index.ts: per-project flat file list cached on mtime of
  project root + .git/HEAD + .git/index (rg --files honors .gitignore)
- services/tools.ts: tools delegate to file_ops, output format unchanged
- routes/projects.ts: GET /list_dir, /view_file, /files endpoints
- web client: api.projects.listDir/viewFile/files + mirrored types

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:15:48 +00:00
124beae2bc batch3 T3 review fix: swap req.user! for requireUser; document ws/user guard
Replaces six non-null assertions on req.user with the requireUser helper
from auth.ts, which throws a descriptive error if the auth hook didn't
populate req.user. Adds an inline comment in /api/ws/user explaining the
manual auth check is defensive (the global hook already enforces auth).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:10:20 +00:00
8fc525eab9 batch3 T3: broker user channel + /api/ws/user + project/session/inference emits
- broker.subscribeUser/publishUser via separate user topics map
- /api/ws/user WS route subscribes to the user channel
- projects/sessions POST/DELETE handlers emit lifecycle frames
- inference 3 terminal-state sites emit session_updated with RETURNING

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:06:31 +00:00
d88b3348a2 batch3 T2 review fix: move PATCH count+bounds check inside sql.begin
A concurrent DELETE between the count read and the transaction could allow
an invalid position value to slip in. Mirror the POST fix by validating
count + bounds inside the transaction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:00:44 +00:00
493df5f25d batch3 T2 review fixes: movePane sentinel + count race + PATCH atomicity
- Move sentinel from -1 to -100 (outside the negate range) so moves from
  position 0 no longer collide with negated row at -1
- Pull count check + position validation inside sql.begin in POST so two
  concurrent inserts can't both pass the max-5 guard
- Wrap movePane + state UPDATE in a single transaction in PATCH so partial
  failures roll back consistently

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:58:49 +00:00
2bc626a40a batch3 T2: panes CRUD route + default chat pane on session POST
Adds /api/sessions/:id/panes (GET, POST), /api/panes/:id (PATCH, DELETE)
with transactional position-shift logic (negate-and-restore pattern to
avoid UNIQUE collisions). Max 5 panes per session enforced.

Sessions.POST now creates the session and a default Chat pane at position
0 atomically via sql.begin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:53:21 +00:00
9dd30efc2e batch3 T1 review fixes: Pane discriminated union + index naming
- Restructure Pane as a tagged union over kind so checking kind narrows state
- Rename session_panes_session_idx -> idx_session_panes_session for naming
  consistency with idx_sessions_project, idx_messages_session

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:49:54 +00:00
08ee57d6a1 batch3 T1: session_panes schema + Pane/UserStreamFrame types + sidebar project_id
Adds the session_panes table, Pane/PaneState/PaneCreate/PaneUpdate types,
UserStreamFrame discriminated union, and extends SidebarSession with
project_id (also added to the sidebar SELECT).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:46:57 +00:00
34 changed files with 3090 additions and 234 deletions

View File

@@ -13,6 +13,7 @@ import { registerMessageRoutes } from './routes/messages.js';
import { registerSidebarRoutes } from './routes/sidebar.js';
import { registerWebSocket } from './routes/ws.js';
import { registerModelRoutes } from './routes/models.js';
import { registerPaneRoutes } from './routes/panes.js';
import { createInferenceRunner } from './services/inference.js';
import { createBroker } from './services/broker.js';
@@ -36,24 +37,31 @@ async function main() {
return { status: dbOk ? 'ok' : 'degraded', db: dbOk };
});
registerProjectRoutes(app, sql, config);
registerSessionRoutes(app, sql, config);
const broker = createBroker();
registerProjectRoutes(app, sql, config, broker);
registerSessionRoutes(app, sql, config, broker);
registerSettingsRoutes(app, sql);
registerModelRoutes(app, config);
registerSidebarRoutes(app, sql);
registerPaneRoutes(app, sql);
const broker = createBroker();
const inference = createInferenceRunner({
sql,
config,
log: app.log,
publish: (sessionId, frame) => {
broker.publish(sessionId, frame as unknown as Record<string, unknown> & { type: string });
const inference = createInferenceRunner(
{
sql,
config,
log: app.log,
publish: (sessionId, frame) => {
broker.publish(sessionId, frame as unknown as Record<string, unknown> & { type: string });
},
},
});
(user, frame) => {
broker.publishUser(user, frame as unknown as Record<string, unknown> & { type: string });
}
);
registerMessageRoutes(app, sql, {
enqueueInference: (sessionId, assistantId) => {
inference.enqueue(sessionId, assistantId);
enqueueInference: (sessionId, assistantId, user) => {
inference.enqueue(sessionId, assistantId, user);
},
publishUserMessage: (sessionId, userMessageId, content) => {
broker.publish(sessionId, {

View File

@@ -2,13 +2,14 @@ import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Message, Session } from '../types/api.js';
import { requireUser } from '../auth.js';
const SendBody = z.object({
content: z.string().min(1).max(64_000),
});
interface MessageHandlers {
enqueueInference: (sessionId: string, assistantMessageId: string) => void;
enqueueInference: (sessionId: string, assistantMessageId: string, user: string) => void;
publishUserMessage: (
sessionId: string,
userMessageId: string,
@@ -76,7 +77,7 @@ export function registerMessageRoutes(
result.user_message_id,
parsed.data.content
);
handlers.enqueueInference(req.params.id, result.assistant_message_id);
handlers.enqueueInference(req.params.id, result.assistant_message_id, requireUser(req));
reply.code(202);
return result;
@@ -132,7 +133,7 @@ export function registerMessageRoutes(
});
handlers.publishMessagesDeleted(sessionId, deletedIds);
handlers.enqueueInference(sessionId, newAssistantId);
handlers.enqueueInference(sessionId, newAssistantId, requireUser(req));
reply.code(202);
return { assistant_message_id: newAssistantId };

View File

@@ -0,0 +1,217 @@
import type { FastifyInstance } from 'fastify';
import type { TransactionSql } from 'postgres';
import type { Sql } from '../db.js';
import type { Pane, PaneCreateRequest, PaneUpdateRequest } from '../types/api.js';
const VALID_KINDS = new Set(['chat', 'file_browser']);
const MAX_PANES = 5;
async function movePane(
tx: TransactionSql,
paneId: string,
sid: string,
oldPos: number,
newPos: number
): Promise<void> {
if (oldPos === newPos) return;
// Move target pane to a sentinel well outside the negate range [-MAX_PANES, -1]
// so it never collides with negated rows during the shift steps.
await tx`UPDATE session_panes SET position = -100 WHERE id = ${paneId}`;
if (newPos > oldPos) {
await tx`UPDATE session_panes SET position = -position
WHERE session_id = ${sid} AND position > ${oldPos} AND position <= ${newPos}`;
await tx`UPDATE session_panes SET position = -position - 1
WHERE session_id = ${sid} AND position < 0 AND id != ${paneId}`;
} else {
await tx`UPDATE session_panes SET position = -position - 2
WHERE session_id = ${sid} AND position >= ${newPos} AND position < ${oldPos}`;
await tx`UPDATE session_panes SET position = -position - 1
WHERE session_id = ${sid} AND position < 0 AND id != ${paneId}`;
}
await tx`UPDATE session_panes SET position = ${newPos} WHERE id = ${paneId}`;
}
export function registerPaneRoutes(app: FastifyInstance, sql: Sql): void {
// GET /api/sessions/:id/panes — list panes ordered by position ASC
app.get<{ Params: { id: string } }>(
'/api/sessions/:id/panes',
async (req, reply) => {
const sessionRows = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`;
if (sessionRows.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
const panes = await sql<Pane[]>`
SELECT id, session_id, position, kind, state, created_at
FROM session_panes
WHERE session_id = ${req.params.id}
ORDER BY position ASC
`;
return { panes };
}
);
// POST /api/sessions/:id/panes — create a new pane
app.post<{ Params: { id: string } }>(
'/api/sessions/:id/panes',
async (req, reply) => {
const body = (req.body ?? {}) as PaneCreateRequest;
const { kind, position } = body;
if (!kind || !VALID_KINDS.has(kind)) {
reply.code(400);
return { error: 'kind must be "chat" or "file_browser"' };
}
const sessionRows = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`;
if (sessionRows.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
const sid = req.params.id;
const state = {};
let insertError: string | null = null;
const inserted = await sql.begin(async (tx) => {
const countResult = await tx<{ n: number }[]>`
SELECT COUNT(*)::int AS n FROM session_panes WHERE session_id = ${sid}
`;
const n = countResult[0]!.n;
if (n >= MAX_PANES) {
throw new Error('MAX_PANES_EXCEEDED');
}
let insertPos: number;
if (position === undefined || position === null) {
insertPos = n;
} else {
if (position < 0 || position > n) {
throw new Error('OUT_OF_BOUNDS');
}
insertPos = position;
}
await tx`UPDATE session_panes SET position = -position - 1
WHERE session_id = ${sid} AND position >= ${insertPos}`;
const [row] = await tx<Pane[]>`
INSERT INTO session_panes (session_id, position, kind, state)
VALUES (${sid}, ${insertPos}, ${kind}, ${JSON.stringify(state)}::jsonb)
RETURNING id, session_id, position, kind, state, created_at
`;
await tx`UPDATE session_panes SET position = -position
WHERE session_id = ${sid} AND position < 0`;
return row;
}).catch((err: Error) => {
insertError = err.message;
return null;
});
if (insertError === 'MAX_PANES_EXCEEDED') {
reply.code(400);
return { error: `session already has ${MAX_PANES} panes (maximum)` };
}
if (insertError === 'OUT_OF_BOUNDS') {
reply.code(400);
return { error: `position out of bounds` };
}
if (insertError) {
reply.code(500);
return { error: 'internal error' };
}
reply.code(201);
return inserted as Pane;
}
);
// PATCH /api/panes/:id — update state and/or position
app.patch<{ Params: { id: string } }>(
'/api/panes/:id',
async (req, reply) => {
const body = (req.body ?? {}) as PaneUpdateRequest;
const { state, position } = body;
if (state === undefined && position === undefined) {
reply.code(400);
return { error: 'must provide at least one of: state, position' };
}
const paneRows = await sql<Pane[]>`
SELECT id, session_id, position, kind, state, created_at
FROM session_panes WHERE id = ${req.params.id}
`;
if (paneRows.length === 0) {
reply.code(404);
return { error: 'pane not found' };
}
const pane = paneRows[0]!;
const sid = pane.session_id;
const oldPos = pane.position;
// Apply position and/or state changes atomically
let patchError: string | null = null;
await sql.begin(async (tx) => {
if (position !== undefined) {
const countRows = await tx<{ n: number }[]>`
SELECT COUNT(*)::int AS n FROM session_panes WHERE session_id = ${sid}
`;
const count = countRows[0]?.n ?? 0;
if (position < 0 || position >= count) {
throw `position must be between 0 and ${count - 1}`;
}
}
if (position !== undefined && position !== oldPos) {
await movePane(tx, req.params.id, sid, oldPos, position);
}
if (state !== undefined) {
await tx`
UPDATE session_panes SET state = ${JSON.stringify(state)}::jsonb
WHERE id = ${req.params.id}
`;
}
}).catch((err: unknown) => {
if (typeof err === 'string') {
patchError = err;
} else {
throw err;
}
});
if (patchError !== null) {
reply.code(400);
return { error: patchError };
}
const [updated] = await sql<Pane[]>`
SELECT id, session_id, position, kind, state, created_at
FROM session_panes WHERE id = ${req.params.id}
`;
return updated as Pane;
}
);
// DELETE /api/panes/:id — delete a pane, shift remaining down
app.delete<{ Params: { id: string } }>(
'/api/panes/:id',
async (req, reply) => {
const paneRows = await sql<{ id: string; session_id: string; position: number }[]>`
SELECT id, session_id, position FROM session_panes WHERE id = ${req.params.id}
`;
if (paneRows.length === 0) {
reply.code(404);
return { error: 'pane not found' };
}
const { session_id: sid, position: P } = paneRows[0]!;
await sql.begin(async (tx) => {
await tx`DELETE FROM session_panes WHERE id = ${req.params.id}`;
await tx`UPDATE session_panes SET position = -position
WHERE session_id = ${sid} AND position > ${P}`;
await tx`UPDATE session_panes SET position = -position - 1
WHERE session_id = ${sid} AND position < 0`;
});
reply.code(204);
return null;
}
);
}

View File

@@ -4,7 +4,12 @@ import { realpath, stat, readdir, access } from 'node:fs/promises';
import { basename, resolve, sep } from 'node:path';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import type { Broker } from '../services/broker.js';
import type { Project, AvailableProject } from '../types/api.js';
import { requireUser } from '../auth.js';
import { resolveProjectRoot, PathScopeError } from '../services/path_guard.js';
import { listDir, viewFile } from '../services/file_ops.js';
import { getProjectFiles } from '../services/file_index.js';
const AddProjectBody = z.object({
path: z.string().min(1),
@@ -42,7 +47,8 @@ async function resolveProjectPath(
export function registerProjectRoutes(
app: FastifyInstance,
sql: Sql,
config: Config
config: Config,
broker: Broker
): void {
app.get('/api/projects', async () => {
const rows = await sql<Project[]>`
@@ -71,6 +77,7 @@ export function registerProjectRoutes(
VALUES (${name}, ${resolved.real})
RETURNING id, name, path, added_at, last_session_id
`;
broker.publishUser(requireUser(req), { type: 'project_created', project: row as unknown as Project });
reply.code(201);
return row;
} catch (err) {
@@ -89,6 +96,7 @@ export function registerProjectRoutes(
reply.code(404);
return { error: 'not found' };
}
broker.publishUser(requireUser(req), { type: 'project_deleted', project_id: id });
reply.code(204);
return null;
});
@@ -127,4 +135,125 @@ export function registerProjectRoutes(
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
});
// GET /api/projects/:id/list_dir?path=<relpath>
app.get<{ Params: { id: string }; Querystring: { path?: string } }>(
'/api/projects/:id/list_dir',
async (req, reply) => {
const { id } = req.params;
const relPath = req.query.path ?? '.';
const rows = await sql<Project[]>`
SELECT id, name, path, added_at, last_session_id
FROM projects WHERE id = ${id}
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'not found' };
}
const project = rows[0]!;
let projectRoot: string;
try {
projectRoot = await resolveProjectRoot(project.path);
} catch (err) {
if (err instanceof PathScopeError) {
reply.code(404);
return { error: err.message };
}
throw err;
}
try {
const result = await listDir(projectRoot, relPath);
return result;
} catch (err) {
if (err instanceof PathScopeError) {
reply.code(400);
return { error: err.message };
}
throw err;
}
}
);
// GET /api/projects/:id/view_file?path=<relpath>
app.get<{ Params: { id: string }; Querystring: { path?: string } }>(
'/api/projects/:id/view_file',
async (req, reply) => {
const { id } = req.params;
const relPath = req.query.path;
if (!relPath) {
reply.code(400);
return { error: 'path is required' };
}
const rows = await sql<Project[]>`
SELECT id, name, path, added_at, last_session_id
FROM projects WHERE id = ${id}
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'not found' };
}
const project = rows[0]!;
let projectRoot: string;
try {
projectRoot = await resolveProjectRoot(project.path);
} catch (err) {
if (err instanceof PathScopeError) {
reply.code(404);
return { error: err.message };
}
throw err;
}
try {
const result = await viewFile(projectRoot, relPath);
return result;
} catch (err) {
if (err instanceof PathScopeError) {
reply.code(400);
return { error: err.message };
}
// File not found (pathGuard throws PathScopeError for non-existent paths)
if (err instanceof Error && err.message.includes('does not exist')) {
reply.code(404);
return { error: err.message };
}
throw err;
}
}
);
// GET /api/projects/:id/files
app.get<{ Params: { id: string } }>(
'/api/projects/:id/files',
async (req, reply) => {
const { id } = req.params;
const rows = await sql<Project[]>`
SELECT id, name, path, added_at, last_session_id
FROM projects WHERE id = ${id}
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'not found' };
}
const project = rows[0]!;
let projectRoot: string;
try {
projectRoot = await resolveProjectRoot(project.path);
} catch (err) {
if (err instanceof PathScopeError) {
reply.code(404);
return { error: err.message };
}
throw err;
}
const files = await getProjectFiles(id, projectRoot);
return { files };
}
);
}

View File

@@ -2,8 +2,10 @@ import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import type { Broker } from '../services/broker.js';
import type { Session } from '../types/api.js';
import { getSetting } from './settings.js';
import { requireUser } from '../auth.js';
const CreateBody = z.object({
name: z.string().min(1).max(200).optional(),
@@ -26,7 +28,8 @@ async function resolveDefaultModel(sql: Sql, config: Config): Promise<string> {
export function registerSessionRoutes(
app: FastifyInstance,
sql: Sql,
config: Config
config: Config,
broker: Broker
): void {
app.get<{ Params: { id: string } }>(
'/api/projects/:id/sessions',
@@ -74,11 +77,23 @@ export function registerSessionRoutes(
const name = parsed.data.name ?? 'New session';
const systemPrompt = parsed.data.system_prompt ?? '';
const [row] = await sql<Session[]>`
INSERT INTO sessions (project_id, name, model, system_prompt)
VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt})
RETURNING id, project_id, name, model, system_prompt, created_at, updated_at
`;
const row = await sql.begin(async (tx) => {
const [session] = await tx<Session[]>`
INSERT INTO sessions (project_id, name, model, system_prompt)
VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt})
RETURNING id, project_id, name, model, system_prompt, created_at, updated_at
`;
await tx`
INSERT INTO session_panes (session_id, position, kind, state)
VALUES (${session!.id}, 0, 'chat', '{}'::jsonb)
`;
return session!;
});
broker.publishUser(requireUser(req), {
type: 'session_created',
session: row,
project_id: row.project_id,
});
reply.code(201);
return row;
}
@@ -126,11 +141,16 @@ export function registerSessionRoutes(
app.delete<{ Params: { id: string } }>(
'/api/sessions/:id',
async (req, reply) => {
const result = await sql`DELETE FROM sessions WHERE id = ${req.params.id}`;
if (result.count === 0) {
const id = req.params.id;
const deleted = await sql<{ project_id: string }[]>`
DELETE FROM sessions WHERE id = ${id} RETURNING project_id
`;
if (deleted.length === 0) {
reply.code(404);
return { error: 'not found' };
}
const project_id = deleted[0]!.project_id;
broker.publishUser(requireUser(req), { type: 'session_deleted', session_id: id, project_id });
reply.code(204);
return null;
}

View File

@@ -18,7 +18,7 @@ export function registerSidebarRoutes(app: FastifyInstance, sql: Sql): void {
projects.map(async (p) => {
const [recent_sessions, countRows] = await Promise.all([
sql<SidebarSession[]>`
SELECT id, name, model, updated_at
SELECT id, project_id, name, model, updated_at
FROM sessions
WHERE project_id = ${p.id}
ORDER BY updated_at DESC

View File

@@ -43,4 +43,25 @@ export function registerWebSocket(
socket.on('error', () => unsubscribe());
}
);
app.get('/api/ws/user', { websocket: true }, async (socket, req) => {
const user = req.user;
// defensive: global auth hook (auth.ts) already rejects unauthenticated /api/* requests;
// keep the explicit check here to close the WS cleanly (1008) rather than throwing.
if (!user) {
socket.close(1008, 'unauthenticated');
return;
}
// No snapshot — user channel is purely live updates.
const unsubscribe = broker.subscribeUser(user, (frame) => {
if (socket.readyState !== socket.OPEN) return;
try {
socket.send(JSON.stringify(frame));
} catch (err) {
app.log.warn({ err, user }, 'user ws send failed');
}
});
socket.on('close', () => unsubscribe());
socket.on('error', () => unsubscribe());
});
}

View File

@@ -46,3 +46,23 @@ CREATE TABLE IF NOT EXISTS settings (
);
INSERT INTO settings (key, value) VALUES ('default_model', '"qwen3.6-35b-a3b-mxfp4"') ON CONFLICT (key) DO NOTHING;
CREATE TABLE IF NOT EXISTS session_panes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('chat', 'file_browser')),
state JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
UNIQUE (session_id, position)
);
CREATE INDEX IF NOT EXISTS idx_session_panes_session ON session_panes (session_id);
-- Backfill: ensure every session has at least one pane (default Chat).
-- Idempotent: skipped on subsequent runs because session_panes rows already exist.
INSERT INTO session_panes (session_id, position, kind, state)
SELECT s.id, 0, 'chat', '{}'::jsonb
FROM sessions s
WHERE NOT EXISTS (
SELECT 1 FROM session_panes p WHERE p.session_id = s.id
);

View File

@@ -4,35 +4,53 @@ export type Listener = (frame: Frame) => void;
export interface Broker {
publish(sessionId: string, frame: Frame): void;
subscribe(sessionId: string, listener: Listener): () => void;
publishUser(user: string, frame: Frame): void;
subscribeUser(user: string, listener: Listener): () => void;
}
export function createBroker(): Broker {
const topics = new Map<string, Set<Listener>>();
const userTopics = new Map<string, Set<Listener>>();
function publishTo(map: Map<string, Set<Listener>>, key: string, frame: Frame): void {
const set = map.get(key);
if (!set) return;
for (const listener of set) {
try {
listener(frame);
} catch {
// ignore listener errors so one bad subscriber doesn't break the rest
}
}
}
function subscribeTo(map: Map<string, Set<Listener>>, key: string, listener: Listener): () => void {
let set = map.get(key);
if (!set) {
set = new Set();
map.set(key, set);
}
set.add(listener);
return () => {
const s = map.get(key);
if (!s) return;
s.delete(listener);
if (s.size === 0) map.delete(key);
};
}
return {
publish(sessionId, frame) {
const set = topics.get(sessionId);
if (!set) return;
for (const listener of set) {
try {
listener(frame);
} catch {
// ignore listener errors so one bad subscriber doesn't break the rest
}
}
publishTo(topics, sessionId, frame);
},
subscribe(sessionId, listener) {
let set = topics.get(sessionId);
if (!set) {
set = new Set();
topics.set(sessionId, set);
}
set.add(listener);
return () => {
const s = topics.get(sessionId);
if (!s) return;
s.delete(listener);
if (s.size === 0) topics.delete(sessionId);
};
return subscribeTo(topics, sessionId, listener);
},
publishUser(user, frame) {
publishTo(userTopics, user, frame);
},
subscribeUser(user, listener) {
return subscribeTo(userTopics, user, listener);
},
};
}

View File

@@ -0,0 +1,52 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { execFile } from 'node:child_process';
interface MtimeSnap {
root: number;
gitHead: number | null;
gitIndex: number | null;
}
interface CacheEntry {
files: string[];
mtimes: MtimeSnap;
}
const cache = new Map<string, CacheEntry>(); // keyed by projectId
// Concurrent calls with a cold/stale cache may both spawn rg. The result is
// deterministic so they overwrite identically — no data corruption, just a
// rare extra subprocess. Acceptable for single-user mode.
export async function getProjectFiles(projectId: string, projectRoot: string): Promise<string[]> {
const current = await snapMtimes(projectRoot);
const cached = cache.get(projectId);
if (cached && eqMtimes(cached.mtimes, current)) {
return cached.files;
}
const files = await runRgFiles(projectRoot);
cache.set(projectId, { files, mtimes: current });
return files;
}
async function snapMtimes(root: string): Promise<MtimeSnap> {
const rootStat = await fs.stat(root);
let gitHead: number | null = null;
let gitIndex: number | null = null;
try { gitHead = (await fs.stat(path.join(root, '.git', 'HEAD'))).mtimeMs; } catch {}
try { gitIndex = (await fs.stat(path.join(root, '.git', 'index'))).mtimeMs; } catch {}
return { root: rootStat.mtimeMs, gitHead, gitIndex };
}
function eqMtimes(a: MtimeSnap, b: MtimeSnap): boolean {
return a.root === b.root && a.gitHead === b.gitHead && a.gitIndex === b.gitIndex;
}
function runRgFiles(root: string): Promise<string[]> {
return new Promise((resolve, reject) => {
execFile('rg', ['--files'], { cwd: root, maxBuffer: 32 * 1024 * 1024 }, (err, stdout) => {
if (err) return reject(err);
resolve(stdout.split('\n').filter(Boolean));
});
});
}

View File

@@ -0,0 +1,253 @@
import { readFile, readdir, stat } from 'node:fs/promises';
import { resolve, relative } from 'node:path';
import { spawn } from 'node:child_process';
import { pathGuard, PathScopeError } from './path_guard.js';
const MAX_FILE_BYTES = 5 * 1024 * 1024;
const DEFAULT_VIEW_LINES = 200;
const MAX_GREP_RESULTS = 200;
const DEFAULT_GREP_RESULTS = 100;
const MAX_FIND_RESULTS = 200;
const DEFAULT_FIND_RESULTS = 100;
const MAX_DIR_ENTRIES = 500;
export interface FileEntry {
name: string;
kind: 'file' | 'dir';
size?: number;
}
export interface ListDirResult {
entries: FileEntry[];
truncated: boolean;
total: number;
}
export interface ViewFileResult {
content: string;
truncated: boolean;
total_bytes: number;
bytes_returned: number;
}
export interface GrepMatch {
path: string;
line: number;
text: string;
}
export interface GrepResult {
matches: GrepMatch[];
truncated: boolean;
}
export interface FindFilesResult {
files: string[];
total: number;
truncated: boolean;
}
export async function listDir(projectRoot: string, relPath: string): Promise<ListDirResult> {
const real = await pathGuard(projectRoot, relPath);
const s = await stat(real);
if (!s.isDirectory()) {
throw new PathScopeError(`not a directory: ${relPath}`);
}
const entries = await readdir(real, { withFileTypes: true });
const total = entries.length;
const slice = entries.slice(0, MAX_DIR_ENTRIES);
const out: FileEntry[] = await Promise.all(
slice.map(async (e) => {
const child = resolve(real, e.name);
let size: number | undefined;
if (e.isFile()) {
try {
const cs = await stat(child);
size = cs.size;
} catch {
/* ignore */
}
}
return {
name: e.name,
kind: e.isDirectory() ? ('dir' as const) : ('file' as const),
...(size != null ? { size } : {}),
};
})
);
return {
entries: out,
total,
truncated: total > MAX_DIR_ENTRIES,
};
}
export async function viewFile(projectRoot: string, relPath: string): Promise<ViewFileResult> {
const real = await pathGuard(projectRoot, relPath);
const s = await stat(real);
if (!s.isFile()) {
throw new PathScopeError(`not a file: ${relPath}`);
}
if (s.size > MAX_FILE_BYTES) {
throw new Error(`file too large (${s.size} bytes, max ${MAX_FILE_BYTES})`);
}
const raw = await readFile(real, 'utf8');
const lines = raw.split('\n');
const total = lines.length;
const end = Math.min(total, DEFAULT_VIEW_LINES);
const slice = lines.slice(0, end);
const content = slice.join('\n');
const truncated = total > end;
const bytes_returned = Buffer.byteLength(content, 'utf8');
return {
content,
truncated,
total_bytes: s.size,
bytes_returned,
};
}
interface RipgrepMatch {
type: string;
data?: {
path?: { text?: string };
line_number?: number;
lines?: { text?: string };
};
}
export async function grep(
projectRoot: string,
pattern: string,
opts?: { path?: string; max_matches?: number; case_sensitive?: boolean; hidden?: boolean }
): Promise<GrepResult> {
const targetPath = opts?.path ?? projectRoot;
const target = await pathGuard(projectRoot, targetPath);
const limit = Math.min(
Math.max(opts?.max_matches ?? DEFAULT_GREP_RESULTS, 1),
MAX_GREP_RESULTS
);
const args = [
'--json',
'--max-count',
String(limit),
'--max-columns',
'300',
];
if (!opts?.case_sensitive) args.push('--ignore-case');
if (opts?.hidden) args.push('--hidden');
args.push('--', pattern, target);
return new Promise((resolveP, rejectP) => {
const child = spawn('rg', args, { cwd: projectRoot });
const matches: GrepMatch[] = [];
let buf = '';
let stderr = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk: string) => {
buf += chunk;
let idx;
while ((idx = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, idx);
buf = buf.slice(idx + 1);
if (!line) continue;
if (matches.length >= limit) continue;
try {
const parsed = JSON.parse(line) as RipgrepMatch;
if (parsed.type !== 'match' || !parsed.data) continue;
const filePath = parsed.data.path?.text ?? '';
const lineNumber = parsed.data.line_number ?? 0;
const content = parsed.data.lines?.text ?? '';
matches.push({
path: relative(projectRoot, filePath) || filePath,
line: lineNumber,
text: content.replace(/\n$/, ''),
});
} catch {
/* ignore non-json */
}
}
if (matches.length >= limit) {
child.kill();
}
});
child.stderr.on('data', (chunk: string) => {
stderr += chunk;
});
child.on('error', (err) => rejectP(err));
child.on('close', (code) => {
if (code === 2 && matches.length === 0) {
rejectP(new Error(`ripgrep failed: ${stderr.slice(0, 300)}`));
return;
}
resolveP({
matches,
truncated: matches.length >= limit,
});
});
});
}
export async function findFiles(
projectRoot: string,
pattern?: string,
opts?: { type?: 'file' | 'dir'; max_results?: number; path?: string }
): Promise<FindFilesResult> {
const limit = Math.min(
Math.max(opts?.max_results ?? DEFAULT_FIND_RESULTS, 1),
MAX_FIND_RESULTS
);
const target = opts?.path != null
? await pathGuard(projectRoot, opts.path)
: projectRoot;
const args = ['--files'];
if (pattern) args.push('--glob', pattern);
args.push(target);
return new Promise((resolveP, rejectP) => {
const child = spawn('rg', args, { cwd: projectRoot });
const files: string[] = [];
let total = 0;
let buf = '';
let stderr = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk: string) => {
buf += chunk;
let idx;
while ((idx = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, idx);
buf = buf.slice(idx + 1);
if (!line) continue;
// Keep counting after limit to report accurate `total` to the caller.
// grep kills early since the LLM doesn't need a total; this differs intentionally.
total++;
if (files.length < limit) {
files.push(relative(projectRoot, line) || line);
}
}
});
child.stderr.on('data', (chunk: string) => {
stderr += chunk;
});
child.on('error', (err) => rejectP(err));
child.on('close', (code) => {
if (code === 2) {
rejectP(new Error(`ripgrep failed: ${stderr.slice(0, 300)}`));
return;
}
if (buf.length > 0) {
total++;
if (files.length < limit) {
files.push(relative(projectRoot, buf) || buf);
}
}
resolveP({
files,
total,
truncated: total > files.length,
});
});
});
}

View File

@@ -1,7 +1,7 @@
import type { FastifyBaseLogger } from 'fastify';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import type { Message, Project, Session, ToolCall } from '../types/api.js';
import type { Message, Project, Session, ToolCall, UserStreamFrame } from '../types/api.js';
import { ALL_TOOLS, TOOLS_BY_NAME, toolJsonSchemas } from './tools.js';
import { PathScopeError, resolveProjectRoot } from './path_guard.js';
import { maybeAutoNameSession } from './auto_name.js';
@@ -86,6 +86,7 @@ export interface InferenceContext {
config: Config;
log: FastifyBaseLogger;
publish: FramePublisher;
publishUser: (frame: UserStreamFrame) => void;
}
export function buildMessagesPayload(
@@ -426,7 +427,12 @@ async function runAssistantTurn(
finished_at = clock_timestamp()
WHERE id = ${assistantMessageId}
`;
await ctx.sql`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
const [failSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>`
UPDATE sessions SET updated_at = clock_timestamp()
WHERE id = ${sessionId}
RETURNING project_id, name, updated_at
`;
ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: failSessRow!.project_id, name: failSessRow!.name, updated_at: failSessRow!.updated_at });
ctx.publish(sessionId, {
type: 'error',
message_id: assistantMessageId,
@@ -459,7 +465,12 @@ async function runAssistantTurn(
WHERE id = ${assistantMessageId}
RETURNING tokens_used, ctx_used, ctx_max, finished_at
`;
await ctx.sql`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
const [toolSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>`
UPDATE sessions SET updated_at = clock_timestamp()
WHERE id = ${sessionId}
RETURNING project_id, name, updated_at
`;
ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: toolSessRow!.project_id, name: toolSessRow!.name, updated_at: toolSessRow!.updated_at });
for (const tc of toolCalls) {
ctx.publish(sessionId, {
type: 'tool_call',
@@ -531,7 +542,12 @@ async function runAssistantTurn(
WHERE id = ${assistantMessageId}
RETURNING tokens_used, ctx_used, ctx_max, finished_at
`;
await ctx.sql`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
const [completeSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>`
UPDATE sessions SET updated_at = clock_timestamp()
WHERE id = ${sessionId}
RETURNING project_id, name, updated_at
`;
ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: completeSessRow!.project_id, name: completeSessRow!.name, updated_at: completeSessRow!.updated_at });
ctx.publish(sessionId, {
type: 'message_complete',
message_id: assistantMessageId,
@@ -563,19 +579,26 @@ export async function runInference(
return runAssistantTurn(ctx, sessionId, assistantMessageId, 0);
}
export function createInferenceRunner(ctx: InferenceContext) {
export function createInferenceRunner(
ctx: Omit<InferenceContext, 'publishUser'>,
publishUserFn: (user: string, frame: UserStreamFrame) => void
) {
return {
enqueue(sessionId: string, assistantMessageId: string) {
enqueue(sessionId: string, assistantMessageId: string, user: string) {
const callCtx: InferenceContext = {
...ctx,
publishUser: (frame) => publishUserFn(user, frame),
};
void (async () => {
try {
await runInference(ctx, sessionId, assistantMessageId);
await runInference(callCtx, sessionId, assistantMessageId);
setImmediate(() => {
void maybeAutoNameSession(ctx, sessionId).catch((err) => {
ctx.log.warn({ err, sessionId }, 'auto-name failed');
void maybeAutoNameSession(callCtx, sessionId).catch((err) => {
callCtx.log.warn({ err, sessionId }, 'auto-name failed');
});
});
} catch (err) {
ctx.log.error({ err }, 'unhandled inference error');
callCtx.log.error({ err }, 'unhandled inference error');
}
})();
},

View File

@@ -1,8 +1,8 @@
import { readFile, readdir, stat } from 'node:fs/promises';
import { resolve, basename, relative } from 'node:path';
import { spawn } from 'node:child_process';
import { z } from 'zod';
import { pathGuard, PathScopeError } from './path_guard.js';
import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js';
const MAX_FILE_BYTES = 5 * 1024 * 1024;
const DEFAULT_VIEW_LINES = 200;
@@ -168,15 +168,6 @@ const GrepInput = z.object({
});
type GrepInputT = z.infer<typeof GrepInput>;
interface RipgrepMatch {
type: string;
data?: {
path?: { text?: string };
line_number?: number;
lines?: { text?: string };
};
}
export const grep: ToolDef<GrepInputT> = {
name: 'grep',
description:
@@ -203,73 +194,27 @@ export const grep: ToolDef<GrepInputT> = {
},
},
async execute(input, projectRoot) {
const target = await pathGuard(projectRoot, input.path ?? projectRoot);
const limit = Math.min(
Math.max(input.max_results ?? DEFAULT_GREP_RESULTS, 1),
MAX_GREP_RESULTS
);
const args = [
'--json',
'--max-count',
String(limit),
'--max-columns',
'300',
];
if (!input.case_sensitive) args.push('--ignore-case');
if (input.hidden) args.push('--hidden');
args.push('--', input.pattern, target);
return await new Promise((resolveP, rejectP) => {
const child = spawn('rg', args, { cwd: projectRoot });
const matches: Array<{ path: string; line: number; content: string }> = [];
let buf = '';
let stderr = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk: string) => {
buf += chunk;
let idx;
while ((idx = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, idx);
buf = buf.slice(idx + 1);
if (!line) continue;
if (matches.length >= limit) continue;
try {
const parsed = JSON.parse(line) as RipgrepMatch;
if (parsed.type !== 'match' || !parsed.data) continue;
const path = parsed.data.path?.text ?? '';
const lineNumber = parsed.data.line_number ?? 0;
const content = parsed.data.lines?.text ?? '';
matches.push({
path: relative(projectRoot, path) || path,
line: lineNumber,
content: content.replace(/\n$/, ''),
});
} catch {
/* ignore non-json */
}
}
if (matches.length >= limit) {
child.kill();
}
});
child.stderr.on('data', (chunk: string) => {
stderr += chunk;
});
child.on('error', (err) => rejectP(err));
child.on('close', (code) => {
// rg exits 1 when no matches, 2 on real error
if (code === 2 && matches.length === 0) {
rejectP(new Error(`ripgrep failed: ${stderr.slice(0, 300)}`));
return;
}
resolveP({
matches,
total: matches.length,
truncated: matches.length >= limit,
});
});
// Delegate to file_ops.grep; reshape match objects to preserve LLM output format
// (file_ops uses {path, line, text}; tool output uses {path, line, content})
const result = await fileOpsGrep(projectRoot, input.pattern, {
path: input.path,
max_matches: limit,
case_sensitive: input.case_sensitive,
hidden: input.hidden,
});
return {
matches: result.matches.map((m) => ({
path: m.path,
line: m.line,
content: m.text,
})),
total: result.matches.length,
truncated: result.truncated,
};
},
};
@@ -303,55 +248,21 @@ export const findFiles: ToolDef<FindFilesInputT> = {
},
},
async execute(input, projectRoot) {
const target = await pathGuard(projectRoot, input.path ?? projectRoot);
const limit = Math.min(
Math.max(input.max_results ?? DEFAULT_FIND_RESULTS, 1),
MAX_FIND_RESULTS
);
return await new Promise((resolveP, rejectP) => {
const args = ['--files', '--glob', input.pattern, target];
const child = spawn('rg', args, { cwd: projectRoot });
const paths: string[] = [];
let total = 0;
let buf = '';
let stderr = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk: string) => {
buf += chunk;
let idx;
while ((idx = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, idx);
buf = buf.slice(idx + 1);
if (!line) continue;
total++;
if (paths.length < limit) {
paths.push(relative(projectRoot, line) || line);
}
}
});
child.stderr.on('data', (chunk: string) => {
stderr += chunk;
});
child.on('error', (err) => rejectP(err));
child.on('close', (code) => {
if (code === 2) {
rejectP(new Error(`ripgrep failed: ${stderr.slice(0, 300)}`));
return;
}
if (buf.length > 0) {
total++;
if (paths.length < limit) {
paths.push(relative(projectRoot, buf) || buf);
}
}
resolveP({
paths,
total,
truncated: total > paths.length,
});
});
// Delegate to file_ops.findFiles; reshape { files, total, truncated } to
// preserve the LLM-visible output format { paths, total, truncated }
const result = await fileOpsFindFiles(projectRoot, input.pattern, {
path: input.path,
max_results: limit,
});
return {
paths: result.files,
total: result.total,
truncated: result.truncated,
};
},
};

View File

@@ -61,6 +61,7 @@ export interface ModelInfo {
export interface SidebarSession {
id: string;
project_id: string;
name: string;
model: string;
updated_at: string;
@@ -76,3 +77,71 @@ export interface SidebarProject {
export interface SidebarResponse {
projects: SidebarProject[];
}
export type PaneKind = 'chat' | 'file_browser';
export interface FileBrowserPaneState {
open_file?: string | null;
filter?: string;
expanded_dirs?: string[];
}
// chat panes have no state for now
export type ChatPaneState = Record<string, never>;
export type PaneState = ChatPaneState | FileBrowserPaneState;
interface PaneBase {
id: string;
session_id: string;
position: number;
created_at: string;
}
export type Pane = PaneBase & (
| { kind: 'chat'; state: ChatPaneState }
| { kind: 'file_browser'; state: FileBrowserPaneState }
);
export interface PaneCreateRequest {
kind: PaneKind;
position?: number; // optional; if omitted, append at end
}
export interface PaneUpdateRequest {
state?: PaneState;
position?: number;
}
// User-stream frames (broadcast on /ws/user channel)
export interface ProjectCreatedFrame {
type: 'project_created';
project: Project;
}
export interface ProjectDeletedFrame {
type: 'project_deleted';
project_id: string;
}
export interface SessionCreatedFrame {
type: 'session_created';
session: Session;
project_id: string;
}
export interface SessionDeletedFrame {
type: 'session_deleted';
session_id: string;
project_id: string;
}
export interface SessionUpdatedFrame {
type: 'session_updated';
session_id: string;
project_id: string;
name: string;
updated_at: string;
}
export type UserStreamFrame =
| ProjectCreatedFrame
| ProjectDeletedFrame
| SessionCreatedFrame
| SessionDeletedFrame
| SessionUpdatedFrame;

View File

@@ -23,6 +23,7 @@
"react-router-dom": "^6.26.0",
"remark-gfm": "^4.0.1",
"shadcn": "^4.7.0",
"shiki": "^1.29.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"

View File

@@ -4,21 +4,29 @@ import { Home } from '@/pages/Home';
import { Project } from '@/pages/Project';
import { Session } from '@/pages/Session';
import { Toaster } from '@/components/ui/sonner';
import { useUserEvents } from '@/hooks/useUserEvents';
function AppShell() {
useUserEvents();
return (
<div className="dark h-screen flex bg-background text-foreground">
<ProjectSidebar />
<main className="flex-1 flex flex-col min-w-0">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/project/:id" element={<Project />} />
<Route path="/session/:id" element={<Session />} />
</Routes>
</main>
<Toaster position="bottom-right" />
</div>
);
}
export default function App() {
return (
<BrowserRouter>
<div className="dark h-screen flex bg-background text-foreground">
<ProjectSidebar />
<main className="flex-1 flex flex-col min-w-0">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/project/:id" element={<Project />} />
<Route path="/session/:id" element={<Session />} />
</Routes>
</main>
<Toaster position="bottom-right" />
</div>
<AppShell />
</BrowserRouter>
);
}

View File

@@ -5,6 +5,11 @@ import type {
Message,
ModelInfo,
SidebarResponse,
ListDirResult,
ViewFileResult,
Pane,
PaneCreateRequest,
PaneUpdateRequest,
} from './types';
export class ApiError extends Error {
@@ -47,6 +52,12 @@ export const api = {
}),
remove: (id: string) =>
request<void>(`/api/projects/${id}`, { method: 'DELETE' }),
listDir: (id: string, path: string) =>
request<ListDirResult>(`/api/projects/${id}/list_dir?path=${encodeURIComponent(path)}`),
viewFile: (id: string, path: string) =>
request<ViewFileResult>(`/api/projects/${id}/view_file?path=${encodeURIComponent(path)}`),
files: (id: string) =>
request<{ files: string[] }>(`/api/projects/${id}/files`),
},
sessions: {
@@ -105,4 +116,21 @@ export const api = {
sidebar: {
get: () => request<SidebarResponse>('/api/sidebar'),
},
panes: {
getForSession: (sessionId: string) =>
request<{ panes: Pane[] }>(`/api/sessions/${sessionId}/panes`),
create: (sessionId: string, body: PaneCreateRequest) =>
request<Pane>(`/api/sessions/${sessionId}/panes`, {
method: 'POST',
body: JSON.stringify(body),
}),
update: (id: string, body: PaneUpdateRequest) =>
request<Pane>(`/api/panes/${id}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
remove: (id: string) =>
request<void>(`/api/panes/${id}`, { method: 'DELETE' }),
},
};

View File

@@ -64,6 +64,7 @@ export interface SidebarSession {
name: string;
model: string;
updated_at: string;
project_id: string;
}
export interface SidebarProject {
@@ -77,6 +78,55 @@ export interface SidebarResponse {
projects: SidebarProject[];
}
export interface FileEntry {
name: string;
kind: 'file' | 'dir';
size?: number;
}
export interface ListDirResult {
entries: FileEntry[];
truncated: boolean;
total: number;
}
export interface ViewFileResult {
content: string;
truncated: boolean;
total_bytes: number;
bytes_returned: number;
}
export type PaneKind = 'chat' | 'file_browser';
export interface FileBrowserPaneState {
open_file?: string | null;
filter?: string;
expanded_dirs?: string[];
}
export type ChatPaneState = Record<string, never>;
export type PaneState = ChatPaneState | FileBrowserPaneState;
interface PaneBase {
id: string;
session_id: string;
position: number;
created_at: string;
}
export type Pane = PaneBase & (
| { kind: 'chat'; state: ChatPaneState }
| { kind: 'file_browser'; state: FileBrowserPaneState }
);
export interface PaneCreateRequest {
kind: PaneKind;
position?: number;
}
export interface PaneUpdateRequest {
state?: PaneState;
position?: number;
}
export type WsFrame =
| { type: 'snapshot'; messages: Message[] }
| { type: 'message_started'; message_id: string; role: MessageRole }

View File

@@ -1,16 +1,86 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { codeToHtml } from 'shiki';
// NOTE: spec calls for syntax-highlighted code blocks. Highlighting deferred
// to keep dep footprint minimal; this renders styled mono code with a copy
// button. Adding a highlighter (shiki / highlight.js) is a one-import swap.
// NOTE: spec calls for syntax-highlighted code blocks. Added Shiki in v1.1.
// Shiki output is compiler-generated and does not contain user input; setting
// it via a ref is safe here.
interface Props {
code: string;
lang?: string;
}
const LANG_MAP: Record<string, string> = {
ts: 'typescript',
tsx: 'tsx',
typescript: 'typescript',
js: 'javascript',
jsx: 'jsx',
javascript: 'javascript',
py: 'python',
python: 'python',
go: 'go',
rs: 'rust',
rust: 'rust',
rb: 'ruby',
ruby: 'ruby',
java: 'java',
c: 'c',
cpp: 'cpp',
cs: 'csharp',
csharp: 'csharp',
php: 'php',
sh: 'bash',
bash: 'bash',
shell: 'bash',
yaml: 'yaml',
yml: 'yaml',
json: 'json',
toml: 'toml',
md: 'markdown',
markdown: 'markdown',
sql: 'sql',
dockerfile: 'dockerfile',
html: 'html',
css: 'css',
};
const SHIKI_THEME = 'github-dark';
export function CodeBlock({ code, lang }: Props) {
const [copied, setCopied] = useState(false);
const [html, setHtml] = useState<string | null>(null);
const highlightRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
let cancelled = false;
const mappedLang = (lang && LANG_MAP[lang.toLowerCase()]) ?? null;
if (!mappedLang) {
setHtml(null);
return;
}
(async () => {
try {
const result = await codeToHtml(code, { lang: mappedLang, theme: SHIKI_THEME });
if (!cancelled) setHtml(result);
} catch (err) {
console.warn('shiki failed', err);
if (!cancelled) setHtml(null);
}
})();
return () => {
cancelled = true;
};
}, [code, lang]);
// Inject Shiki HTML via ref; output is compiler-generated, not user input.
useEffect(() => {
if (highlightRef.current) {
// Shiki generates sanitized HTML spans — not user-supplied content.
// eslint-disable-next-line no-unsanitized/property
highlightRef.current.innerHTML = html ?? '';
}
}, [html]);
async function copy() {
try {
@@ -36,9 +106,16 @@ export function CodeBlock({ code, lang }: Props) {
<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
<pre className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed">
{code}
</pre>
{html !== null ? (
<div
ref={highlightRef}
className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed [&>pre]:!bg-transparent [&>pre]:!m-0 [&>pre]:!p-0"
/>
) : (
<pre className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed">
{code}
</pre>
)}
</div>
);
}

View File

@@ -1,13 +1,87 @@
import { useState } from 'react';
import { Children, cloneElement, isValidElement, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Copy, RefreshCw, Check } from 'lucide-react';
import { toast } from 'sonner';
import type { Message } from '@/api/types';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { ToolCallCard } from './ToolCallCard';
import { CodeBlock } from './CodeBlock';
// 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
// match, but `src/foo.ts` will). False positives at the edges are accepted
// per Sam's design decision (2026-05-14).
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
function isPathLike(s: string): boolean {
return s.includes('/');
}
function emitOpenFile(path: string): void {
sessionEvents.emit({ type: 'open_file_in_browser', path });
}
// Split a plain string into a flat array of strings and clickable button
// nodes for path-shaped substrings. If no matches, returns the original
// string verbatim (no array wrapping).
function linkifyPaths(text: string, keyPrefix: string): ReactNode {
const out: ReactNode[] = [];
let lastIdx = 0;
let idx = 0;
for (const match of text.matchAll(PATH_REGEX)) {
const matchedText = match[0];
const start = match.index ?? 0;
if (!isPathLike(matchedText)) continue;
if (start > lastIdx) out.push(text.slice(lastIdx, start));
out.push(
<button
key={`${keyPrefix}-${idx}`}
type="button"
onClick={() => emitOpenFile(matchedText)}
className="text-primary underline cursor-pointer hover:text-primary/80"
>
{matchedText}
</button>
);
lastIdx = start + matchedText.length;
idx += 1;
}
if (out.length === 0) return text;
if (lastIdx < text.length) out.push(text.slice(lastIdx));
return out;
}
// Walk react-markdown children, linkifying string text nodes. Children of
// <code> nodes (CodeBlock and inline code) are left untouched — the regex
// shouldn't run inside code spans.
function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
const arr = Children.toArray(children);
return arr.map((child, i) => {
if (typeof child === 'string') {
return (
<span key={`${keyPrefix}-${i}`}>
{linkifyPaths(child, `${keyPrefix}-${i}`)}
</span>
);
}
if (isValidElement(child)) {
const el = child as ReactElement<{ children?: ReactNode }>;
// Skip inline/block code — paths in code spans aren't link targets.
if (el.type === 'code' || el.type === CodeBlock) return child;
const grandchildren = el.props.children;
if (grandchildren === undefined) return child;
return cloneElement(el, {
key: el.key ?? `linkified-${i}`,
children: linkifyChildren(grandchildren, `${keyPrefix}-${i}`),
});
}
return child;
});
}
interface Props {
message: Message;
sessionId: string;
@@ -55,7 +129,10 @@ function MarkdownBody({ content }: { content: string }) {
ol: ({ children }) => (
<ol className="list-decimal pl-5 space-y-1">{children}</ol>
),
p: ({ children }) => <p className="leading-relaxed">{children}</p>,
li: ({ children }) => <li>{linkifyChildren(children)}</li>,
p: ({ children }) => (
<p className="leading-relaxed">{linkifyChildren(children)}</p>
),
h1: ({ children }) => <h1 className="text-base font-semibold mt-2">{children}</h1>,
h2: ({ children }) => <h2 className="text-sm font-semibold mt-2">{children}</h2>,
h3: ({ children }) => <h3 className="text-sm font-semibold mt-1">{children}</h3>,
@@ -73,7 +150,9 @@ function MarkdownBody({ content }: { content: string }) {
<th className="border border-border px-2 py-1 text-left font-medium">{children}</th>
),
td: ({ children }) => (
<td className="border border-border px-2 py-1">{children}</td>
<td className="border border-border px-2 py-1">
{linkifyChildren(children)}
</td>
),
}}
>

View File

@@ -0,0 +1,116 @@
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>
);
}

View File

@@ -14,6 +14,7 @@ import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { useSidebar } from '@/hooks/useSidebar';
import type { SidebarProject } from '@/api/types';
import { cn } from '@/lib/utils';
const EXPANDED_KEY = 'boocode.sidebar.expanded';
const MAX_VISIBLE_SESSIONS = 5;
@@ -55,13 +56,29 @@ function relTime(iso: string): string {
return `${Math.floor(mo / 12)}y`;
}
function activeProjectId(pathname: string, projects: SidebarProject[]): string | null {
function activeProjectId(
pathname: string,
projects: SidebarProject[],
activeSession: { session_id: string; project_id: string } | null
): string | null {
const pm = pathname.match(/^\/project\/([^/]+)/);
if (pm?.[1]) return pm[1];
const sm = pathname.match(/^\/session\/([^/]+)/);
const sid = sm?.[1];
if (!sid) return null;
return projects.find((p) => p.recent_sessions.some((s) => s.id === sid))?.id ?? null;
// Prefer the cache lookup so we resolve correctly even when an older
// activeSession (from a prior route) hasn't been cleared yet.
const fromCache = projects.find((p) =>
p.recent_sessions.some((s) => s.id === sid)
)?.id;
if (fromCache) return fromCache;
// Fallback: the session was loaded via deep link (not in cache) and
// emitted session_loaded — use that. Guard against stale values by
// matching the current URL sid.
if (activeSession && activeSession.session_id === sid) {
return activeSession.project_id;
}
return null;
}
function activeSessionId(pathname: string): string | null {
@@ -70,7 +87,8 @@ function activeSessionId(pathname: string): string | null {
}
export function ProjectSidebar() {
const { data, error, loading, retry } = useSidebar();
const { data, error, loading, retry, activeSession: loadedActiveSession } =
useSidebar();
const [addOpen, setAddOpen] = useState(false);
const [expanded, setExpanded] = useState<Set<string>>(() => readExpanded());
const navigate = useNavigate();
@@ -87,8 +105,8 @@ export function ProjectSidebar() {
const projects = data?.projects ?? [];
const activeProject = useMemo(
() => activeProjectId(location.pathname, projects),
[location.pathname, projects]
() => activeProjectId(location.pathname, projects, loadedActiveSession),
[location.pathname, projects, loadedActiveSession]
);
const activeSession = useMemo(
() => activeSessionId(location.pathname),
@@ -173,11 +191,17 @@ export function ProjectSidebar() {
type="button"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
aria-expanded={isExpanded}
disabled={isActiveProject}
onClick={(e) => {
e.stopPropagation();
if (isActiveProject) return;
toggle(p.id);
}}
className="flex items-center justify-center size-4 shrink-0 opacity-70 hover:opacity-100"
className={cn(
'flex items-center justify-center size-4 shrink-0 opacity-70 hover:opacity-100',
isActiveProject &&
'opacity-50 cursor-not-allowed hover:opacity-50'
)}
>
<ChevronRight
className={`size-3.5 transition-transform ${isExpanded ? 'rotate-90' : ''}`}

View File

@@ -1,12 +1,50 @@
import { useState } from 'react';
import type { ReactNode } from 'react';
import { ChevronRight, Wrench } from 'lucide-react';
import type { Message, ToolCall } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
interface Props {
message?: Message;
toolCall?: ToolCall;
}
// Same regex/heuristic as MessageBubble: paths ending in `.ext` with at
// least one `/`. Linkifies file paths emitted by tools like grep / find_files
// so they're clickable.
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
function linkifyOutput(text: string): ReactNode[] {
const out: ReactNode[] = [];
let lastIdx = 0;
let idx = 0;
for (const match of text.matchAll(PATH_REGEX)) {
const matchedText = match[0];
const start = match.index ?? 0;
if (!matchedText.includes('/')) continue;
if (start > lastIdx) out.push(text.slice(lastIdx, start));
out.push(
<button
key={idx}
type="button"
onClick={() =>
sessionEvents.emit({
type: 'open_file_in_browser',
path: matchedText,
})
}
className="text-primary underline cursor-pointer hover:text-primary/80"
>
{matchedText}
</button>
);
lastIdx = start + matchedText.length;
idx += 1;
}
if (lastIdx < text.length) out.push(text.slice(lastIdx));
return out.length > 0 ? out : [text];
}
export function ToolCallCard({ message, toolCall }: Props) {
const [open, setOpen] = useState(false);
const tc = toolCall ?? message?.tool_calls?.[0];
@@ -48,7 +86,11 @@ export function ToolCallCard({ message, toolCall }: Props) {
</pre>
) : output !== undefined ? (
<pre className="text-xs font-mono whitespace-pre-wrap overflow-x-auto max-h-72 overflow-y-auto">
{typeof output === 'string' ? output : JSON.stringify(output, null, 2)}
{linkifyOutput(
typeof output === 'string'
? output
: JSON.stringify(output, null, 2)
)}
</pre>
) : (
<div className="text-xs text-muted-foreground">no result yet</div>

View File

@@ -0,0 +1,339 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { DragEvent } from 'react';
import { Plus } from 'lucide-react';
import { usePanes } from '@/hooks/usePanes';
import { sessionEvents } from '@/hooks/sessionEvents';
import type { FileBrowserPaneState, Pane, PaneKind } from '@/api/types';
import { PaneTab } from '@/components/PaneTab';
import { PaneShell } from '@/components/panes/PaneShell';
import { ChatPane } from '@/components/panes/ChatPane';
import { FileBrowserPane } from '@/components/panes/FileBrowserPane';
import { cn } from '@/lib/utils';
interface Props {
sessionId: string;
projectId: string;
}
const MAX_PANES = 5;
function PaneSkeleton() {
return (
<div className="flex flex-col h-full">
<div className="flex items-center border-b border-border bg-muted/20 h-8" />
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">
Loading panes...
</div>
</div>
);
}
function PaneError({
message,
onRetry,
}: {
message: string;
onRetry: () => void | Promise<void>;
}) {
return (
<div className="flex flex-col h-full items-center justify-center gap-2 text-sm">
<span className="text-destructive">{message}</span>
<button
type="button"
onClick={() => void onRetry()}
className="text-xs underline text-muted-foreground hover:text-foreground"
>
Retry
</button>
</div>
);
}
export function Workspace({ sessionId, projectId }: Props) {
const { panes, loading, error, create, update, remove, refresh } =
usePanes(sessionId);
const [activeId, setActiveId] = useState<string | null>(null);
const draggingIdRef = useRef<string | null>(null);
// Keep latest panes in a ref so the event-bus subscription doesn't need
// to re-subscribe whenever the list changes (which would race with rapid
// updates).
const panesRef = useRef<Pane[] | null>(null);
panesRef.current = panes;
// Default active: first pane (and reset if the active one disappears)
useEffect(() => {
if (!panes || panes.length === 0) {
if (activeId !== null) setActiveId(null);
return;
}
if (!panes.some((p) => p.id === activeId)) {
setActiveId(panes[0]!.id);
}
}, [panes, activeId]);
// Tracks an in-flight create() call so rapid open_file_in_browser events
// don't race to each spawn a new file_browser pane. While a create is in
// progress the subsequent events wait for it and update the same pane.
const creatingRef = useRef<{ id: string; promise: Promise<string> } | null>(
null
);
// Subscribe to open_file_in_browser events: focus an existing file_browser
// pane (updating its open_file) or spawn one if room is available.
useEffect(() => {
return sessionEvents.subscribe((event) => {
if (event.type !== 'open_file_in_browser') return;
void (async () => {
// If a create is already in flight, wait for it to finish then update
// the newly-created pane rather than spawning a second one.
if (creatingRef.current) {
const { id: pendingId, promise } = creatingRef.current;
const resolvedId = await promise;
const targetId = resolvedId || pendingId;
const current = panesRef.current;
const fb = current?.find((p) => p.id === targetId);
const nextState: FileBrowserPaneState = {
...(fb?.kind === 'file_browser' ? fb.state : {}),
open_file: event.path,
};
await update(targetId, { state: nextState });
setActiveId(targetId);
return;
}
const current = panesRef.current;
if (!current) return;
const fb = current.find(
(p): p is Pane & { kind: 'file_browser' } =>
p.kind === 'file_browser'
);
if (fb) {
const nextState: FileBrowserPaneState = {
...fb.state,
open_file: event.path,
};
await update(fb.id, { state: nextState });
setActiveId(fb.id);
} else if (current.length < MAX_PANES) {
// Reserve the slot immediately so concurrent events see the flag.
const createPromise = (async (): Promise<string> => {
const newPane = await create({ kind: 'file_browser' });
return newPane.id;
})();
// Use a stable object; id is filled in once resolved.
const entry: { id: string; promise: Promise<string> } = {
id: '',
promise: createPromise,
};
creatingRef.current = entry;
try {
const newId = await createPromise;
entry.id = newId;
const nextState: FileBrowserPaneState = {
open_file: event.path,
filter: '',
expanded_dirs: [],
};
await update(newId, { state: nextState });
setActiveId(newId);
} finally {
if (creatingRef.current === entry) {
creatingRef.current = null;
}
}
}
})();
});
}, [create, update]);
const handleClose = useCallback(
async (id: string) => {
try {
await remove(id);
} catch {
/* error surfaced via hook state */
}
},
[remove]
);
const handleSplit = useCallback(
async (afterIdx: number, kind: PaneKind) => {
const current = panesRef.current;
if (!current || current.length >= MAX_PANES) return;
try {
const created = await create({ kind, position: afterIdx + 1 });
setActiveId(created.id);
} catch {
/* error surfaced via hook state */
}
},
[create]
);
const handleCloseOthers = useCallback(
async (id: string) => {
const current = panesRef.current;
if (!current) return;
const targets = current.filter((p) => p.id !== id).map((p) => p.id);
for (const targetId of targets) {
try {
await remove(targetId);
} catch {
// Stop on first failure to avoid cascading errors.
return;
}
}
},
[remove]
);
const handleCloseToRight = useCallback(
async (idx: number) => {
const current = panesRef.current;
if (!current) return;
const targets = current.slice(idx + 1).map((p) => p.id);
for (const targetId of targets) {
try {
await remove(targetId);
} catch {
return;
}
}
},
[remove]
);
const handleCloseAll = useCallback(async () => {
const current = panesRef.current;
if (!current) return;
const targets = current.map((p) => p.id);
for (const targetId of targets) {
try {
await remove(targetId);
} catch {
return;
}
}
}, [remove]);
const handleAdd = useCallback(async () => {
const current = panesRef.current;
if (current && current.length >= MAX_PANES) return;
try {
const created = await create({ kind: 'chat' });
setActiveId(created.id);
} catch {
/* error surfaced via hook state */
}
}, [create]);
const handleDragStart = useCallback(
(id: string) => (e: DragEvent<HTMLDivElement>) => {
draggingIdRef.current = id;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', id);
},
[]
);
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}, []);
const handleDrop = useCallback(
(targetIdx: number) => async (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
const draggedId =
draggingIdRef.current || e.dataTransfer.getData('text/plain');
draggingIdRef.current = null;
if (!draggedId) return;
const current = panesRef.current;
if (!current) return;
const draggedIdx = current.findIndex((p) => p.id === draggedId);
if (draggedIdx < 0 || draggedIdx === targetIdx) return;
try {
await update(draggedId, { position: targetIdx });
} catch {
/* error surfaced via hook state */
}
},
[update]
);
if (loading && !panes) return <PaneSkeleton />;
if (error && !panes) return <PaneError message={error} onRetry={refresh} />;
if (!panes) return <PaneSkeleton />;
return (
<div className="flex flex-col h-full min-h-0">
<div
className="flex items-center border-b border-border bg-muted/20"
role="tablist"
>
{panes.map((pane, idx) => (
<PaneTab
key={pane.id}
pane={pane}
isActive={pane.id === activeId}
onClick={() => setActiveId(pane.id)}
onClose={() => void handleClose(pane.id)}
onSplit={(kind) => void handleSplit(idx, kind)}
onCloseOthers={() => void handleCloseOthers(pane.id)}
onCloseToRight={() => void handleCloseToRight(idx)}
onCloseAll={() => void handleCloseAll()}
onDragStart={handleDragStart(pane.id)}
onDragOver={handleDragOver}
onDrop={handleDrop(idx)}
/>
))}
<button
type="button"
onClick={() => void handleAdd()}
disabled={panes.length >= MAX_PANES}
className={cn(
'p-1.5 ml-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground',
panes.length >= MAX_PANES && 'opacity-40 cursor-not-allowed hover:bg-transparent'
)}
aria-label="Add pane"
>
<Plus size={14} />
</button>
</div>
{panes.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">
No panes. Click + to add one.
</div>
) : (
<div
className="flex-1 grid min-h-0"
style={{
gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))`,
}}
>
{panes.map((pane) => (
<PaneShell
key={pane.id}
pane={pane}
onClose={() => void handleClose(pane.id)}
>
{pane.kind === 'chat' ? (
<ChatPane sessionId={sessionId} />
) : (
<FileBrowserPane
pane={pane}
projectId={projectId}
onStateChange={(state) =>
void update(pane.id, { state })
}
/>
)}
</PaneShell>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { useEffect, useRef } from 'react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import { useSessionStream } from '@/hooks/useSessionStream';
import { MessageList } from '@/components/MessageList';
import { ChatInput } from '@/components/ChatInput';
interface Props {
sessionId: string;
}
export function ChatPane({ sessionId }: Props) {
const stream = useSessionStream(sessionId);
const lastErrorRef = useRef<string | null>(null);
// Surface stream errors via toast — matches Session.tsx behavior.
useEffect(() => {
if (stream.error && stream.error !== lastErrorRef.current) {
lastErrorRef.current = stream.error;
toast.error(stream.error);
}
if (!stream.error) {
lastErrorRef.current = null;
}
}, [stream.error]);
async function handleSend(content: string) {
await api.messages.send(sessionId, content);
}
const streaming = stream.messages.some((m) => m.status === 'streaming');
return (
<div className="flex flex-col h-full min-h-0">
<MessageList messages={stream.messages} sessionId={sessionId} />
<ChatInput disabled={streaming} onSend={handleSend} />
</div>
);
}

View File

@@ -0,0 +1,637 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { KeyboardEvent } from 'react';
import { ChevronRight, ChevronDown, FileText, Folder, X } from 'lucide-react';
import { api, ApiError } from '@/api/client';
import type {
FileBrowserPaneState,
FileEntry,
Pane,
ViewFileResult,
} from '@/api/types';
import { CodeBlock } from '@/components/CodeBlock';
import { cn } from '@/lib/utils';
interface Props {
pane: Pane & { kind: 'file_browser' };
projectId: string;
onStateChange: (state: FileBrowserPaneState) => void;
}
const LANG_BY_EXT: Record<string, string> = {
ts: 'typescript',
tsx: 'tsx',
js: 'javascript',
jsx: 'jsx',
mjs: 'javascript',
cjs: 'javascript',
py: 'python',
go: 'go',
rs: 'rust',
rb: 'ruby',
java: 'java',
c: 'c',
h: 'c',
cpp: 'cpp',
hpp: 'cpp',
cs: 'csharp',
php: 'php',
sh: 'bash',
bash: 'bash',
zsh: 'bash',
yaml: 'yaml',
yml: 'yaml',
json: 'json',
toml: 'toml',
md: 'markdown',
markdown: 'markdown',
sql: 'sql',
dockerfile: 'dockerfile',
html: 'html',
htm: 'html',
css: 'css',
scss: 'scss',
};
function deriveLang(filePath: string): string | undefined {
// basename
const base = filePath.split('/').pop() ?? filePath;
if (base.toLowerCase() === 'dockerfile') return 'dockerfile';
const dot = base.lastIndexOf('.');
if (dot < 0 || dot === base.length - 1) return undefined;
const ext = base.slice(dot + 1).toLowerCase();
return LANG_BY_EXT[ext];
}
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);
}
};
}, []);
// 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;
const filterResults = useMemo<FlatEntry[]>(() => {
if (!filterActive) return [];
const needle = trimmedFilter.toLowerCase();
return flattenedAll.filter((e) => e.path.toLowerCase().includes(needle));
}, [filterActive, trimmedFilter, 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>) {
const list = filterActive ? filterResults : 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);
}
}
}
// 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]);
// 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 =
entry.kind === 'file' && 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);
if (entry.kind === 'dir') {
toggleDir(entry.path);
} else {
selectFile(entry.path);
}
}}
>
{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.path}</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">
{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">
{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>
)}
<CodeBlock code={viewer.result.content} lang={deriveLang(openFile)} />
</div>
)}
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
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>
);
}

View File

@@ -0,0 +1,263 @@
"use client"
import * as React from "react"
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn("z-50 min-w-32 overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="context-menu-checkbox-item-indicator"
>
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="context-menu-radio-item-indicator"
>
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn("z-50 min-w-[96px] overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuPortal,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuGroup,
ContextMenuLabel,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubTrigger,
ContextMenuSubContent,
}

View File

@@ -32,12 +32,34 @@ export interface SessionDeletedEvent {
project_id: string;
}
export interface SessionUpdatedEvent {
type: 'session_updated';
session_id: string;
project_id: string;
name: string;
updated_at: string;
}
export interface SessionLoadedEvent {
type: 'session_loaded';
session_id: string;
project_id: string;
}
export interface OpenFileInBrowserEvent {
type: 'open_file_in_browser';
path: string; // project-relative
}
export type SessionEvent =
| SessionRenamedEvent
| ProjectCreatedEvent
| ProjectDeletedEvent
| SessionCreatedEvent
| SessionDeletedEvent;
| SessionDeletedEvent
| SessionUpdatedEvent
| SessionLoadedEvent
| OpenFileInBrowserEvent;
type Listener = (event: SessionEvent) => void;
const listeners = new Set<Listener>();

View File

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

View File

@@ -13,6 +13,7 @@ let sharedError: string | null = null;
let sharedLoading: boolean = true;
let initialized = false;
let fetchInFlight: Promise<void> | null = null;
let activeSession: { session_id: string; project_id: string } | null = null;
const subscribers = new Set<() => void>();
function notify(): void {
@@ -74,6 +75,7 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
name: event.session.name,
model: event.session.model,
updated_at: event.session.updated_at,
project_id: event.project_id,
};
return {
...p,
@@ -113,7 +115,30 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
});
return changed ? { ...prev, projects } : prev;
}
default:
case 'session_updated': {
let changed = false;
const projects = prev.projects.map((p) => {
if (p.id !== event.project_id) return p;
let projectChanged = false;
const recent = p.recent_sessions.map((s) => {
if (s.id !== event.session_id) return s;
projectChanged = true;
return { ...s, name: event.name, updated_at: event.updated_at };
});
if (!projectChanged) return p;
changed = true;
const sorted = [...recent].sort(
(a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
);
return { ...p, recent_sessions: sorted };
});
return changed ? { ...prev, projects } : prev;
}
case 'session_loaded':
// activeSessionProjectId is updated in the subscribe callback; no data change here.
return prev;
case 'open_file_in_browser':
// Consumed by Workspace (T7); no sidebar state change needed.
return prev;
}
}
@@ -122,6 +147,13 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
// before the initial fetch resolves are dropped; the eventual fetch
// result is the source of truth.
sessionEvents.subscribe((event) => {
// session_loaded updates activeSessionProjectId regardless of whether
// sharedData is populated yet — notify so subscribers can re-read.
if (event.type === 'session_loaded') {
activeSession = { session_id: event.session_id, project_id: event.project_id };
notify();
return;
}
if (!sharedData) return;
const next = applyEvent(sharedData, event);
if (next === sharedData) return;
@@ -133,10 +165,11 @@ interface Snapshot {
data: SidebarResponse | null;
error: string | null;
loading: boolean;
activeSession: { session_id: string; project_id: string } | null;
}
function snapshot(): Snapshot {
return { data: sharedData, error: sharedError, loading: sharedLoading };
return { data: sharedData, error: sharedError, loading: sharedLoading, activeSession };
}
export function useSidebar(): {
@@ -144,6 +177,7 @@ export function useSidebar(): {
error: string | null;
loading: boolean;
retry: () => void;
activeSession: { session_id: string; project_id: string } | null;
} {
const [state, setState] = useState<Snapshot>(snapshot);
@@ -165,5 +199,5 @@ export function useSidebar(): {
void load();
};
return { data: state.data, error: state.error, loading: state.loading, retry };
return { data: state.data, error: state.error, loading: state.loading, retry, activeSession: state.activeSession };
}

View File

@@ -0,0 +1,56 @@
import { useEffect } from 'react';
import { sessionEvents } from './sessionEvents';
const RECONNECT_INITIAL_MS = 1000;
const RECONNECT_MAX_MS = 30000;
export function useUserEvents(): void {
useEffect(() => {
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectDelay = RECONNECT_INITIAL_MS;
let unmounted = false;
const connect = () => {
if (unmounted) return;
const url = new URL('/api/ws/user', window.location.href);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(url.toString());
ws.onopen = () => {
reconnectDelay = RECONNECT_INITIAL_MS;
};
ws.onmessage = (ev) => {
try {
const parsed: unknown = JSON.parse(ev.data);
if (parsed && typeof (parsed as { type?: unknown }).type === 'string') {
sessionEvents.emit(parsed as import('./sessionEvents').SessionEvent);
}
} catch (err) {
console.warn('useUserEvents: failed to parse frame', err);
}
};
ws.onclose = () => {
if (unmounted) return;
const delay = reconnectDelay;
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
reconnectTimer = setTimeout(connect, delay);
};
ws.onerror = () => {
// close handler will trigger reconnect
try { ws?.close(); } catch {}
};
};
connect();
return () => {
unmounted = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
if (ws) try { ws.close(); } catch {}
};
}, []);
}

View File

@@ -1,43 +1,43 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { ChevronLeft } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { Session as SessionType } from '@/api/types';
import { useSessionStream } from '@/hooks/useSessionStream';
import { sessionEvents } from '@/hooks/sessionEvents';
import { MessageList } from '@/components/MessageList';
import { ChatInput } from '@/components/ChatInput';
import { Workspace } from '@/components/Workspace';
import { ModelPicker } from '@/components/ModelPicker';
export function Session() {
const { id } = useParams<{ id: string }>();
const stream = useSessionStream(id);
const [session, setSession] = useState<SessionType | null>(null);
const [name, setName] = useState('');
const [editingName, setEditingName] = useState(false);
const lastErrorRef = useRef<string | null>(null);
useEffect(() => {
if (stream.error && stream.error !== lastErrorRef.current) {
lastErrorRef.current = stream.error;
toast.error(stream.error);
}
if (!stream.error) {
lastErrorRef.current = null;
}
}, [stream.error]);
useEffect(() => {
if (!id) return;
setSession(null);
let cancelled = false;
api.sessions
.get(id)
.then((s) => {
if (cancelled) return;
setSession(s);
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({
type: 'session_loaded',
session_id: id,
project_id: s.project_id,
});
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [id]);
useEffect(() => {
@@ -68,13 +68,6 @@ export function Session() {
setEditingName(false);
}
async function handleSend(content: string) {
if (!id) return;
await api.messages.send(id, content);
}
const streaming = stream.messages.some((m) => m.status === 'streaming');
return (
<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">
@@ -122,14 +115,11 @@ export function Session() {
/>
)}
</div>
{!stream.connected && (
<span className="text-xs text-muted-foreground">reconnecting</span>
)}
</header>
{id && <MessageList messages={stream.messages} sessionId={id} />}
<ChatInput disabled={streaming} onSend={handleSend} />
{id && session && (
<Workspace sessionId={id} projectId={session.project_id} />
)}
</div>
);
}

129
pnpm-lock.yaml generated
View File

@@ -87,6 +87,9 @@ importers:
shadcn:
specifier: ^4.7.0
version: 4.7.0(@types/node@20.19.41)(typescript@5.9.3)
shiki:
specifier: ^1.29.2
version: 1.29.2
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -1566,6 +1569,27 @@ packages:
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@shikijs/core@1.29.2':
resolution: {integrity: sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ==}
'@shikijs/engine-javascript@1.29.2':
resolution: {integrity: sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A==}
'@shikijs/engine-oniguruma@1.29.2':
resolution: {integrity: sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA==}
'@shikijs/langs@1.29.2':
resolution: {integrity: sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ==}
'@shikijs/themes@1.29.2':
resolution: {integrity: sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g==}
'@shikijs/types@1.29.2':
resolution: {integrity: sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw==}
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
'@sindresorhus/merge-streams@4.0.0':
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
@@ -2059,6 +2083,9 @@ packages:
electron-to-chromium@1.5.355:
resolution: {integrity: sha512-LUPZhKzZPYSPme1jEYohpkA+ybYCJztr1quAdBd7E7h3+VOBVcKkwwtBJu41nrjawrRzfb8mtMfzWozoaK0ZIQ==}
emoji-regex-xs@1.0.0:
resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==}
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -2327,6 +2354,9 @@ packages:
resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
engines: {node: '>= 0.4'}
hast-util-to-html@9.0.5:
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
@@ -2343,6 +2373,9 @@ packages:
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
http-errors@2.0.0:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'}
@@ -2908,6 +2941,9 @@ packages:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'}
oniguruma-to-es@2.3.0:
resolution: {integrity: sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==}
open@11.0.0:
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
engines: {node: '>=20'}
@@ -3129,6 +3165,15 @@ packages:
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
engines: {node: '>= 4'}
regex-recursion@5.1.1:
resolution: {integrity: sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==}
regex-utilities@2.3.0:
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
regex@5.1.1:
resolution: {integrity: sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==}
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
@@ -3244,6 +3289,9 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shiki@1.29.2:
resolution: {integrity: sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg==}
side-channel-list@1.0.1:
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
engines: {node: '>= 0.4'}
@@ -5015,6 +5063,41 @@ snapshots:
'@sec-ant/readable-stream@0.4.1': {}
'@shikijs/core@1.29.2':
dependencies:
'@shikijs/engine-javascript': 1.29.2
'@shikijs/engine-oniguruma': 1.29.2
'@shikijs/types': 1.29.2
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@1.29.2':
dependencies:
'@shikijs/types': 1.29.2
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 2.3.0
'@shikijs/engine-oniguruma@1.29.2':
dependencies:
'@shikijs/types': 1.29.2
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/langs@1.29.2':
dependencies:
'@shikijs/types': 1.29.2
'@shikijs/themes@1.29.2':
dependencies:
'@shikijs/types': 1.29.2
'@shikijs/types@1.29.2':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/vscode-textmate@10.0.2': {}
'@sindresorhus/merge-streams@4.0.0': {}
'@tailwindcss/node@4.3.0':
@@ -5444,6 +5527,8 @@ snapshots:
electron-to-chromium@1.5.355: {}
emoji-regex-xs@1.0.0: {}
emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {}
@@ -5802,6 +5887,20 @@ snapshots:
dependencies:
function-bind: 1.1.2
hast-util-to-html@9.0.5:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
ccount: 2.0.1
comma-separated-tokens: 2.0.3
hast-util-whitespace: 3.0.0
html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.1
property-information: 7.1.0
space-separated-tokens: 2.0.2
stringify-entities: 4.0.4
zwitch: 2.0.4
hast-util-to-jsx-runtime@2.3.6:
dependencies:
'@types/estree': 1.0.8
@@ -5835,6 +5934,8 @@ snapshots:
html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
http-errors@2.0.0:
dependencies:
depd: 2.0.0
@@ -6528,6 +6629,12 @@ snapshots:
dependencies:
mimic-function: 5.0.1
oniguruma-to-es@2.3.0:
dependencies:
emoji-regex-xs: 1.0.0
regex: 5.1.1
regex-recursion: 5.1.1
open@11.0.0:
dependencies:
default-browser: 5.5.0
@@ -6821,6 +6928,17 @@ snapshots:
tiny-invariant: 1.3.3
tslib: 2.8.1
regex-recursion@5.1.1:
dependencies:
regex: 5.1.1
regex-utilities: 2.3.0
regex-utilities@2.3.0: {}
regex@5.1.1:
dependencies:
regex-utilities: 2.3.0
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4
@@ -7021,6 +7139,17 @@ snapshots:
shebang-regex@3.0.0: {}
shiki@1.29.2:
dependencies:
'@shikijs/core': 1.29.2
'@shikijs/engine-javascript': 1.29.2
'@shikijs/engine-oniguruma': 1.29.2
'@shikijs/langs': 1.29.2
'@shikijs/themes': 1.29.2
'@shikijs/types': 1.29.2
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
side-channel-list@1.0.1:
dependencies:
es-errors: 1.3.0