Compare commits

...

22 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
842cf146ec v1.1 batch 2: sidebar restructure — chats under projects, max 5 + view-all, live updates
Schema (idempotent):
  ALTER TABLE sessions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp();
The column already exists from v1 (DEFAULT NOW()); ALTER is a no-op kept for
self-documentation. Explicit clock_timestamp() bumps now run wherever the
column actually matters — see services/inference.ts and routes/sessions.ts.

Backend updated_at maintenance:
- services/inference.ts: after each terminal status UPDATE on the assistant
  message (failure / tool-call complete / clean complete), also bump
  sessions.updated_at = clock_timestamp() so the parent session jumps to
  the top of recency ordering on every assistant turn.
- routes/sessions.ts PATCH: NOW() → clock_timestamp() for consistency.

New endpoint GET /api/sidebar (routes/sidebar.ts):
  { projects: [{ id, name, recent_sessions[≤6], total_sessions }] }
One outer query for projects ordered added_at DESC; per-project Promise.all
over (recent_sessions LIMIT 6 ORDER BY updated_at DESC) and COUNT(*)::int.
Outer Promise.all parallelizes across projects. Two queries per project; the
composite idx_sessions_project(project_id, updated_at DESC) serves the inner
query. Auth via the global Remote-User hook. types/api.ts gains
SidebarSession / SidebarProject / SidebarResponse; index.ts wires the route.

Frontend foundations:
- api/types.ts mirrors the three sidebar interfaces.
- api/client.ts: api.sidebar.get() → Promise<SidebarResponse>.
- hooks/sessionEvents.ts: five-variant union — added project_created,
  project_deleted, session_created, session_deleted. session_renamed
  unchanged from Batch 1. Bus internals untouched (still a dumb
  Set<Listener>, no validation).

New hooks/useSidebar.ts (module-singleton):
- Module-scope sharedData/sharedError/sharedLoading/initialized/fetchInFlight/
  subscribers; a single sessionEvents.subscribe at module-top-level mutates
  sharedData via an exhaustive switch over the five events. load() dedupes
  parallel calls via fetchInFlight. Hook is a thin subscription layer: any
  number of mount points share state and the very first one triggers the
  single GET /api/sidebar. Subsequent mounts read cached state synchronously
  (no skeleton flash). Public shape: { data, error, loading, retry }.
- Lift to module-scope was driven by the "ONE sidebar request on mount"
  spec promise — both ProjectSidebar AND Home consume the hook now, and
  they share the singleton.

Frontend UI:
- components/ProjectSidebar.tsx (rewrite, 234 lines): per-project chevron +
  folder + name; chevron toggles expand, name navigates /project/:id.
  Expanded → ≤5 sessions with MessageSquare + name + muted relTime()
  timestamp. "View all (N)" link when total_sessions > 5, routing to
  /project/:id. Active session row uses bg-sidebar-accent. Active project
  always renders expanded (URL-derived: direct /project/:id or scan of
  recent_sessions for /session/:id). Expanded ids persisted in
  localStorage['boocode.sidebar.expanded'] with try/catch on both read and
  write. Loading shows 4 muted-pulse skeleton blocks; empty + error +
  retry button; error toast guarded by ref so it fires once per distinct
  message and resets on recovery. Remove path calls api.projects.remove
  directly + explicit project_deleted emit (replaced the prior
  useProjects() dependency which fired a redundant /api/projects on
  mount, violating the one-fetch promise).
- components/AddProjectModal.tsx: captures returned Project and emits
  project_created before onAdded() / onOpenChange(false).
- pages/Project.tsx: emits session_created after create(); trash button is
  now async with try/catch — emits session_deleted on success,
  toast.error on failure.
- pages/Home.tsx: switched from useProjects to useSidebar so loading /
  fires exactly one /api/sidebar, with no parallel /api/projects.
- pages/Session.tsx: manual inline rename now emits session_renamed on
  the success path so the sidebar updates live without a refresh (also
  fixes the regression made visible by Batch 2 — the sidebar caches
  session names where the project page used to re-fetch on every visit).

useProjects.ts retains a project_deleted emit inside remove for any future
caller; no live consumer uses it (ProjectSidebar calls api.projects.remove
directly). Acknowledged dead code, to be removed in the next cleanup pass
along with three remaining NOW() → clock_timestamp() consistency flips at
routes/messages.ts:70, routes/messages.ts:127, and services/auto_name.ts:144.

Cross-tab parity for session_created/session_deleted/project_created/
project_deleted is deferred — those events are tab-local in Batch 2 per
spec. session_renamed continues to propagate cross-tab via the existing
WS frame from Batch 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:19:59 +00:00
2464d23bb6 v1.1 batch 1: markdown, message actions, tok/s+ctx, AI naming
Four features land together on this branch:

1. Markdown rendering — assistant messages go through react-markdown +
   remark-gfm. Fenced code blocks render via existing CodeBlock (with copy
   button); inline `code` is styled inline. User messages stay plain text.
   No raw HTML (no rehype-raw).

2. Per-message Copy + Regenerate. New endpoint
   POST /api/sessions/:id/messages/:message_id/regenerate validates the
   target (404/400/409), atomically deletes the target plus any later
   messages in the session, inserts a fresh streaming assistant row, and
   enqueues a normal inference run. The DELETE bound uses a SQL subquery
   (`created_at >= (SELECT created_at FROM messages WHERE id = $1)`)
   instead of a JS round-trip so postgres TIMESTAMPTZ µs precision is
   preserved — otherwise sub-ms clock_timestamp() differences between the
   user row and the assistant row collapsed to the same JS Date, pulling
   the triggering user message into the >= bound. New `messages_deleted`
   WS frame so already-connected clients prune the stale tail without
   needing a full snapshot resend.

3. tok/s + ctx counter. Five new nullable message columns: tokens_used,
   ctx_used, ctx_max, started_at, finished_at. started_at is set right
   before the OpenAI call in services/inference.ts (not in the route, not
   in the frame handler); finished_at + tokens_used + ctx_used + ctx_max
   are committed in the same UPDATE that flips status to 'complete'. The
   inference request now opts into stream_options.include_usage so the
   final chunk carries usage; defensive parsing also picks up timings.n_ctx
   when llama.cpp emits it (currently absent for our llama-swap models, so
   ctx_max stays NULL and the UI just shows `<used> ctx`). message_complete
   frame extended with tokens_used / ctx_used / ctx_max / started_at /
   finished_at / model. Frontend StatsLine in MessageBubble computes tok/s
   client-side from the timestamps and renders muted mono text below the
   body of completed assistant messages.

4. AI chat naming after the first turn. Backend services/auto_name.ts
   runs via setImmediate after the top-level inference resolves; it
   checks that there is exactly one completed assistant message and that
   the session has not been user-renamed (`name IS NULL OR name = '' OR
   name = 'New session'`), then fires a single non-streaming chat
   completion with the spec prompt. Qwen3 chat templates emit chain-of-
   thought into reasoning_content and burn the entire max_tokens budget
   without producing visible output, so the request includes
   `chat_template_kwargs: { enable_thinking: false }` and max_tokens=30.
   Title is trimmed, quote-stripped, "Title:" prefix dropped, and
   truncated to 60 chars before a guarded UPDATE on sessions.name. New
   `session_renamed` WS frame propagates to the open session view
   directly and to the project's session list via a tiny module-scope
   event bus (apps/web/src/hooks/sessionEvents.ts) — kept dumb: one event
   type, two methods, no library.

Cleanups: dropped the now-unused splitCodeBlocks export from CodeBlock.tsx
(react-markdown supersedes it), and added a long-form NOTE in auto_name.ts
documenting the enable_thinking + max_tokens pattern for any future Qwen-
family non-streaming utility calls (planned: fork-message, agent-routing,
web-search summarization).

Schema bootstrap remains idempotent (ADD COLUMN IF NOT EXISTS). Auth,
broker, clock_timestamp() conventions, and zod validation all unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:52:40 +00:00
42 changed files with 5140 additions and 367 deletions

View File

@@ -10,8 +10,10 @@ import { registerProjectRoutes } from './routes/projects.js';
import { registerSessionRoutes } from './routes/sessions.js';
import { registerSettingsRoutes } from './routes/settings.js';
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';
@@ -35,23 +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, {
onSend: (sessionId, _userId, assistantId) => {
inference.enqueue(sessionId, assistantId);
enqueueInference: (sessionId, assistantId, user) => {
inference.enqueue(sessionId, assistantId, user);
},
publishUserMessage: (sessionId, userMessageId, content) => {
broker.publish(sessionId, {
@@ -69,6 +79,12 @@ async function main() {
message_id: userMessageId,
});
},
publishMessagesDeleted: (sessionId, messageIds) => {
broker.publish(sessionId, {
type: 'messages_deleted',
message_ids: messageIds,
});
},
});
registerWebSocket(app, sql, broker);

View File

@@ -2,18 +2,20 @@ 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 {
onSend: (sessionId: string, userMessageId: string, assistantMessageId: string) => void;
enqueueInference: (sessionId: string, assistantMessageId: string, user: string) => void;
publishUserMessage: (
sessionId: string,
userMessageId: string,
content: string
) => void;
publishMessagesDeleted: (sessionId: string, messageIds: string[]) => void;
}
export function registerMessageRoutes(
@@ -30,7 +32,8 @@ export function registerMessageRoutes(
return { error: 'session not found' };
}
const rows = await sql<Message[]>`
SELECT id, session_id, role, content, tool_calls, tool_results, status, last_seq, created_at
SELECT id, session_id, role, content, tool_calls, tool_results, status, last_seq,
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at
FROM messages
WHERE session_id = ${req.params.id}
ORDER BY created_at ASC, id ASC
@@ -74,10 +77,66 @@ export function registerMessageRoutes(
result.user_message_id,
parsed.data.content
);
handlers.onSend(req.params.id, result.user_message_id, result.assistant_message_id);
handlers.enqueueInference(req.params.id, result.assistant_message_id, requireUser(req));
reply.code(202);
return result;
}
);
app.post<{ Params: { id: string; message_id: string } }>(
'/api/sessions/:id/messages/:message_id/regenerate',
async (req, reply) => {
const { id: sessionId, message_id: targetId } = req.params;
const target = await sql<{ id: string; role: string; status: string }[]>`
SELECT id, role, status
FROM messages
WHERE session_id = ${sessionId} AND id = ${targetId}
`;
if (target.length === 0) {
reply.code(404);
return { error: 'message not found' };
}
const targetRow = target[0]!;
if (targetRow.role !== 'assistant') {
reply.code(400);
return { error: 'only assistant messages can be regenerated' };
}
if (targetRow.status === 'streaming') {
reply.code(409);
return { error: 'message is still streaming' };
}
const { newAssistantId, deletedIds } = await sql.begin(async (tx) => {
// Subquery keeps created_at in postgres at TIMESTAMPTZ µs precision.
// Round-tripping through JS Date loses sub-ms precision and can pull
// earlier rows (e.g. the triggering user message) into the >= bound.
const deletedRows = await tx<{ id: string }[]>`
DELETE FROM messages
WHERE session_id = ${sessionId}
AND created_at >= (
SELECT created_at FROM messages WHERE id = ${targetId}
)
RETURNING id
`;
const [row] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, role, content, status, created_at)
VALUES (${sessionId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await tx`UPDATE sessions SET updated_at = NOW() WHERE id = ${sessionId}`;
return {
newAssistantId: row!.id,
deletedIds: deletedRows.map((r) => r.id),
};
});
handlers.publishMessagesDeleted(sessionId, deletedIds);
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;
}
@@ -111,7 +126,7 @@ export function registerSessionRoutes(
name = COALESCE(${name ?? null}, name),
model = COALESCE(${model ?? null}, model),
system_prompt = COALESCE(${system_prompt ?? null}, system_prompt),
updated_at = NOW()
updated_at = clock_timestamp()
WHERE id = ${req.params.id}
RETURNING id, project_id, name, model, system_prompt, created_at, updated_at
`;
@@ -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

@@ -0,0 +1,44 @@
import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
import type {
SidebarProject,
SidebarResponse,
SidebarSession,
} from '../types/api.js';
export function registerSidebarRoutes(app: FastifyInstance, sql: Sql): void {
app.get('/api/sidebar', async (): Promise<SidebarResponse> => {
const projects = await sql<{ id: string; name: string }[]>`
SELECT id, name
FROM projects
ORDER BY added_at DESC
`;
const enriched: SidebarProject[] = await Promise.all(
projects.map(async (p) => {
const [recent_sessions, countRows] = await Promise.all([
sql<SidebarSession[]>`
SELECT id, project_id, name, model, updated_at
FROM sessions
WHERE project_id = ${p.id}
ORDER BY updated_at DESC
LIMIT 6
`,
sql<{ n: number }[]>`
SELECT COUNT(*)::int AS n
FROM sessions
WHERE project_id = ${p.id}
`,
]);
return {
id: p.id,
name: p.name,
recent_sessions,
total_sessions: countRows[0]?.n ?? 0,
};
})
);
return { projects: enriched };
});
}

View File

@@ -22,7 +22,8 @@ export function registerWebSocket(
}
const messages = await sql<Message[]>`
SELECT id, session_id, role, content, tool_calls, tool_results, status, last_seq, created_at
SELECT id, session_id, role, content, tool_calls, tool_results, status, last_seq,
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at
FROM messages
WHERE session_id = ${sessionId}
ORDER BY created_at ASC, id ASC
@@ -42,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

@@ -32,9 +32,37 @@ CREATE TABLE IF NOT EXISTS messages (
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
ALTER TABLE messages ADD COLUMN IF NOT EXISTS tokens_used INTEGER;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS ctx_used INTEGER;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS ctx_max INTEGER;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS finished_at TIMESTAMPTZ;
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp();
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value JSONB NOT NULL
);
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

@@ -0,0 +1,157 @@
import type { InferenceContext } from './inference.js';
const NAMING_SYSTEM_PROMPT =
'You name chat sessions. Reply directly with no thinking, reasoning, or explanation. Output ONLY the title, 4 words max, no quotes, no punctuation, no prefix like "Title:".';
const MAX_TITLE_CHARS = 60;
// QWEN3 NON-STREAMING UTILITY-CALL PATTERN
// ----------------------------------------
// Qwen3-family chat templates default to chain-of-thought reasoning: the
// model emits a long <think>…</think> block into `reasoning_content` and
// only finalizes a real reply in `content`. For short utility calls
// (naming, classification, routing, summarization) with a tight token
// budget, the model burns the entire budget on reasoning and returns:
// - content: ""
// - reasoning_content: "Thinking Process: 1. ..." (mid-thought, truncated)
// - finish_reason: "length"
// Fix: pass `chat_template_kwargs: { enable_thinking: false }` to skip the
// thinking block, and keep `max_tokens` low (~30 is plenty for a 4-word
// title). The kwarg is a no-op for non-Qwen chat templates, so it's safe
// to apply unconditionally for any short non-streaming model call.
// Apply this same pattern to: fork-message (planned), agent-routing
// (planned), web-search summarization (planned).
function cleanTitle(raw: string): string {
let name = raw.trim();
// Strip surrounding straight or smart quotes (one layer).
const quotes = ['"', "'", '`', '', '', '“', '”'];
while (name.length >= 2 && quotes.includes(name[0]!) && quotes.includes(name[name.length - 1]!)) {
name = name.slice(1, -1).trim();
}
// Drop a leading "Title:" prefix if the model added one despite instructions.
name = name.replace(/^title\s*:\s*/i, '').trim();
if (name.length > MAX_TITLE_CHARS) {
name = name.slice(0, MAX_TITLE_CHARS).trim();
}
return name;
}
interface NamingResponse {
choices?: Array<{
message?: {
content?: string;
reasoning_content?: string;
};
}>;
}
// Some Qwen-family models emit "thinking" tokens into reasoning_content and
// only finalize a real reply in content. Pull a sensible candidate string.
function pickTitleSource(data: NamingResponse): string {
const choice = data.choices?.[0]?.message;
if (!choice) return '';
if (choice.content && choice.content.trim().length > 0) return choice.content;
// Fallback: try to extract a last-line title from reasoning, if present.
const reasoning = choice.reasoning_content ?? '';
if (reasoning.length === 0) return '';
const lines = reasoning
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0);
return lines[lines.length - 1] ?? '';
}
export async function maybeAutoNameSession(
ctx: InferenceContext,
sessionId: string
): Promise<void> {
const counts = await ctx.sql<{ n: number }[]>`
SELECT COUNT(*)::int AS n
FROM messages
WHERE session_id = ${sessionId}
AND role = 'assistant'
AND status = 'complete'
`;
if (counts[0]?.n !== 1) return;
const sessionRows = await ctx.sql<
{ id: string; name: string; model: string }[]
>`
SELECT id, name, model FROM sessions WHERE id = ${sessionId}
`;
const session = sessionRows[0];
if (!session) return;
const existingName = session.name ?? '';
if (existingName !== '' && existingName !== 'New session') return;
const userMsg = await ctx.sql<{ content: string }[]>`
SELECT content FROM messages
WHERE session_id = ${sessionId} AND role = 'user'
ORDER BY created_at ASC
LIMIT 1
`;
const assistantMsg = await ctx.sql<{ content: string }[]>`
SELECT content FROM messages
WHERE session_id = ${sessionId}
AND role = 'assistant'
AND status = 'complete'
ORDER BY created_at ASC
LIMIT 1
`;
if (!userMsg[0] || !assistantMsg[0]) return;
const userText = userMsg[0].content.slice(0, 2000);
const assistantText = assistantMsg[0].content.slice(0, 2000);
const body = {
model: session.model,
messages: [
{ role: 'system', content: NAMING_SYSTEM_PROMPT },
{
role: 'user',
content: `First user message: ${userText}\nFirst assistant reply: ${assistantText}`,
},
],
max_tokens: 30,
temperature: 0.3,
stream: false,
// Qwen-family models default to chain-of-thought; this template kwarg
// tells llama.cpp's chat template renderer to skip the thinking block.
// Harmless for non-Qwen models.
chat_template_kwargs: { enable_thinking: false },
};
const res = await fetch(`${ctx.config.LLAMA_SWAP_URL}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`naming request failed: ${res.status} ${text.slice(0, 200)}`);
}
const data = (await res.json()) as NamingResponse;
const raw = pickTitleSource(data);
const name = cleanTitle(raw);
if (!name) {
ctx.log.warn({ sessionId, raw }, 'auto-name: empty title from model');
return;
}
const updated = await ctx.sql<{ id: string; name: string }[]>`
UPDATE sessions
SET name = ${name}, updated_at = NOW()
WHERE id = ${sessionId}
AND (name IS NULL OR name = '' OR name = 'New session')
RETURNING id, name
`;
if (updated.length === 0) return;
ctx.publish(sessionId, {
type: 'session_renamed',
session_id: sessionId,
name,
});
ctx.log.info({ sessionId, name }, 'session auto-named');
}

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,9 +1,10 @@
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';
const BASE_SYSTEM_PROMPT = (projectPath: string) =>
`You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`;
@@ -12,8 +13,17 @@ const DB_FLUSH_INTERVAL_MS = 500;
const MAX_TOOL_LOOP_DEPTH = 5;
export interface InferenceFrame {
type: 'message_started' | 'delta' | 'tool_call' | 'tool_result' | 'message_complete' | 'error';
type:
| 'message_started'
| 'delta'
| 'tool_call'
| 'tool_result'
| 'message_complete'
| 'messages_deleted'
| 'session_renamed'
| 'error';
message_id?: string;
message_ids?: string[];
tool_message_id?: string;
tool_call_id?: string;
role?: 'assistant' | 'tool' | 'user';
@@ -22,6 +32,14 @@ export interface InferenceFrame {
output?: unknown;
truncated?: boolean;
error?: string;
tokens_used?: number | null;
ctx_used?: number | null;
ctx_max?: number | null;
started_at?: string | null;
finished_at?: string | null;
model?: string;
session_id?: string;
name?: string;
}
export type FramePublisher = (sessionId: string, frame: InferenceFrame) => void;
@@ -49,17 +67,26 @@ interface ChatCompletionDelta {
}
interface ChatCompletionChunk {
choices: Array<{
choices?: Array<{
delta: ChatCompletionDelta;
finish_reason: string | null;
}>;
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
timings?: {
n_ctx?: number;
};
}
interface InferenceContext {
export interface InferenceContext {
sql: Sql;
config: Config;
log: FastifyBaseLogger;
publish: FramePublisher;
publishUser: (frame: UserStreamFrame) => void;
}
export function buildMessagesPayload(
@@ -130,7 +157,8 @@ async function loadContext(
const project = projectRows[0]!;
const history = await sql<Message[]>`
SELECT id, session_id, role, content, tool_calls, tool_results, status, last_seq, created_at
SELECT id, session_id, role, content, tool_calls, tool_results, status, last_seq,
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at
FROM messages
WHERE session_id = ${sessionId}
ORDER BY created_at ASC, id ASC
@@ -162,14 +190,28 @@ async function* sseLines(stream: ReadableStream<Uint8Array>): AsyncGenerator<str
}
}
interface StreamResult {
finishReason: string | null;
content: string;
toolCalls: ToolCall[];
promptTokens: number | null;
completionTokens: number | null;
nCtx: number | null;
}
async function streamCompletion(
ctx: InferenceContext,
model: string,
messages: OpenAiMessage[],
includeTools: boolean,
onDelta: (content: string) => void
): Promise<{ finishReason: string | null; content: string; toolCalls: ToolCall[] }> {
const body: Record<string, unknown> = { model, messages, stream: true };
): Promise<StreamResult> {
const body: Record<string, unknown> = {
model,
messages,
stream: true,
stream_options: { include_usage: true },
};
if (includeTools) {
body['tools'] = toolJsonSchemas();
body['tool_choice'] = 'auto';
@@ -187,6 +229,9 @@ async function streamCompletion(
let content = '';
let finishReason: string | null = null;
let promptTokens: number | null = null;
let completionTokens: number | null = null;
let nCtx: number | null = null;
const toolCallsBuffer = new Map<number, { id: string; name: string; argsText: string }>();
for await (const line of sseLines(res.body)) {
@@ -199,6 +244,19 @@ async function streamCompletion(
} catch {
continue;
}
if (parsed.usage) {
if (typeof parsed.usage.prompt_tokens === 'number') {
promptTokens = parsed.usage.prompt_tokens;
}
if (typeof parsed.usage.completion_tokens === 'number') {
completionTokens = parsed.usage.completion_tokens;
}
}
if (parsed.timings && typeof parsed.timings.n_ctx === 'number') {
nCtx = parsed.timings.n_ctx;
}
const choice = parsed.choices?.[0];
if (!choice) continue;
const delta = choice.delta ?? {};
@@ -232,7 +290,7 @@ async function streamCompletion(
toolCalls.push({ id: t.id || `call_${toolCalls.length}`, name: t.name, args });
}
return { finishReason, content, toolCalls };
return { finishReason, content, toolCalls, promptTokens, completionTokens, nCtx };
}
async function executeToolCall(
@@ -279,7 +337,9 @@ async function runAssistantTurn(
if (depth > MAX_TOOL_LOOP_DEPTH) {
await ctx.sql`
UPDATE messages
SET status = 'failed', content = ${'tool loop depth exceeded'}
SET status = 'failed',
content = ${'tool loop depth exceeded'},
finished_at = clock_timestamp()
WHERE id = ${assistantMessageId}
`;
ctx.publish(sessionId, {
@@ -299,6 +359,14 @@ async function runAssistantTurn(
const projectRoot = await resolveProjectRoot(project.path);
const messages = buildMessagesPayload(session, project, history);
const startedRow = await ctx.sql<{ started_at: string }[]>`
UPDATE messages
SET started_at = clock_timestamp()
WHERE id = ${assistantMessageId}
RETURNING started_at
`;
const startedAt = startedRow[0]?.started_at ?? null;
ctx.publish(sessionId, {
type: 'message_started',
message_id: assistantMessageId,
@@ -328,12 +396,9 @@ async function runAssistantTurn(
}, DB_FLUSH_INTERVAL_MS);
};
let content = '';
let finishReason: string | null = null;
let toolCalls: ToolCall[] = [];
let result: StreamResult;
try {
const result = await streamCompletion(
result = await streamCompletion(
ctx,
session.model,
messages,
@@ -349,9 +414,6 @@ async function runAssistantTurn(
scheduleFlush();
}
);
content = result.content;
finishReason = result.finishReason;
toolCalls = result.toolCalls;
} catch (err) {
if (pendingFlushTimer) {
clearTimeout(pendingFlushTimer);
@@ -360,9 +422,17 @@ async function runAssistantTurn(
const errMsg = err instanceof Error ? err.message : String(err);
await ctx.sql`
UPDATE messages
SET status = 'failed', content = ${accumulated}
SET status = 'failed',
content = ${accumulated},
finished_at = clock_timestamp()
WHERE id = ${assistantMessageId}
`;
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,
@@ -378,13 +448,29 @@ async function runAssistantTurn(
}
await flushPromise;
const { content, finishReason, toolCalls, promptTokens, completionTokens, nCtx } = result;
if (toolCalls.length > 0) {
await ctx.sql`
const [updated] = await ctx.sql<
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
>`
UPDATE messages
SET content = ${content}, status = 'complete',
tool_calls = ${ctx.sql.json(toolCalls as never)}
SET content = ${content},
status = 'complete',
tool_calls = ${ctx.sql.json(toolCalls as never)},
tokens_used = ${completionTokens},
ctx_used = ${promptTokens},
ctx_max = ${nCtx},
finished_at = clock_timestamp()
WHERE id = ${assistantMessageId}
RETURNING tokens_used, ctx_used, ctx_max, finished_at
`;
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',
@@ -395,6 +481,12 @@ async function runAssistantTurn(
ctx.publish(sessionId, {
type: 'message_complete',
message_id: assistantMessageId,
tokens_used: updated?.tokens_used ?? null,
ctx_used: updated?.ctx_used ?? null,
ctx_max: updated?.ctx_max ?? null,
started_at: startedAt,
finished_at: updated?.finished_at ?? null,
model: session.model,
});
await Promise.all(
@@ -405,12 +497,12 @@ async function runAssistantTurn(
RETURNING id
`;
const toolMessageId = toolRow!.id;
const result = await executeToolCall(projectRoot, tc);
const tres = await executeToolCall(projectRoot, tc);
const stored = {
tool_call_id: tc.id,
output: result.output,
truncated: result.truncated,
...(result.error ? { error: result.error } : {}),
output: tres.output,
truncated: tres.truncated,
...(tres.error ? { error: tres.error } : {}),
};
await ctx.sql`
UPDATE messages
@@ -421,9 +513,9 @@ async function runAssistantTurn(
type: 'tool_result',
tool_message_id: toolMessageId,
tool_call_id: tc.id,
output: result.output,
truncated: result.truncated,
...(result.error ? { error: result.error } : {}),
output: tres.output,
truncated: tres.truncated,
...(tres.error ? { error: tres.error } : {}),
});
})
);
@@ -437,16 +529,46 @@ async function runAssistantTurn(
return;
}
await ctx.sql`
const [updated] = await ctx.sql<
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
>`
UPDATE messages
SET content = ${content}, status = 'complete'
SET content = ${content},
status = 'complete',
tokens_used = ${completionTokens},
ctx_used = ${promptTokens},
ctx_max = ${nCtx},
finished_at = clock_timestamp()
WHERE id = ${assistantMessageId}
RETURNING tokens_used, ctx_used, ctx_max, finished_at
`;
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,
tokens_used: updated?.tokens_used ?? null,
ctx_used: updated?.ctx_used ?? null,
ctx_max: updated?.ctx_max ?? null,
started_at: startedAt,
finished_at: updated?.finished_at ?? null,
model: session.model,
});
ctx.log.info({ sessionId, assistantMessageId, finishReason, chars: content.length }, 'inference complete');
ctx.log.info(
{
sessionId,
assistantMessageId,
finishReason,
chars: content.length,
tokens_used: updated?.tokens_used,
ctx_used: updated?.ctx_used,
},
'inference complete'
);
}
export async function runInference(
@@ -457,12 +579,28 @@ 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) {
void runInference(ctx, sessionId, assistantMessageId).catch((err) => {
ctx.log.error({ err }, 'unhandled inference error');
});
enqueue(sessionId: string, assistantMessageId: string, user: string) {
const callCtx: InferenceContext = {
...ctx,
publishUser: (frame) => publishUserFn(user, frame),
};
void (async () => {
try {
await runInference(callCtx, sessionId, assistantMessageId);
setImmediate(() => {
void maybeAutoNameSession(callCtx, sessionId).catch((err) => {
callCtx.log.warn({ err, sessionId }, 'auto-name failed');
});
});
} catch (err) {
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

@@ -46,6 +46,11 @@ export interface Message {
tool_results: ToolResult | null;
status: MessageStatus;
last_seq: number;
tokens_used: number | null;
ctx_used: number | null;
ctx_max: number | null;
started_at: string | null;
finished_at: string | null;
created_at: string;
}
@@ -53,3 +58,90 @@ export interface ModelInfo {
id: string;
[key: string]: unknown;
}
export interface SidebarSession {
id: string;
project_id: string;
name: string;
model: string;
updated_at: string;
}
export interface SidebarProject {
id: string;
name: string;
recent_sessions: SidebarSession[];
total_sessions: number;
}
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

@@ -19,8 +19,11 @@
"radix-ui": "^1.4.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"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

@@ -4,6 +4,12 @@ import type {
Session,
Message,
ModelInfo,
SidebarResponse,
ListDirResult,
ViewFileResult,
Pane,
PaneCreateRequest,
PaneUpdateRequest,
} from './types';
export class ApiError extends Error {
@@ -46,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: {
@@ -83,6 +95,11 @@ export const api = {
body: JSON.stringify({ content }),
}
),
regenerate: (sessionId: string, messageId: string) =>
request<{ assistant_message_id: string }>(
`/api/sessions/${sessionId}/messages/${messageId}/regenerate`,
{ method: 'POST' }
),
},
models: () => request<ModelInfo[]>('/api/models'),
@@ -95,4 +112,25 @@ export const api = {
body: JSON.stringify(body),
}),
},
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

@@ -46,6 +46,11 @@ export interface Message {
tool_results: ToolResult | null;
status: MessageStatus;
last_seq: number;
tokens_used: number | null;
ctx_used: number | null;
ctx_max: number | null;
started_at: string | null;
finished_at: string | null;
created_at: string;
}
@@ -54,6 +59,74 @@ export interface ModelInfo {
[key: string]: unknown;
}
export interface SidebarSession {
id: string;
name: string;
model: string;
updated_at: string;
project_id: string;
}
export interface SidebarProject {
id: string;
name: string;
recent_sessions: SidebarSession[];
total_sessions: number;
}
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 }
@@ -67,5 +140,15 @@ export type WsFrame =
truncated: boolean;
error?: string;
}
| { type: 'message_complete'; message_id: string }
| {
type: 'message_complete';
message_id: string;
tokens_used?: number | null;
ctx_used?: number | null;
ctx_max?: number | null;
started_at?: string | null;
finished_at?: string | null;
}
| { type: 'messages_deleted'; message_ids: string[] }
| { type: 'session_renamed'; session_id: string; name: string }
| { type: 'error'; message_id?: string; error: string };

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { api } from '@/api/client';
import type { AvailableProject } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -42,7 +43,8 @@ export function AddProjectModal({ open, onOpenChange, onAdded }: Props) {
setBusy(true);
setError(null);
try {
await api.projects.add({ path });
const created = await api.projects.add({ path });
sessionEvents.emit({ type: 'project_created', project: created });
onAdded();
onOpenChange(false);
} catch (err) {

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,42 +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>
);
}
interface SegmentText {
kind: 'text';
value: string;
}
interface SegmentCode {
kind: 'code';
lang?: string;
value: string;
}
export type Segment = SegmentText | SegmentCode;
export function splitCodeBlocks(input: string): Segment[] {
const segments: Segment[] = [];
const fence = /```([a-zA-Z0-9_-]*)\n([\s\S]*?)```/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = fence.exec(input)) !== null) {
if (match.index > lastIndex) {
segments.push({ kind: 'text', value: input.slice(lastIndex, match.index) });
}
segments.push({
kind: 'code',
lang: match[1] || undefined,
value: (match[2] ?? '').replace(/\n$/, ''),
});
lastIndex = match.index + match[0].length;
}
if (lastIndex < input.length) {
segments.push({ kind: 'text', value: input.slice(lastIndex) });
}
return segments;
}

View File

@@ -1,49 +1,288 @@
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, splitCodeBlocks } from './CodeBlock';
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;
}
export function MessageBubble({ message }: Props) {
function MarkdownBody({ content }: { content: string }) {
return (
<Markdown
remarkPlugins={[remarkGfm]}
components={{
pre: ({ children }) => <>{children}</>,
code: (props) => {
const { children, className, ...rest } = props as {
children?: unknown;
className?: string;
};
const text = String(children ?? '').replace(/\n$/, '');
const langMatch = /language-([\w-]+)/.exec(className ?? '');
const isBlock = !!langMatch || text.includes('\n');
if (isBlock) {
return <CodeBlock code={text} lang={langMatch?.[1]} />;
}
return (
<code
{...rest}
className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]"
>
{children as React.ReactNode}
</code>
);
},
a: ({ children, href }) => (
<a
href={href}
target="_blank"
rel="noreferrer"
className="underline decoration-muted-foreground/40 underline-offset-2 hover:decoration-foreground"
>
{children}
</a>
),
ul: ({ children }) => (
<ul className="list-disc pl-5 space-y-1">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal pl-5 space-y-1">{children}</ol>
),
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>,
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-border pl-3 text-muted-foreground">
{children}
</blockquote>
),
table: ({ children }) => (
<div className="overflow-x-auto">
<table className="border-collapse text-xs">{children}</table>
</div>
),
th: ({ children }) => (
<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">
{linkifyChildren(children)}
</td>
),
}}
>
{content}
</Markdown>
);
}
function StatsLine({ message }: { message: Message }) {
const tokens = message.tokens_used;
if (typeof tokens !== 'number' || tokens <= 0) return null;
const started = message.started_at ? Date.parse(message.started_at) : NaN;
const finished = message.finished_at ? Date.parse(message.finished_at) : NaN;
let tps: number | null = null;
if (!Number.isNaN(started) && !Number.isNaN(finished) && finished > started) {
const seconds = (finished - started) / 1000;
if (seconds > 0) tps = Math.round((tokens / seconds) * 10) / 10;
}
const ctxUsed = message.ctx_used;
const ctxMax = message.ctx_max;
const ctxPart =
typeof ctxUsed === 'number'
? typeof ctxMax === 'number' && ctxMax > 0
? `${ctxUsed} / ${ctxMax} ctx`
: `${ctxUsed} ctx`
: null;
const parts: string[] = [`${tokens} tokens`];
if (tps !== null) parts.push(`${tps.toFixed(1)} tok/s`);
if (ctxPart) parts.push(ctxPart);
return (
<div className="text-[10px] font-mono text-muted-foreground">
{parts.join(' · ')}
</div>
);
}
function ActionRow({
message,
sessionId,
}: {
message: Message;
sessionId: string;
}) {
const [justCopied, setJustCopied] = useState(false);
const [regenerating, setRegenerating] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(message.content);
setJustCopied(true);
setTimeout(() => setJustCopied(false), 1200);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'copy failed');
}
}
async function regenerate() {
if (regenerating || message.status === 'streaming') return;
setRegenerating(true);
try {
await api.messages.regenerate(sessionId, message.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'regenerate failed');
} finally {
setRegenerating(false);
}
}
const isAssistant = message.role === 'assistant';
const canRegen = isAssistant && message.status !== 'streaming';
return (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => void copy()}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Copy message"
title="Copy"
>
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
</button>
{isAssistant && (
<button
type="button"
onClick={() => void regenerate()}
disabled={!canRegen || regenerating}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Regenerate message"
title="Regenerate"
>
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
</button>
)}
</div>
);
}
export function MessageBubble({ message, sessionId }: Props) {
if (message.role === 'tool') {
return <ToolCallCard message={message} />;
}
if (message.role === 'user') {
return (
<div className="flex justify-end">
<div className="group flex flex-col items-end gap-1">
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap">
{message.content}
</div>
<ActionRow message={message} sessionId={sessionId} />
</div>
);
}
const isStreaming = message.status === 'streaming';
const failed = message.status === 'failed';
const hasContent = message.content.length > 0;
const hasToolCalls = (message.tool_calls?.length ?? 0) > 0;
return (
<div className="flex flex-col gap-2">
<div className="group flex flex-col gap-2">
{message.tool_calls?.map((tc) => (
<ToolCallCard key={tc.id} toolCall={tc} />
))}
{(message.content.length > 0 || (!message.tool_calls?.length && isStreaming)) && (
{(hasContent || (!hasToolCalls && isStreaming)) && (
<div className="max-w-[90%] text-sm leading-relaxed space-y-2">
{splitCodeBlocks(message.content).map((seg, i) =>
seg.kind === 'code' ? (
<CodeBlock key={i} code={seg.value} lang={seg.lang} />
) : (
<div key={i} className="whitespace-pre-wrap">
{seg.value}
{isStreaming && i === splitCodeBlocks(message.content).length - 1 && (
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse ml-0.5" />
)}
</div>
)
)}
{message.content.length === 0 && isStreaming && (
{hasContent ? <MarkdownBody content={message.content} /> : null}
{isStreaming && (
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
)}
</div>
@@ -51,6 +290,10 @@ export function MessageBubble({ message }: Props) {
{failed && (
<div className="text-xs text-destructive">message failed</div>
)}
{!isStreaming && <StatsLine message={message} />}
{!isStreaming && (hasContent || hasToolCalls) && (
<ActionRow message={message} sessionId={sessionId} />
)}
</div>
);
}

View File

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

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

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { NavLink, useNavigate } from 'react-router-dom';
import { Plus, Folder } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { ChevronRight, Folder, MessageSquare, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
@@ -10,88 +10,249 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { AddProjectModal } from './AddProjectModal';
import { useProjects } from '@/hooks/useProjects';
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;
function readExpanded(): Set<string> {
try {
const raw = localStorage.getItem(EXPANDED_KEY);
if (!raw) return new Set();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((v): v is string => typeof v === 'string'));
} catch {
return new Set();
}
}
function writeExpanded(ids: Set<string>): void {
try {
localStorage.setItem(EXPANDED_KEY, JSON.stringify(Array.from(ids)));
} catch {
/* quota or disabled storage — ignore */
}
}
function relTime(iso: string): string {
const now = Date.now();
const t = Date.parse(iso);
if (Number.isNaN(t)) return '';
const sec = Math.max(0, Math.floor((now - t) / 1000));
if (sec < 60) return `${sec}s`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h`;
const day = Math.floor(hr / 24);
if (day < 30) return `${day}d`;
const mo = Math.floor(day / 30);
if (mo < 12) return `${mo}mo`;
return `${Math.floor(mo / 12)}y`;
}
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;
// 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 {
const m = pathname.match(/^\/session\/([^/]+)/);
return m?.[1] ?? null;
}
export function ProjectSidebar() {
const { projects, refresh, remove } = useProjects();
const { data, error, loading, retry, activeSession: loadedActiveSession } =
useSidebar();
const [addOpen, setAddOpen] = useState(false);
const [expanded, setExpanded] = useState<Set<string>>(() => readExpanded());
const navigate = useNavigate();
const location = useLocation();
const lastToastedError = useRef<string | null>(null);
useEffect(() => {
if (error && !data && error !== lastToastedError.current) {
toast.error(error);
lastToastedError.current = error;
}
if (!error) lastToastedError.current = null;
}, [error, data]);
const projects = data?.projects ?? [];
const activeProject = useMemo(
() => activeProjectId(location.pathname, projects, loadedActiveSession),
[location.pathname, projects, loadedActiveSession]
);
const activeSession = useMemo(
() => activeSessionId(location.pathname),
[location.pathname]
);
function toggle(id: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
writeExpanded(next);
return next;
});
}
async function handleRemove(id: string) {
try {
await remove(id);
await api.projects.remove(id);
sessionEvents.emit({ type: 'project_deleted', project_id: id });
navigate('/');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to remove project');
}
}
const rowCls = (active: boolean) =>
active ? 'bg-sidebar-accent text-sidebar-accent-foreground' : 'hover:bg-sidebar-accent/60';
return (
<aside className="w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen">
<div className="px-4 py-3 border-b flex items-center justify-between">
<NavLink to="/" className="font-semibold tracking-tight text-base">
BooCode
</NavLink>
<Button
size="icon-sm"
variant="ghost"
onClick={() => setAddOpen(true)}
aria-label="Add project"
>
<Button size="icon-sm" variant="ghost" onClick={() => setAddOpen(true)} aria-label="Add project">
<Plus />
</Button>
</div>
<nav className="flex-1 overflow-y-auto py-2">
{projects === null && (
<div className="px-4 py-2 text-xs text-muted-foreground">Loading</div>
)}
{projects && projects.length === 0 && (
<div className="px-4 py-2 text-xs text-muted-foreground">No projects yet.</div>
)}
{projects?.map((p) => (
<div key={p.id} className="px-2">
<DropdownMenu>
<NavLink
to={`/project/${p.id}`}
className={({ isActive }) =>
`group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm ${
isActive
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'hover:bg-sidebar-accent/60'
}`
}
onContextMenu={(e) => {
e.preventDefault();
(
e.currentTarget.parentElement?.querySelector(
'[data-ctxtrigger]'
) as HTMLElement | null
)?.click();
}}
>
<Folder className="size-3.5 shrink-0 opacity-70" />
<span className="truncate" title={p.path}>
{p.name}
</span>
</NavLink>
<DropdownMenuTrigger asChild>
<button data-ctxtrigger className="hidden" aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
variant="destructive"
onClick={() => void handleRemove(p.id)}
>
Remove from sidebar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{loading && data == null && (
<div className="space-y-2 px-2">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="bg-muted/40 animate-pulse rounded h-6" />
))}
</div>
))}
)}
{data != null && projects.length === 0 && (
<div className="px-4 py-2 text-xs text-muted-foreground">
No projects yet. Click + to add one.
</div>
)}
{error != null && !data && (
<div className="px-4 py-2 space-y-2">
<div className="text-xs text-muted-foreground">{error}</div>
<Button size="sm" variant="outline" onClick={retry}>
Retry
</Button>
</div>
)}
{data != null &&
projects.map((p) => {
const isActiveProject = activeProject === p.id;
const isExpanded = isActiveProject || expanded.has(p.id);
const visible = p.recent_sessions.slice(0, MAX_VISIBLE_SESSIONS);
return (
<div key={p.id} className="px-2">
<DropdownMenu>
<div
className={`group flex items-center gap-1 rounded-md px-2 py-1.5 text-sm ${rowCls(isActiveProject)}`}
onContextMenu={(e) => {
e.preventDefault();
(
e.currentTarget.parentElement?.querySelector(
'[data-ctxtrigger]'
) as HTMLElement | null
)?.click();
}}
>
<button
type="button"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
aria-expanded={isExpanded}
disabled={isActiveProject}
onClick={(e) => {
e.stopPropagation();
if (isActiveProject) return;
toggle(p.id);
}}
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' : ''}`}
/>
</button>
<NavLink to={`/project/${p.id}`} className="flex items-center gap-2 min-w-0 flex-1">
<Folder className="size-3.5 shrink-0 opacity-70" />
<span className="truncate" title={p.name}>{p.name}</span>
</NavLink>
</div>
<DropdownMenuTrigger asChild>
<button data-ctxtrigger className="hidden" aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem variant="destructive" onClick={() => void handleRemove(p.id)}>
Remove from sidebar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{isExpanded && (
<div className="ml-5 mt-0.5 space-y-0.5">
{visible.map((s) => (
<NavLink
key={s.id}
to={`/session/${s.id}`}
className={`flex items-center gap-2 rounded-md px-2 py-1 text-sm min-w-0 ${rowCls(activeSession === s.id)}`}
>
<MessageSquare className="size-3.5 shrink-0 opacity-70" />
<span className="truncate flex-1" title={s.name}>{s.name}</span>
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
{relTime(s.updated_at)}
</span>
</NavLink>
))}
{p.total_sessions > MAX_VISIBLE_SESSIONS && (
<NavLink
to={`/project/${p.id}`}
className="block rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-sidebar-accent/60"
>
View all ({p.total_sessions})
</NavLink>
)}
</div>
)}
</div>
);
})}
</nav>
<AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={refresh} />
<AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={() => {}} />
</aside>
);
}

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

@@ -0,0 +1,83 @@
// Tiny in-app event bus for session metadata changes that need to propagate
// across hooks (e.g. AI rename arriving via WS in the session view needs to
// also refresh the sidebar's session list).
import type { Project, Session } from '@/api/types';
export interface SessionRenamedEvent {
type: 'session_renamed';
session_id: string;
name: string;
}
export interface ProjectCreatedEvent {
type: 'project_created';
project: Project;
}
export interface ProjectDeletedEvent {
type: 'project_deleted';
project_id: string;
}
export interface SessionCreatedEvent {
type: 'session_created';
session: Session;
project_id: string;
}
export interface SessionDeletedEvent {
type: 'session_deleted';
session_id: string;
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
| SessionUpdatedEvent
| SessionLoadedEvent
| OpenFileInBrowserEvent;
type Listener = (event: SessionEvent) => void;
const listeners = new Set<Listener>();
export const sessionEvents = {
emit(event: SessionEvent) {
for (const listener of listeners) {
try {
listener(event);
} catch {
// swallow — one bad listener shouldn't break others
}
}
},
subscribe(listener: Listener): () => void {
listeners.add(listener);
return () => {
listeners.delete(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

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

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import type { Message, WsFrame } from '@/api/types';
import { sessionEvents } from './sessionEvents';
interface State {
messages: Message[];
@@ -24,6 +25,11 @@ function applyFrame(state: State, frame: WsFrame): State {
tool_results: null,
status: 'streaming',
last_seq: 0,
tokens_used: null,
ctx_used: null,
ctx_max: null,
started_at: null,
finished_at: null,
created_at: new Date().toISOString(),
};
return { ...state, messages: [...state.messages, newMsg] };
@@ -76,16 +82,47 @@ function applyFrame(state: State, frame: WsFrame): State {
},
status: 'complete',
last_seq: 0,
tokens_used: null,
ctx_used: null,
ctx_max: null,
started_at: null,
finished_at: null,
created_at: new Date().toISOString(),
};
return { ...state, messages: [...state.messages, newMsg] };
}
case 'message_complete': {
const next = state.messages.map((m) =>
m.id === frame.message_id ? { ...m, status: 'complete' as const } : m
m.id === frame.message_id
? {
...m,
status: 'complete' as const,
...(frame.tokens_used !== undefined ? { tokens_used: frame.tokens_used } : {}),
...(frame.ctx_used !== undefined ? { ctx_used: frame.ctx_used } : {}),
...(frame.ctx_max !== undefined ? { ctx_max: frame.ctx_max } : {}),
...(frame.started_at !== undefined ? { started_at: frame.started_at } : {}),
...(frame.finished_at !== undefined ? { finished_at: frame.finished_at } : {}),
}
: m
);
return { ...state, messages: next };
}
case 'messages_deleted': {
const removeSet = new Set(frame.message_ids);
return {
...state,
messages: state.messages.filter((m) => !removeSet.has(m.id)),
};
}
case 'session_renamed': {
// Side-effect, not state — dispatch via event bus to other hooks.
sessionEvents.emit({
type: 'session_renamed',
session_id: frame.session_id,
name: frame.name,
});
return state;
}
case 'error': {
const next = frame.message_id
? state.messages.map((m) =>

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { api } from '@/api/client';
import type { Session } from '@/api/types';
import { sessionEvents } from './sessionEvents';
export function useSessions(projectId: string | undefined) {
const [sessions, setSessions] = useState<Session[] | null>(null);
@@ -24,6 +25,23 @@ export function useSessions(projectId: string | undefined) {
void refresh();
}, [refresh]);
useEffect(() => {
return sessionEvents.subscribe((event) => {
if (event.type !== 'session_renamed') return;
setSessions((prev) => {
if (!prev) return prev;
let changed = false;
const next = prev.map((s) => {
if (s.id !== event.session_id) return s;
if (s.name === event.name) return s;
changed = true;
return { ...s, name: event.name };
});
return changed ? next : prev;
});
});
}, []);
const create = useCallback(
async (body: { name?: string; model?: string; system_prompt?: string }) => {
if (!projectId) throw new Error('no project');

View File

@@ -0,0 +1,203 @@
import { useEffect, useState } from 'react';
import { api } from '@/api/client';
import type { SidebarProject, SidebarResponse, SidebarSession } from '@/api/types';
import { sessionEvents } from './sessionEvents';
const RECENT_SESSIONS_LIMIT = 6;
// Module-scope shared state — there is at most one sidebar fetch
// for the lifetime of the page, regardless of how many components
// call useSidebar().
let sharedData: SidebarResponse | null = null;
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 {
for (const sub of subscribers) {
try {
sub();
} catch {
// swallow — one bad subscriber shouldn't break others
}
}
}
function load(): Promise<void> {
if (fetchInFlight) return fetchInFlight;
sharedLoading = true;
sharedError = null;
notify();
const p = (async () => {
try {
const res = await api.sidebar.get();
sharedData = res;
sharedError = null;
} catch (err) {
sharedData = null;
sharedError = err instanceof Error ? err.message : 'failed to load sidebar';
} finally {
sharedLoading = false;
fetchInFlight = null;
notify();
}
})();
fetchInFlight = p;
return p;
}
function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').SessionEvent): SidebarResponse {
switch (event.type) {
case 'project_created': {
const fresh: SidebarProject = {
id: event.project.id,
name: event.project.name,
recent_sessions: [],
total_sessions: 0,
};
return { ...prev, projects: [fresh, ...prev.projects] };
}
case 'project_deleted': {
const next = prev.projects.filter((p) => p.id !== event.project_id);
if (next.length === prev.projects.length) return prev;
return { ...prev, projects: next };
}
case 'session_created': {
let changed = false;
const projects = prev.projects.map((p) => {
if (p.id !== event.project_id) return p;
changed = true;
const fresh: SidebarSession = {
id: event.session.id,
name: event.session.name,
model: event.session.model,
updated_at: event.session.updated_at,
project_id: event.project_id,
};
return {
...p,
recent_sessions: [fresh, ...p.recent_sessions].slice(0, RECENT_SESSIONS_LIMIT),
total_sessions: p.total_sessions + 1,
};
});
return changed ? { ...prev, projects } : prev;
}
case 'session_deleted': {
let changed = false;
const projects = prev.projects.map((p) => {
if (p.id !== event.project_id) return p;
changed = true;
const recent = p.recent_sessions.filter((s) => s.id !== event.session_id);
return {
...p,
recent_sessions: recent,
total_sessions: Math.max(0, p.total_sessions - 1),
};
});
return changed ? { ...prev, projects } : prev;
}
case 'session_renamed': {
let changed = false;
const projects = prev.projects.map((p) => {
let projectChanged = false;
const recent = p.recent_sessions.map((s) => {
if (s.id !== event.session_id) return s;
if (s.name === event.name) return s;
projectChanged = true;
return { ...s, name: event.name };
});
if (!projectChanged) return p;
changed = true;
return { ...p, recent_sessions: recent };
});
return changed ? { ...prev, projects } : prev;
}
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;
}
}
// One bus subscription for the lifetime of the module. Events arriving
// 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;
sharedData = next;
notify();
});
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, activeSession };
}
export function useSidebar(): {
data: SidebarResponse | null;
error: string | null;
loading: boolean;
retry: () => void;
activeSession: { session_id: string; project_id: string } | null;
} {
const [state, setState] = useState<Snapshot>(snapshot);
useEffect(() => {
const sub = () => setState(snapshot());
subscribers.add(sub);
// Sync up if the module state changed between render and effect.
sub();
if (!initialized) {
initialized = true;
void load();
}
return () => {
subscribers.delete(sub);
};
}, []);
const retry = () => {
void load();
};
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,13 +1,13 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { AddProjectModal } from '@/components/AddProjectModal';
import { useProjects } from '@/hooks/useProjects';
import { useSidebar } from '@/hooks/useSidebar';
export function Home() {
const { projects, refresh } = useProjects();
const { data } = useSidebar();
const [open, setOpen] = useState(false);
const empty = projects && projects.length === 0;
const empty = data ? data.projects.length === 0 : false;
return (
<div className="flex-1 flex items-center justify-center px-6">
@@ -29,7 +29,7 @@ export function Home() {
</>
)}
</div>
<AddProjectModal open={open} onOpenChange={setOpen} onAdded={refresh} />
<AddProjectModal open={open} onOpenChange={setOpen} onAdded={() => {}} />
</div>
);
}

View File

@@ -1,9 +1,11 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { Plus, MessageSquare, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { Project as ProjectType } from '@/api/types';
import { Button } from '@/components/ui/button';
import { sessionEvents } from '@/hooks/sessionEvents';
import { useSessions } from '@/hooks/useSessions';
export function Project() {
@@ -26,6 +28,7 @@ export function Project() {
setCreating(true);
try {
const s = await create({});
sessionEvents.emit({ type: 'session_created', session: s, project_id: id });
navigate(`/session/${s.id}`);
} finally {
setCreating(false);
@@ -73,7 +76,20 @@ export function Project() {
variant="ghost"
size="icon-sm"
aria-label="Delete session"
onClick={() => void remove(s.id)}
onClick={async () => {
try {
await remove(s.id);
sessionEvents.emit({
type: 'session_deleted',
session_id: s.id,
project_id: id!,
});
} catch (err) {
toast.error(
err instanceof Error ? err.message : 'failed to delete session'
);
}
}}
>
<Trash2 />
</Button>

View File

@@ -1,44 +1,55 @@
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 { MessageList } from '@/components/MessageList';
import { ChatInput } from '@/components/ChatInput';
import { sessionEvents } from '@/hooks/sessionEvents';
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(() => {
if (!id) return;
return sessionEvents.subscribe((event) => {
if (event.type !== 'session_renamed') return;
if (event.session_id !== id) return;
setSession((prev) => (prev ? { ...prev, name: event.name } : prev));
setName((prev) => (editingName ? prev : event.name));
});
}, [id, editingName]);
async function saveName() {
if (!id || !session) return;
const trimmed = name.trim();
@@ -49,16 +60,14 @@ export function Session() {
}
const updated = await api.sessions.update(id, { name: trimmed });
setSession(updated);
sessionEvents.emit({
type: 'session_renamed',
session_id: id,
name: trimmed,
});
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">
@@ -106,14 +115,11 @@ export function Session() {
/>
)}
</div>
{!stream.connected && (
<span className="text-xs text-muted-foreground">reconnecting</span>
)}
</header>
<MessageList messages={stream.messages} />
<ChatInput disabled={streaming} onSend={handleSend} />
{id && session && (
<Workspace sessionId={id} projectId={session.project_id} />
)}
</div>
);
}

997
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff