Compare commits

..

8 Commits

Author SHA1 Message Date
4a9f207fe8 v1.5.1: bootstrap fixes (git + ssh in container, Tailscale host rewrite, /opt/projects label)
- Dockerfile: install git + openssh-client in runtime image; pre-populate
  /root/.ssh/known_hosts with the Tailscale ssh-keyscan for
  100.114.205.53:2222 (Gitea SSH). Without these, the bootstrap push
  step from inside the container fails with "command not found" or
  host-key prompts.
- docker-compose.yml: mount ./secrets/boocode_gitea as
  /root/.ssh/id_ed25519:ro so the container can authenticate to Gitea
  over SSH for the initial push.
- .gitignore: add secrets/ so the keypair never lands in the repo.
- project_bootstrap.ts: rewrite the Gitea-returned ssh_url's hostname
  from git.indifferentketchup.com to 100.114.205.53 before adding it
  as origin, so the push hits the Tailscale interface that the
  known_hosts entry covers.
- CreateProjectModal.tsx: preview label now reads "Folder:
  /opt/projects/<name>" to match the new BOOTSTRAP_ROOT (was /opt/).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 05:11:39 +00:00
782c2b183d feat: persistent context-window tracker in ChatPane
Adds a floating popover above the chat input showing current
context-window usage. Modeled on Paseo's tracker.

- New hook useChatContextStats(chatId, messages) finds the latest
  assistant message in the chat with both ctx_used and ctx_max set,
  computes percent, and returns null when data unavailable.
- New component ChatContextPopover renders a small card with the
  "Context window" label, big percent, and "used / max tokens"
  subline. Hidden when stats is null.
- Color thresholds: <60% muted, 60-85 amber, >85 destructive.
- Not a portal — absolutely positioned inside a new relative
  wrapper around ChatInput in ChatPane.tsx, so it's pane-local
  (multi-pane safe).
- Live updates via the existing messages-array dependency.
- No API / schema / WS changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 04:36:08 +00:00
7f0fd1281b security: scope /opt mount to /opt/projects
Splits the previous /opt:/opt:rw bind into two mounts to narrow the
writable scope of the container:

- /opt:/opt:ro — read-only mount for legacy/existing project
  add-existing flow. resolveProjectPath still uses
  PROJECT_ROOT_WHITELIST (/opt by default) so existing projects under
  /opt/<name> (analytics, boolab, boocode itself) continue to resolve
  and serve their file-tree via the read-only tools.
- /opt/projects:/opt/projects:rw — writable mount targeted at the
  create-new-project bootstrap path.

Picked Option B from the spec (simpler than two scan roots):
PROJECT_ROOT_WHITELIST stays /opt, new BOOTSTRAP_ROOT env var defaults
to /opt/projects and is used by project_bootstrap.ts as the mkdir
target. Bootstrap path-escape check now compares against
BOOTSTRAP_ROOT.

Prereq: host must `mkdir -p /opt/projects` before next container
restart. Documented in CLAUDE.md and .env.example.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 04:35:59 +00:00
2f6be39efd chore: surface swallowed errors + remove dead session_renamed paths
Swallowed-error logging (audit Feature 3):
- file_index.ts:36-37 (git mtime probes): comment — best-effort, project
  may not be a git repo.
- useUserEvents.ts:44 / 53 (ws.close on error / unmount): comments —
  best-effort, socket may already be closing.
- RightRail.tsx:38 (localStorage write): comment — best-effort, quota or
  private mode.
- App.tsx:21 (api.sessions.get for RightRail projectId): replaced silent
  catch with console.warn.
- Session.tsx:38, 41 (session fetch + project list for breadcrumb):
  replaced silent catches with console.warn.

H1: ProjectSidebar.tsx:189 — dropped the local sessionEvents.emit
({type:'session_renamed'}) after PATCH. Server publishes via
broker.publishUser since v1.4; useUserEvents forwards.

H2: useSessionStream.ts session_renamed case removed (dead — no
server code path publishes session_renamed on the per-session WS
channel; only user channel via broker.publishUser). Also dropped the
session_renamed variant from WsFrame (in apps/web/src/api/types.ts)
to keep the discriminated-union switch exhaustive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 04:35:49 +00:00
1ecb79476e test: vitest harness + unit tests for security-critical pure functions
Adds vitest 3.x (pinned to ^3 because vitest 4 requires Vite 6, while the
web app pins Vite 5). Tests live under src/**/__tests__/**.

Three target functions:
- sanitizeFolderName (project_bootstrap.ts): 8 cases covering happy path,
  path-traversal stripping, empty-after-sanitize, control chars, truncation
  at 64, null bytes, leading/trailing dot/slash stripping.
- resolveProjectPath (projects.ts): 7 cases including symlink-escape via
  realpath, outside-whitelist rejection, nonexistent path, AND a flagged
  BEHAVIOR GAP: passing the whitelist path itself currently returns success
  rather than erroring out (function early-exits the scope check when
  real === whitelistReal). Test asserts current behavior with explicit
  comment flagging the spec violation — function NOT silently patched.
  Function made exportable for testing (single keyword change).
- buildMessagesPayload (inference.ts): 8 cases for compact-marker logic
  (no marker, marker present, multiple compacts, tool-message position).

tsconfig.json excludes __tests__ + *.test.ts from emit so dist/ stays clean.

pnpm -C apps/server test => 23 passed in ~340ms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 04:35:31 +00:00
9436a81b5f refactor: split FileBrowserPane / Workspace / runAssistantTurn
- FileBrowserPane.tsx: deleted (unreferenced post-v1.4 PaneTab.tsx removal;
  the legacy file_browser pane kind isn't part of the active WorkspacePane
  taxonomy).
- Workspace.tsx (524 -> 172 lines): extracted useWorkspacePanes(sessionId)
  and useSessionChats(sessionId) hooks. Workspace is layout-only composition
  now. localStorage key + WS frame handling + drag semantics unchanged.
- inference.ts runAssistantTurn (~265 -> 48 lines): bundled args into
  TurnArgs interface, extracted executeStreamPhase / executeToolPhase /
  finalizeCompletion / handleAbortOrError. All WS publish ordering preserved
  byte-for-byte (mentally traced for tool / non-tool / abort / error /
  depth-exceeded paths). flushPromise chain + setImmediate + signal
  propagation unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 04:35:20 +00:00
59fe6f0522 v1.4-fork-header: fork from message + delete message + header polish + housekeeping
- Fork: POST /api/chats/:id/fork creates a new chat in the same session,
  copies messages up to target (status=complete) with row-offset
  clock_timestamp() for stable ordering. Client emits open_chat_in_active_pane
  event; Workspace opens it in the active pane. No maybeAutoNameChat on forks.
- Delete: DELETE /api/chats/:id/messages/:message_id with 409 if the chat is
  currently streaming. Cascading-forward delete (created_at >= target).
  MessageBubble Trash button + confirm Dialog.
- Header: Projects -> Project -> Session breadcrumb, model badge pill,
  inline session rename, active file path via new useActivePane() hook.
  Server now publishes session_renamed on PATCH /api/sessions/:id;
  client-side dup emit removed from Session.tsx.
- Housekeeping: NOW() -> clock_timestamp() in schema.sql defaults, dead
  PaneTab.tsx and panes/PaneShell.tsx removed, session_panes backfill
  INSERT removed (CREATE TABLE retained), Tailnet trust comment near
  app.listen().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 04:12:01 +00:00
eabef7671e docs: CLAUDE.md updates from v1.3 audit — Fastify empty-body parser, event dedup discipline, CHECK migration order, deploy one-liner, stale pane refs cleaned
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 03:44:58 +00:00
45 changed files with 2046 additions and 1635 deletions

View File

@@ -3,5 +3,6 @@ PORT=3000
DATABASE_URL=postgres://boocode:CHANGE_ME@boocode_db:5432/boocode
LLAMA_SWAP_URL=http://100.101.41.16:8401
PROJECT_ROOT_WHITELIST=/opt
BOOTSTRAP_ROOT=/opt/projects
DEFAULT_MODEL=qwen3.6-35b-a3b-mxfp4
POSTGRES_PASSWORD=CHANGE_ME

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ dist
.DS_Store
.vite
coverage
secrets/

View File

@@ -62,8 +62,7 @@ Key patterns:
- **`hooks/sessionEvents.ts`** — Module-singleton event bus (Set of listeners). Used for cross-component communication: session renames, file-open events, attachment dispatch. 9 event types in the discriminated union. When adding a new event type to the `SessionEvent` union, you must also add a case to the `applyEvent` switch in `useSidebar.ts` (even if it's a no-op `return prev`).
- **`hooks/useSessionStream.ts`** — WebSocket per session, `applyFrame` reducer builds message list from streaming frames.
- **`hooks/useUserEvents.ts`** — Single app-level WS to `/api/ws/user` with exponential backoff reconnect. Forwards frames onto the sessionEvents bus.
- **`hooks/usePanes.ts`** — Per-session pane CRUD with 300ms debounced state PATCH (Map-based coalescing for last-write-wins).
- **`hooks/useSidebar.ts`** — Module-singleton with Set<setState> subscriber pattern. Handles all sessionEvent types to keep sidebar in sync.
- **`hooks/useSidebar.ts`** — Module-singleton with Set<setState> subscriber pattern; one bus subscription guarded by `globalThis.__boocode_sidebar_subscribed` for HMR safety. Every new `SessionEvent` type needs a `case` in the `applyEvent` switch (no-op `return prev` is fine).
- **`api/client.ts`** — Centralized typed fetch wrapper. All endpoints under `api.*` namespace.
### Data flow for chat
@@ -77,23 +76,27 @@ Key patterns:
### Multi-pane workspace
Sessions hold 15 panes (chat or file_browser). `Workspace.tsx` renders tab strip + CSS grid layout. Pane state persisted in `session_panes` table (position + JSONB state). Tab reorder via native HTML5 drag events.
Sessions hold 15 panes (chat / empty / placeholder terminal+agent). Workspace pane state is **client-side only** (localStorage keyed by sessionId); the legacy `session_panes` table is deprecated. Each chat lives in at most one pane; tab strip is per-pane and tracks `chatIds[]` + `activeChatIdx`. Sessions 1:N chats; chats own messages. Tab reorder via native HTML5 drag events.
## Database
PostgreSQL 16. Tables: `projects`, `sessions`, `messages`, `settings`, `session_panes`. Schema applied idempotently on startup via `applySchema()`. Use `clock_timestamp()` (not `NOW()`) inside transactions for accurate per-statement timestamps.
PostgreSQL 16. Tables: `projects`, `sessions`, `chats`, `messages`, `settings`, `session_panes` (deprecated). Schema applied idempotently on startup via `applySchema()`. Use `clock_timestamp()` (not `NOW()`) inside transactions. CHECK constraints in place: `projects_status_chk` ('open'|'archived'), `sessions_status_chk` (same), `chats_status_chk` (same), `messages_role_chk`, `messages_status_chk` — keep in sync with the `*_STATUSES` const arrays in `apps/server/src/types/api.ts`.
Position-shift pattern for panes: negate-and-restore to avoid UNIQUE(session_id, position) collisions during reorder/insert/delete. Sentinel value -100 for the moving pane.
Schema CHECK migration order when renaming allowed values: (1) `ALTER TABLE ... DROP CONSTRAINT IF EXISTS <system_name>` (inline `CREATE TABLE` checks get `<table>_<column>_check`), (2) `UPDATE` rows to new values, (3) wrap new constraint ADD in `DO $$ ... pg_constraint` guard — that block is the only way to get `ADD CONSTRAINT IF NOT EXISTS`.
Position-shift pattern for panes (legacy `session_panes` table): negate-and-restore to avoid UNIQUE(session_id, position) collisions during reorder/insert/delete. Sentinel value -100 for the moving pane.
## Environment
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt), `DEFAULT_MODEL`, `LOG_LEVEL`.
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`.
## Workflow
- Sam reviews all diffs and commits manually. Do not commit unless explicitly asked.
- Deploy: `cd /opt/boocode && docker compose build --no-cache boocode && docker compose up -d`
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
- Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client.
- Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present).
## Conventions

View File

@@ -19,7 +19,8 @@ RUN pnpm deploy --filter=@boocode/server --prod --legacy /out/server
FROM node:20-alpine AS runtime
RUN apk add --no-cache ripgrep
RUN apk add --no-cache ripgrep git openssh-client
RUN mkdir -p /root/.ssh && ssh-keyscan -p 2222 -H 100.114.205.53 git.indifferentketchup.com >> /root/.ssh/known_hosts && chmod 700 /root/.ssh && chmod 600 /root/.ssh/known_hosts
WORKDIR /app
COPY --from=builder /out/server ./

View File

@@ -7,7 +7,8 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@fastify/static": "^7.0.4",
@@ -21,6 +22,7 @@
"@types/node": "^20.14.10",
"@types/ws": "^8.5.10",
"tsx": "^4.16.2",
"typescript": "^5.5.0"
"typescript": "^5.5.0",
"vitest": "^3.2.4"
}
}

View File

@@ -7,6 +7,7 @@ const ConfigSchema = z.object({
DATABASE_URL: z.string().url(),
LLAMA_SWAP_URL: z.string().url(),
PROJECT_ROOT_WHITELIST: z.string().default('/opt'),
BOOTSTRAP_ROOT: z.string().default('/opt/projects'),
DEFAULT_MODEL: z.string().default('qwen3.6-35b-a3b-mxfp4'),
LOG_LEVEL: z.string().default('info'),
GITEA_BASE_URL: z.string().url().default('https://git.indifferentketchup.com'),

View File

@@ -83,6 +83,7 @@ async function main() {
cancelInference: async (sessionId, chatId) => {
return inference.cancel(sessionId, chatId);
},
hasActiveInference: (chatId) => inference.hasActive(chatId),
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
broker.publish(sessionId, {
type: 'message_started',
@@ -144,6 +145,9 @@ async function main() {
process.on('SIGINT', () => void shutdown('SIGINT'));
process.on('SIGTERM', () => void shutdown('SIGTERM'));
// Bound to 0.0.0.0 intentionally. Public access goes through Caddy → Authelia.
// Direct Tailscale access (100.114.205.53:9500) is unauthenticated by design;
// the threat model treats Tailnet membership as the trust boundary.
await app.listen({ port: config.PORT, host: config.HOST });
app.log.info(`boocode server listening on http://${config.HOST}:${config.PORT}`);
}

View File

@@ -0,0 +1,100 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtemp, mkdir, rm, realpath, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { resolveProjectPath } from '../projects.js';
describe('resolveProjectPath', () => {
let scratch: string;
let whitelist: string;
let outside: string;
beforeAll(async () => {
// mkdtemp returns a real path on most platforms, but symlink-resolve it
// anyway to defeat /tmp -> /private/tmp style indirection on macOS, and
// keep this stable for the comparisons below.
scratch = await realpath(await mkdtemp(join(tmpdir(), 'boocode-projects-test-')));
whitelist = join(scratch, 'wl');
outside = join(scratch, 'other');
await mkdir(whitelist, { recursive: true });
await mkdir(outside, { recursive: true });
});
afterAll(async () => {
if (scratch) {
await rm(scratch, { recursive: true, force: true });
}
});
it('returns real path and basename for a valid subdirectory under the whitelist', async () => {
const projectDir = join(whitelist, 'my-project');
await mkdir(projectDir);
const result = await resolveProjectPath(projectDir, whitelist);
expect('error' in result).toBe(false);
if ('error' in result) return; // narrow
expect(result.real).toBe(projectDir);
expect(result.name).toBe('my-project');
});
it('rejects a path outside the whitelist', async () => {
const projectDir = join(outside, 'foo');
await mkdir(projectDir);
const result = await resolveProjectPath(projectDir, whitelist);
expect('error' in result).toBe(true);
if (!('error' in result)) return;
expect(result.error.toLowerCase()).toContain('path must be under');
});
it('rejects relative paths', async () => {
const result = await resolveProjectPath('relative/path', whitelist);
expect('error' in result).toBe(true);
if (!('error' in result)) return;
expect(result.error.toLowerCase()).toContain('absolute');
});
it('rejects nonexistent paths', async () => {
const result = await resolveProjectPath('/nonexistent/foo-boocode-test', whitelist);
expect('error' in result).toBe(true);
if (!('error' in result)) return;
expect(result.error.toLowerCase()).toContain('does not exist');
});
it('rejects symlink escapes (realpath resolution catches traversal)', async () => {
// Create a symlink INSIDE the whitelist that points OUTSIDE. realpath
// should resolve through it and the resulting real path should fail the
// whitelist scope check.
const escapeLink = join(whitelist, 'escape-link');
await symlink(outside, escapeLink);
const result = await resolveProjectPath(escapeLink, whitelist);
expect('error' in result).toBe(true);
if (!('error' in result)) return;
expect(result.error.toLowerCase()).toContain('path must be under');
});
it('BEHAVIOR GAP: currently accepts the whitelist itself as a project root', async () => {
// SPEC says: the whitelist directory itself should be rejected — a
// project's parent can't be the project. The current implementation does
// NOT enforce this: the scope check is
// if (real !== whitelistReal && !real.startsWith(whitelistReal + sep))
// which evaluates to false when real === whitelistReal, so the whitelist
// path falls through and is accepted as a valid project root.
//
// This test documents the ACTUAL current behavior. Reported as a bug in
// the harness report; not silently fixed here. To tighten the check,
// change the condition to:
// if (!real.startsWith(whitelistReal + sep))
const result = await resolveProjectPath(whitelist, whitelist);
expect('error' in result).toBe(false);
if ('error' in result) return;
expect(result.real).toBe(whitelist);
});
it('rejects non-directory targets (file under whitelist)', async () => {
const filePath = join(whitelist, 'a-file.txt');
await writeFile(filePath, 'content', 'utf8');
const result = await resolveProjectPath(filePath, whitelist);
expect('error' in result).toBe(true);
if (!('error' in result)) return;
expect(result.error.toLowerCase()).toContain('not a directory');
});
});

View File

@@ -12,6 +12,11 @@ const PatchBody = z.object({
name: z.string().min(1).max(200),
});
const ForkBody = z.object({
message_id: z.string().uuid(),
name: z.string().min(1).max(200).optional(),
});
export function registerChatRoutes(
app: FastifyInstance,
sql: Sql,
@@ -181,6 +186,78 @@ export function registerChatRoutes(
}
);
app.post<{ Params: { id: string } }>(
'/api/chats/:id/fork',
async (req, reply) => {
const parsed = ForkBody.safeParse(req.body ?? {});
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const sourceRows = await sql<Chat[]>`
SELECT id, session_id, name, status, created_at, updated_at
FROM chats WHERE id = ${req.params.id}
`;
if (sourceRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const source = sourceRows[0]!;
const targetRows = await sql<{ created_at: string; status: string }[]>`
SELECT created_at, status FROM messages
WHERE chat_id = ${source.id} AND id = ${parsed.data.message_id}
`;
if (targetRows.length === 0) {
reply.code(404);
return { error: 'message not found in chat' };
}
const target = targetRows[0]!;
if (target.status !== 'complete') {
reply.code(400);
return { error: 'can only fork from completed messages' };
}
const newName = parsed.data.name ?? `${source.name ?? 'Chat'} (fork)`;
const newChat = await sql.begin(async (tx) => {
const [chat] = await tx<Chat[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${source.session_id}, ${newName}, 'open')
RETURNING id, session_id, name, status, created_at, updated_at
`;
await tx`
INSERT INTO messages (
session_id, chat_id, role, content, kind, tool_calls, tool_results,
status, tokens_used, ctx_used, ctx_max, started_at, finished_at,
created_at
)
SELECT
${source.session_id}, ${chat!.id}, role, content, kind,
tool_calls, tool_results, status,
tokens_used, ctx_used, ctx_max, started_at, finished_at,
clock_timestamp() + (
ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) * INTERVAL '1 microsecond'
)
FROM messages
WHERE chat_id = ${source.id}
AND created_at <= ${target.created_at}::timestamptz
AND status = 'complete'
`;
return chat!;
});
broker.publishUser('default', {
type: 'chat_created',
chat: newChat,
session_id: source.session_id,
});
reply.code(201);
return newChat;
}
);
app.get<{ Params: { id: string } }>(
'/api/chats/:id/messages',
async (req, reply) => {

View File

@@ -18,6 +18,7 @@ interface MessageHandlers {
) => void;
publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void;
cancelInference: (sessionId: string, chatId: string) => Promise<boolean>;
hasActiveInference: (chatId: string) => boolean;
}
export function registerMessageRoutes(
@@ -156,6 +157,53 @@ export function registerMessageRoutes(
}
);
app.delete<{ Params: { id: string; message_id: string } }>(
'/api/chats/:id/messages/:message_id',
async (req, reply) => {
const { id: chatId, message_id: messageId } = req.params;
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${chatId}
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const chat = chatRows[0]!;
if (handlers.hasActiveInference(chatId)) {
reply.code(409);
return { error: 'chat is currently streaming; stop it first' };
}
const deletedIds = await sql.begin(async (tx) => {
const deletedRows = await tx<{ id: string }[]>`
DELETE FROM messages
WHERE chat_id = ${chatId}
AND created_at >= (
SELECT created_at FROM messages
WHERE id = ${messageId} AND chat_id = ${chatId}
)
RETURNING id
`;
if (deletedRows.length > 0) {
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
}
return deletedRows.map((r) => r.id);
});
if (deletedIds.length === 0) {
reply.code(404);
return { error: 'message not found' };
}
handlers.publishMessagesDeleted(chat.session_id, chatId, deletedIds);
reply.code(204);
return null;
}
);
app.post<{ Params: { id: string } }>(
'/api/chats/:id/compact',
async (req, reply) => {

View File

@@ -41,7 +41,7 @@ async function isDir(path: string): Promise<boolean> {
}
}
async function resolveProjectPath(
export async function resolveProjectPath(
raw: string,
whitelist: string
): Promise<{ real: string; name: string } | { error: string }> {

View File

@@ -134,7 +134,15 @@ export function registerSessionRoutes(
reply.code(404);
return { error: 'session not found' };
}
return rows[0];
const session = rows[0]!;
if (name !== undefined) {
broker.publishUser('default', {
type: 'session_renamed',
session_id: session.id,
name: session.name,
});
}
return session;
}
);

View File

@@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
added_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
last_session_id UUID
);
@@ -12,8 +12,8 @@ CREATE TABLE IF NOT EXISTS sessions (
name TEXT NOT NULL,
model TEXT NOT NULL,
system_prompt TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id, updated_at DESC);
@@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS messages (
tool_results JSONB,
status TEXT NOT NULL DEFAULT 'complete',
last_seq INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
@@ -60,14 +60,9 @@ CREATE TABLE IF NOT EXISTS session_panes (
);
CREATE INDEX IF NOT EXISTS idx_session_panes_session ON session_panes (session_id);
-- 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
);
-- v1.4: backfill removed. Pane layout is client-side (localStorage) since v1.2-batch4.
-- The CREATE TABLE above is retained for additive-schema discipline; drop is a
-- future destructive migration.
-- v1.2: sessions.status (open | archived)
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';

View File

@@ -0,0 +1,237 @@
import { describe, it, expect } from 'vitest';
import { buildMessagesPayload } from '../inference.js';
import type {
Message,
MessageRole,
Project,
Session,
ToolCall,
ToolResult,
} from '../../types/api.js';
// ---- fixtures ---------------------------------------------------------------
function makeSession(overrides: Partial<Session> = {}): Session {
return {
id: 'sess',
project_id: 'proj',
name: 'test session',
model: 'test-model',
system_prompt: '',
status: 'open',
created_at: new Date(0).toISOString(),
updated_at: new Date(0).toISOString(),
...overrides,
};
}
function makeProject(overrides: Partial<Project> = {}): Project {
return {
id: 'proj',
name: 'test project',
path: '/tmp/proj',
added_at: new Date(0).toISOString(),
last_session_id: null,
status: 'open',
gitea_remote: null,
...overrides,
};
}
let counter = 0;
function makeMessage(
role: MessageRole,
content: string,
overrides: Partial<Message> = {}
): Message {
counter += 1;
return {
id: `m${counter}`,
session_id: 'sess',
chat_id: 'chat',
role,
content,
kind: 'message',
tool_calls: null,
tool_results: null,
status: 'complete',
last_seq: 0,
tokens_used: null,
ctx_used: null,
ctx_max: null,
started_at: null,
finished_at: null,
created_at: new Date(counter * 1000).toISOString(),
...overrides,
};
}
// ---- tests ------------------------------------------------------------------
describe('buildMessagesPayload', () => {
it('prepends a system prompt containing the project path', () => {
const session = makeSession();
const project = makeProject({ path: '/tmp/my-proj' });
const result = buildMessagesPayload(session, project, []);
expect(result).toHaveLength(1);
expect(result[0]!.role).toBe('system');
expect(result[0]!.content).toContain('/tmp/my-proj');
});
it('appends session.system_prompt to the system message when set', () => {
const session = makeSession({ system_prompt: 'Be terse.' });
const project = makeProject();
const result = buildMessagesPayload(session, project, []);
expect(result).toHaveLength(1);
expect(result[0]!.role).toBe('system');
expect(result[0]!.content).toContain('Be terse.');
});
it('returns user/assistant messages in order when no compact marker is present', () => {
const session = makeSession();
const project = makeProject();
const history: Message[] = [
makeMessage('user', 'hi'),
makeMessage('assistant', 'hello'),
makeMessage('user', 'how are you'),
makeMessage('assistant', 'great'),
];
const result = buildMessagesPayload(session, project, history);
// 1 system + 4 history messages
expect(result).toHaveLength(5);
expect(result[0]!.role).toBe('system');
expect(result[1]).toMatchObject({ role: 'user', content: 'hi' });
expect(result[2]).toMatchObject({ role: 'assistant', content: 'hello' });
expect(result[3]).toMatchObject({ role: 'user', content: 'how are you' });
expect(result[4]).toMatchObject({ role: 'assistant', content: 'great' });
});
it('starts from the latest compact marker, emitting it as a system message', () => {
const session = makeSession();
const project = makeProject();
const history: Message[] = [
makeMessage('user', 'old1'),
makeMessage('assistant', 'oldreply1'),
makeMessage('user', 'old2'),
makeMessage('assistant', 'compacted summary text', { kind: 'compact' }),
makeMessage('user', 'new1'),
makeMessage('assistant', 'newreply1'),
];
const result = buildMessagesPayload(session, project, history);
// Expect: leading base-system prompt, then the compact as system, then
// the user/assistant pair following it.
expect(result).toHaveLength(4);
expect(result[0]!.role).toBe('system');
expect(result[1]).toMatchObject({
role: 'system',
content: 'compacted summary text',
});
expect(result[2]).toMatchObject({ role: 'user', content: 'new1' });
expect(result[3]).toMatchObject({ role: 'assistant', content: 'newreply1' });
});
it('uses only the most recent compact when multiple are present', () => {
const session = makeSession();
const project = makeProject();
const history: Message[] = [
makeMessage('user', 'u1'),
makeMessage('assistant', 'first compact summary', { kind: 'compact' }),
makeMessage('user', 'u2'),
makeMessage('assistant', 'second compact summary', { kind: 'compact' }),
makeMessage('user', 'u3'),
makeMessage('assistant', 'final reply'),
];
const result = buildMessagesPayload(session, project, history);
// Expect: base system + latest compact as system + the two messages
// following it. The earlier compact and pre-compact history are dropped.
expect(result).toHaveLength(4);
expect(result[0]!.role).toBe('system');
expect(result[1]).toMatchObject({
role: 'system',
content: 'second compact summary',
});
expect(result[2]).toMatchObject({ role: 'user', content: 'u3' });
expect(result[3]).toMatchObject({ role: 'assistant', content: 'final reply' });
// None of the earlier content should leak through
const concatenated = result.map((m) => m.content ?? '').join(' ');
expect(concatenated).not.toContain('first compact summary');
expect(concatenated).not.toContain('u1');
expect(concatenated).not.toContain('u2');
});
it('skips streaming and cancelled assistant rows', () => {
const session = makeSession();
const project = makeProject();
const history: Message[] = [
makeMessage('user', 'hi'),
makeMessage('assistant', 'partial...', { status: 'streaming' }),
makeMessage('assistant', 'cancelled fragment', { status: 'cancelled' }),
makeMessage('assistant', 'final answer'),
];
const result = buildMessagesPayload(session, project, history);
// 1 system + 1 user + 1 assistant (only the complete one)
expect(result).toHaveLength(3);
expect(result[1]).toMatchObject({ role: 'user', content: 'hi' });
expect(result[2]).toMatchObject({ role: 'assistant', content: 'final answer' });
});
it('round-trips an assistant-with-tool_calls followed by its tool result', () => {
const session = makeSession();
const project = makeProject();
const toolCall: ToolCall = {
id: 'call_abc',
name: 'view_file',
args: { path: 'src/index.ts' },
};
const toolResult: ToolResult = {
tool_call_id: 'call_abc',
output: { contents: 'console.log(1)' },
truncated: false,
};
const history: Message[] = [
makeMessage('user', 'show me the file'),
makeMessage('assistant', '', { tool_calls: [toolCall] }),
makeMessage('tool', '', { tool_results: toolResult }),
makeMessage('assistant', 'here it is'),
];
const result = buildMessagesPayload(session, project, history);
// 1 system + 1 user + 1 assistant(tool_calls) + 1 tool + 1 assistant
expect(result).toHaveLength(5);
expect(result[1]).toMatchObject({ role: 'user', content: 'show me the file' });
expect(result[2]!.role).toBe('assistant');
expect(result[2]!.tool_calls).toBeDefined();
expect(result[2]!.tool_calls).toHaveLength(1);
expect(result[2]!.tool_calls![0]).toMatchObject({
id: 'call_abc',
type: 'function',
function: { name: 'view_file' },
});
// The OpenAI shape stringifies args.
expect(result[2]!.tool_calls![0]!.function.arguments).toBe(
JSON.stringify({ path: 'src/index.ts' })
);
// assistant with empty content should be serialized as content: null
expect(result[2]!.content).toBeNull();
expect(result[3]).toMatchObject({
role: 'tool',
tool_call_id: 'call_abc',
});
// Non-string tool output is JSON-stringified.
expect(result[3]!.content).toBe(JSON.stringify({ contents: 'console.log(1)' }));
expect(result[4]).toMatchObject({ role: 'assistant', content: 'here it is' });
});
it('skips tool rows with no tool_results', () => {
const session = makeSession();
const project = makeProject();
const history: Message[] = [
makeMessage('user', 'do it'),
makeMessage('tool', '', { tool_results: null }),
makeMessage('assistant', 'done'),
];
const result = buildMessagesPayload(session, project, history);
// 1 system + 1 user + 1 assistant; the empty tool row is dropped.
expect(result).toHaveLength(3);
expect(result.find((m) => m.role === 'tool')).toBeUndefined();
});
});

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest';
import { sanitizeFolderName } from '../project_bootstrap.js';
describe('sanitizeFolderName', () => {
it('passes through a normal slug-like name', () => {
expect(sanitizeFolderName('my-project')).toBe('my-project');
});
it('lowercases and replaces whitespace with hyphens', () => {
expect(sanitizeFolderName('Hello World')).toBe('hello-world');
});
it('strips path-traversal characters', () => {
// dots and slashes fall outside [a-z0-9-] and are removed entirely.
expect(sanitizeFolderName('../etc/passwd')).toBe('etcpasswd');
});
it('strips trailing and leading dots and slashes', () => {
expect(sanitizeFolderName('./foo/')).toBe('foo');
});
it('collapses runs of hyphens and strips leading/trailing ones', () => {
expect(sanitizeFolderName('---foo---')).toBe('foo');
});
it('returns empty string when nothing survives sanitization', () => {
// NOTE: sanitizeFolderName itself does NOT throw — it returns ''. The
// BootstrapNameError is raised by the caller (bootstrapProject) when the
// sanitized result fails the SAFE_NAME regex. The spec's "throws" phrasing
// refers to that caller-level validation, not this pure function.
expect(sanitizeFolderName('...')).toBe('');
expect(sanitizeFolderName(' ')).toBe('');
});
it('strips control characters and null bytes', () => {
// Null bytes and control characters are not in [a-z0-9-] so they're
// filtered out (effectively rejected as folder-name content).
expect(sanitizeFolderName('my\x00proj\x01')).toBe('myproj');
expect(sanitizeFolderName('foo\x00bar')).toBe('foobar');
});
it('truncates names longer than 64 characters', () => {
const long = 'a'.repeat(100);
expect(sanitizeFolderName(long)).toBe('a'.repeat(64));
expect(sanitizeFolderName(long)).toHaveLength(64);
});
});

View File

@@ -33,6 +33,7 @@ async function snapMtimes(root: string): Promise<MtimeSnap> {
const rootStat = await fs.stat(root);
let gitHead: number | null = null;
let gitIndex: number | null = null;
// best-effort; ignore failure because the project may not be a git repo
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 };

View File

@@ -348,39 +348,27 @@ async function executeToolCall(
}
}
async function runAssistantTurn(
ctx: InferenceContext,
sessionId: string,
chatId: string,
assistantMessageId: string,
depth: number,
signal?: AbortSignal
): Promise<void> {
if (depth > MAX_TOOL_LOOP_DEPTH) {
await ctx.sql`
UPDATE messages
SET status = 'failed',
content = ${'tool loop depth exceeded'},
finished_at = clock_timestamp()
WHERE id = ${assistantMessageId}
`;
ctx.publish(sessionId, {
type: 'error',
message_id: assistantMessageId,
chat_id: chatId,
error: 'tool loop depth exceeded',
});
return;
}
interface TurnArgs {
sessionId: string;
chatId: string;
assistantMessageId: string;
depth: number;
signal: AbortSignal | undefined;
}
const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (!loaded) {
ctx.log.warn({ sessionId }, 'inference: session or project missing');
return;
}
const { session, project, history } = loaded;
const projectRoot = await resolveProjectRoot(project.path);
const messages = buildMessagesPayload(session, project, history);
interface StreamPhaseState {
accumulated: string;
startedAt: string | null;
}
async function executeStreamPhase(
ctx: InferenceContext,
args: TurnArgs,
session: Session,
messages: OpenAiMessage[],
state: StreamPhaseState
): Promise<StreamResult> {
const { sessionId, chatId, assistantMessageId, signal } = args;
const startedRow = await ctx.sql<{ started_at: string }[]>`
UPDATE messages
@@ -388,7 +376,7 @@ async function runAssistantTurn(
WHERE id = ${assistantMessageId}
RETURNING started_at
`;
const startedAt = startedRow[0]?.started_at ?? null;
state.startedAt = startedRow[0]?.started_at ?? null;
ctx.publish(sessionId, {
type: 'message_started',
@@ -397,7 +385,6 @@ async function runAssistantTurn(
role: 'assistant',
});
let accumulated = '';
let pendingFlushTimer: NodeJS.Timeout | null = null;
let flushPromise: Promise<unknown> = Promise.resolve();
@@ -406,7 +393,7 @@ async function runAssistantTurn(
clearTimeout(pendingFlushTimer);
pendingFlushTimer = null;
}
const snapshot = accumulated;
const snapshot = state.accumulated;
flushPromise = flushPromise.then(() =>
ctx.sql`UPDATE messages SET content = ${snapshot} WHERE id = ${assistantMessageId}`
);
@@ -420,15 +407,14 @@ async function runAssistantTurn(
}, DB_FLUSH_INTERVAL_MS);
};
let result: StreamResult;
try {
result = await streamCompletion(
return await streamCompletion(
ctx,
session.model,
messages,
true,
(delta) => {
accumulated += delta;
state.accumulated += delta;
ctx.publish(sessionId, {
type: 'delta',
message_id: assistantMessageId,
@@ -440,136 +426,162 @@ async function runAssistantTurn(
},
signal
);
} catch (err) {
} finally {
if (pendingFlushTimer) {
clearTimeout(pendingFlushTimer);
pendingFlushTimer = null;
}
await flushPromise;
const isAbort = err instanceof Error && err.name === 'AbortError';
const finalStatus = isAbort ? 'cancelled' : 'failed';
await ctx.sql`
UPDATE messages
SET status = ${finalStatus},
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 });
if (isAbort) {
ctx.publish(sessionId, {
type: 'message_complete',
message_id: assistantMessageId,
chat_id: chatId,
});
ctx.log.info({ sessionId, chatId, assistantMessageId }, 'inference cancelled');
} else {
const errMsg = err instanceof Error ? err.message : String(err);
ctx.publish(sessionId, {
type: 'error',
message_id: assistantMessageId,
chat_id: chatId,
error: errMsg,
});
ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed');
}
return;
}
}
if (pendingFlushTimer) {
clearTimeout(pendingFlushTimer);
pendingFlushTimer = null;
}
await flushPromise;
const { content, finishReason, toolCalls, promptTokens, completionTokens, nCtx } = result;
if (toolCalls.length > 0) {
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)},
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',
message_id: assistantMessageId,
chat_id: chatId,
tool_call: tc,
});
}
async function handleAbortOrError(
ctx: InferenceContext,
args: TurnArgs,
accumulated: string,
err: unknown
): Promise<void> {
const { sessionId, chatId, assistantMessageId } = args;
const isAbort = err instanceof Error && err.name === 'AbortError';
const finalStatus = isAbort ? 'cancelled' : 'failed';
await ctx.sql`
UPDATE messages
SET status = ${finalStatus},
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 });
if (isAbort) {
ctx.publish(sessionId, {
type: 'message_complete',
message_id: assistantMessageId,
chat_id: chatId,
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(
toolCalls.map(async (tc) => {
const [toolRow] = await ctx.sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'tool', '', 'complete', clock_timestamp())
RETURNING id
`;
const toolMessageId = toolRow!.id;
const tres = await executeToolCall(projectRoot, tc);
const stored = {
tool_call_id: tc.id,
output: tres.output,
truncated: tres.truncated,
...(tres.error ? { error: tres.error } : {}),
};
await ctx.sql`
UPDATE messages
SET tool_results = ${ctx.sql.json(stored as never)}
WHERE id = ${toolMessageId}
`;
ctx.publish(sessionId, {
type: 'tool_result',
tool_message_id: toolMessageId,
chat_id: chatId,
tool_call_id: tc.id,
output: tres.output,
truncated: tres.truncated,
...(tres.error ? { error: tres.error } : {}),
});
})
);
const [nextAssistant] = await ctx.sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await runAssistantTurn(ctx, sessionId, chatId, nextAssistant!.id, depth + 1, signal);
return;
ctx.log.info({ sessionId, chatId, assistantMessageId }, 'inference cancelled');
} else {
const errMsg = err instanceof Error ? err.message : String(err);
ctx.publish(sessionId, {
type: 'error',
message_id: assistantMessageId,
chat_id: chatId,
error: errMsg,
});
ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed');
}
}
async function executeToolPhase(
ctx: InferenceContext,
args: TurnArgs,
result: StreamResult,
startedAt: string | null,
session: Session,
projectRoot: string
): Promise<void> {
const { sessionId, chatId, assistantMessageId, depth, signal } = args;
const { content, toolCalls, promptTokens, completionTokens, nCtx } = result;
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)},
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',
message_id: assistantMessageId,
chat_id: chatId,
tool_call: tc,
});
}
ctx.publish(sessionId, {
type: 'message_complete',
message_id: assistantMessageId,
chat_id: chatId,
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(
toolCalls.map(async (tc) => {
const [toolRow] = await ctx.sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'tool', '', 'complete', clock_timestamp())
RETURNING id
`;
const toolMessageId = toolRow!.id;
const tres = await executeToolCall(projectRoot, tc);
const stored = {
tool_call_id: tc.id,
output: tres.output,
truncated: tres.truncated,
...(tres.error ? { error: tres.error } : {}),
};
await ctx.sql`
UPDATE messages
SET tool_results = ${ctx.sql.json(stored as never)}
WHERE id = ${toolMessageId}
`;
ctx.publish(sessionId, {
type: 'tool_result',
tool_message_id: toolMessageId,
chat_id: chatId,
tool_call_id: tc.id,
output: tres.output,
truncated: tres.truncated,
...(tres.error ? { error: tres.error } : {}),
});
})
);
const [nextAssistant] = await ctx.sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await runAssistantTurn(ctx, {
sessionId,
chatId,
assistantMessageId: nextAssistant!.id,
depth: depth + 1,
signal,
});
}
async function finalizeCompletion(
ctx: InferenceContext,
args: TurnArgs,
result: StreamResult,
startedAt: string | null,
session: Session
): Promise<void> {
const { sessionId, chatId, assistantMessageId } = args;
const { content, finishReason, promptTokens, completionTokens, nCtx } = result;
const [updated] = await ctx.sql<
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
@@ -615,6 +627,55 @@ async function runAssistantTurn(
);
}
async function runAssistantTurn(
ctx: InferenceContext,
args: TurnArgs,
): Promise<void> {
const { sessionId, chatId, assistantMessageId, depth } = args;
if (depth > MAX_TOOL_LOOP_DEPTH) {
await ctx.sql`
UPDATE messages
SET status = 'failed',
content = ${'tool loop depth exceeded'},
finished_at = clock_timestamp()
WHERE id = ${assistantMessageId}
`;
ctx.publish(sessionId, {
type: 'error',
message_id: assistantMessageId,
chat_id: chatId,
error: 'tool loop depth exceeded',
});
return;
}
const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (!loaded) {
ctx.log.warn({ sessionId }, 'inference: session or project missing');
return;
}
const { session, project, history } = loaded;
const projectRoot = await resolveProjectRoot(project.path);
const messages = buildMessagesPayload(session, project, history);
const state: StreamPhaseState = { accumulated: '', startedAt: null };
let result: StreamResult;
try {
result = await executeStreamPhase(ctx, args, session, messages, state);
} catch (err) {
await handleAbortOrError(ctx, args, state.accumulated, err);
return;
}
if (result.toolCalls.length > 0) {
await executeToolPhase(ctx, args, result, state.startedAt, session, projectRoot);
return;
}
await finalizeCompletion(ctx, args, result, state.startedAt, session);
}
export async function runInference(
ctx: InferenceContext,
sessionId: string,
@@ -622,7 +683,7 @@ export async function runInference(
assistantMessageId: string,
signal?: AbortSignal
): Promise<void> {
return runAssistantTurn(ctx, sessionId, chatId, assistantMessageId, 0, signal);
return runAssistantTurn(ctx, { sessionId, chatId, assistantMessageId, depth: 0, signal });
}
const COMPACT_SYSTEM_PROMPT =
@@ -764,6 +825,10 @@ export function createInferenceRunner(
await reg.completed.catch(() => {});
return true;
},
hasActive(chatId: string): boolean {
return registry.has(chatId);
},
};
}

View File

@@ -82,11 +82,13 @@ export async function bootstrapProject(
throw new BootstrapNameError(`invalid name after sanitization: "${folder}"`);
}
// Whitelist resolution
const whitelistReal = await realpath(config.PROJECT_ROOT_WHITELIST);
const fullPath = resolve(whitelistReal, folder);
if (!fullPath.startsWith(whitelistReal + sep)) {
throw new BootstrapPathError('path escapes whitelist');
// Bootstrap target resolution. Uses BOOTSTRAP_ROOT (writable), not
// PROJECT_ROOT_WHITELIST (which may be a wider read-only scope for
// add-existing flow).
const bootstrapReal = await realpath(config.BOOTSTRAP_ROOT);
const fullPath = resolve(bootstrapReal, folder);
if (!fullPath.startsWith(bootstrapReal + sep)) {
throw new BootstrapPathError('path escapes bootstrap root');
}
if (existsSync(fullPath)) {
throw new BootstrapCollisionError(`path already exists: ${fullPath}`);
@@ -144,7 +146,8 @@ export async function bootstrapProject(
// Step 6: git remote add + push
try {
await execFileAsync('git', ['remote', 'add', 'origin', repo.ssh_url], { cwd: fullPath });
const sshUrl = repo.ssh_url.replace('git.indifferentketchup.com', '100.114.205.53');
await execFileAsync('git', ['remote', 'add', 'origin', sshUrl], { cwd: fullPath });
await execFileAsync('git', ['push', '-u', 'origin', 'main'], { cwd: fullPath });
gitea_pushed = true;
log.info({ folder }, 'project_bootstrap: pushed to gitea');

View File

@@ -176,6 +176,11 @@ export interface SessionUpdatedFrame {
name: string;
updated_at: string;
}
export interface SessionRenamedFrame {
type: 'session_renamed';
session_id: string;
name: string;
}
export interface SessionArchivedFrame {
type: 'session_archived';
session_id: string;
@@ -226,6 +231,7 @@ export type UserStreamFrame =
| SessionCreatedFrame
| SessionDeletedFrame
| SessionUpdatedFrame
| SessionRenamedFrame
| SessionArchivedFrame
| ChatCreatedFrame
| ChatUpdatedFrame

View File

@@ -10,5 +10,6 @@
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*"]
"include": ["src/**/*"],
"exclude": ["src/**/__tests__/**", "**/*.test.ts"]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
globals: false,
include: ['src/**/__tests__/**/*.test.ts'],
},
});

View File

@@ -18,7 +18,10 @@ function SessionRightRail() {
function RightRailForSession({ sessionId }: { sessionId: string }) {
const [projectId, setProjectId] = useState<string | null>(null);
useEffect(() => {
api.sessions.get(sessionId).then((s) => setProjectId(s.project_id)).catch(() => {});
api.sessions
.get(sessionId)
.then((s) => setProjectId(s.project_id))
.catch((err) => console.warn('RightRail: failed to fetch session', err));
}, [sessionId]);
if (!projectId) return null;
return <RightRail projectId={projectId} />;

View File

@@ -148,6 +148,11 @@ export const api = {
`/api/chats/${chatId}/force_send`,
{ method: 'POST', body: JSON.stringify({ content }) }
),
fork: (chatId: string, body: { messageId: string; name?: string }) =>
request<Chat>(`/api/chats/${chatId}/fork`, {
method: 'POST',
body: JSON.stringify({ message_id: body.messageId, name: body.name }),
}),
},
messages: {
@@ -166,6 +171,10 @@ export const api = {
`/api/chats/${chatId}/messages/${messageId}/regenerate`,
{ method: 'POST' }
),
remove: (chatId: string, messageId: string) =>
request<void>(`/api/chats/${chatId}/messages/${messageId}`, {
method: 'DELETE',
}),
},
models: () => request<ModelInfo[]>('/api/models'),

View File

@@ -191,6 +191,5 @@ export type WsFrame =
finished_at?: string | null;
}
| { type: 'messages_deleted'; message_ids: string[]; chat_id?: string }
| { type: 'session_renamed'; session_id: string; name: string; chat_id?: string }
| { type: 'chat_renamed'; chat_id: string; name: string }
| { type: 'error'; message_id?: string; chat_id?: string; error: string };

View File

@@ -0,0 +1,55 @@
import type { ChatContextStats } from '@/hooks/useChatContextStats';
interface Props {
stats: ChatContextStats | null;
}
/**
* Formats a token count into a compact k/m-suffix string.
* - < 1_000 → raw integer (e.g. "42")
* - 1_000999_999 → "Nk" or "N.Nk" (e.g. "30k", "12.5k", "100k")
* - >= 1_000_000 → "Nm" or "N.Nm" (e.g. "1m", "1.5m", "100m")
*
* Drops a trailing ".0" so we get "30k" instead of "30.0k".
*/
function formatTokens(n: number): string {
if (n < 1000) return String(n);
if (n < 1_000_000) {
const k = n / 1000;
return k >= 100 ? `${Math.round(k)}k` : `${k.toFixed(1).replace(/\.0$/, '')}k`;
}
const m = n / 1_000_000;
return m >= 100 ? `${Math.round(m)}m` : `${m.toFixed(1).replace(/\.0$/, '')}m`;
}
/**
* Color thresholds:
* - > 85% → text-destructive
* - >= 60% → text-amber-500
* - else → text-muted-foreground
* (85% itself falls into the amber band.)
*/
function percentColorClass(percent: number): string {
if (percent > 85) return 'text-destructive';
if (percent >= 60) return 'text-amber-500';
return 'text-muted-foreground';
}
export function ChatContextPopover({ stats }: Props) {
if (!stats) return null;
return (
<div className="absolute bottom-full right-4 mb-4 z-20 pointer-events-none">
<div className="rounded-md border border-border bg-card text-card-foreground shadow-sm px-3 py-2 text-xs min-w-[140px]">
<div className="text-muted-foreground/80 text-[10px] uppercase tracking-wide mb-0.5">
Context window
</div>
<div className={`text-base font-medium ${percentColorClass(stats.percent)}`}>
{stats.percent}% used
</div>
<div className="text-muted-foreground text-[10px] font-mono">
{formatTokens(stats.used)} / {formatTokens(stats.max)} tokens
</div>
</div>
</div>
);
}

View File

@@ -103,7 +103,7 @@ export function CreateProjectModal({ open, onOpenChange }: Props) {
/>
{name && (
<div className="text-xs text-muted-foreground font-mono">
Folder: /opt/{folderPreview || <span className="text-destructive">(empty after sanitization)</span>}
Folder: /opt/projects/{folderPreview || <span className="text-destructive">(empty after sanitization)</span>}
</div>
)}
</div>

View File

@@ -2,13 +2,22 @@ import { Children, cloneElement, isValidElement, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw } from 'lucide-react';
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import type { Chat, Message } from '@/api/types';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { ToolCallCard } from './ToolCallCard';
import { CodeBlock } from './CodeBlock';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
// Match path-shaped substrings ending in `.ext`. Additionally require a `/`
// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't
@@ -198,6 +207,9 @@ function ActionRow({
}) {
const [justCopied, setJustCopied] = useState(false);
const [regenerating, setRegenerating] = useState(false);
const [forking, setForking] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
async function copy() {
try {
@@ -221,33 +233,114 @@ function ActionRow({
}
}
async function fork() {
if (forking || message.status !== 'complete') return;
setForking(true);
try {
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
sessionEvents.emit({ type: 'open_chat_in_active_pane', chat_id: chat.id });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'fork failed');
} finally {
setForking(false);
}
}
async function confirmDelete() {
if (deleting) return;
setDeleting(true);
try {
await api.messages.remove(message.chat_id, message.id);
setDeleteOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'delete failed');
} finally {
setDeleting(false);
}
}
const isAssistant = message.role === 'assistant';
const canRegen = isAssistant && message.status !== 'streaming';
const canFork = message.status === 'complete';
const canDelete = 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 && (
<>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<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"
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"
>
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
</button>
)}
</div>
{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>
)}
<button
type="button"
onClick={() => void fork()}
disabled={!canFork || forking}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Fork from here"
title="Fork from here"
>
<GitFork className="size-3" />
</button>
<button
type="button"
onClick={() => setDeleteOpen(true)}
disabled={!canDelete}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Delete message"
title="Delete message"
>
<Trash2 className="size-3" />
</button>
</div>
<Dialog
open={deleteOpen}
onOpenChange={(open) => {
if (!deleting) setDeleteOpen(open);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this message and all messages after it?</DialogTitle>
<DialogDescription>
This removes the selected message and every later message in this chat. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteOpen(false)}
disabled={deleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => void confirmDelete()}
disabled={deleting}
>
{deleting ? 'Deleting…' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,116 +0,0 @@
import type { DragEvent } from 'react';
import { FolderOpen, MessageSquare, X } from 'lucide-react';
import type { Pane, PaneKind } from '@/api/types';
import { cn } from '@/lib/utils';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
interface Props {
pane: Pane;
isActive: boolean;
onClick: () => void;
onClose: () => void;
onSplit: (kind: PaneKind) => void;
onCloseOthers: () => void;
onCloseToRight: () => void;
onCloseAll: () => void;
onDragStart: (e: DragEvent<HTMLDivElement>) => void;
onDragOver: (e: DragEvent<HTMLDivElement>) => void;
onDrop: (e: DragEvent<HTMLDivElement>) => void;
}
function basename(path: string): string {
if (!path) return '';
const parts = path.split('/');
return parts[parts.length - 1] ?? path;
}
function labelFor(pane: Pane): string {
if (pane.kind === 'chat') return 'Chat';
const openFile = pane.state.open_file;
if (openFile) return basename(openFile);
return 'Files';
}
export function PaneTab({
pane,
isActive,
onClick,
onClose,
onSplit,
onCloseOthers,
onCloseToRight,
onCloseAll,
onDragStart,
onDragOver,
onDrop,
}: Props) {
const Icon = pane.kind === 'chat' ? MessageSquare : FolderOpen;
const label = labelFor(pane);
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
draggable
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onClick={onClick}
className={cn(
'group flex items-center gap-1.5 px-3 py-1.5 text-xs border-r border-border cursor-default select-none',
isActive
? 'bg-background text-foreground'
: 'bg-muted/30 text-muted-foreground hover:bg-muted/60'
)}
role="tab"
aria-selected={isActive}
>
<Icon size={12} className="shrink-0" />
<span className="truncate max-w-[160px]" title={label}>
{label}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="p-0.5 hover:bg-muted rounded opacity-60 hover:opacity-100 shrink-0"
aria-label="Close tab"
>
<X size={10} />
</button>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuSub>
<ContextMenuSubTrigger>Split</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem onSelect={() => onSplit('chat')}>
<MessageSquare /> Chat
</ContextMenuItem>
<ContextMenuItem onSelect={() => onSplit('file_browser')}>
<FolderOpen /> File Browser
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem onSelect={onClose}>Close</ContextMenuItem>
<ContextMenuItem onSelect={onCloseOthers}>Close others</ContextMenuItem>
<ContextMenuItem onSelect={onCloseToRight}>
Close to the right
</ContextMenuItem>
<ContextMenuItem onSelect={onCloseAll}>Close all</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}

View File

@@ -19,7 +19,6 @@ import {
} from '@/components/ui/dialog';
import { AddProjectModal } from './AddProjectModal';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { useSidebar } from '@/hooks/useSidebar';
import type { SidebarProject } from '@/api/types';
import { giteaUrlFor } from '@/lib/projectUrls';
@@ -186,7 +185,8 @@ export function ProjectSidebar() {
if (!trimmed) return;
try {
await api.sessions.update(sessionId, { name: trimmed });
sessionEvents.emit({ type: 'session_renamed', session_id: sessionId, name: trimmed });
// Server publishes session_renamed via broker.publishUser; useUserEvents
// forwards onto the bus. No local emit needed.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to rename session');
}

View File

@@ -35,6 +35,7 @@ export function RightRail({ projectId }: Props) {
const [viewerFile, setViewerFile] = useState<{ path: string; content: string } | null>(null);
useEffect(() => {
// best-effort; ignore failure because localStorage may be unavailable (quota, private mode)
try { localStorage.setItem(`${STORAGE_KEY}.open`, String(open)); } catch {}
}, [open]);

View File

@@ -1,10 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { DragEvent } from 'react';
import { useCallback } from 'react';
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import type { Chat, WorkspacePane } from '@/api/types';
import { useWorkspacePanes, MAX_PANES } from '@/hooks/useWorkspacePanes';
import { useSessionChats } from '@/hooks/useSessionChats';
import { ChatPane } from '@/components/panes/ChatPane';
import { ChatTabBar } from '@/components/ChatTabBar';
import { SessionLandingPage } from '@/components/SessionLandingPage';
@@ -21,376 +19,53 @@ interface Props {
projectId: string;
}
const MAX_PANES = 5;
const STORAGE_KEY = 'boocode.workspace.panes';
function generateId(): string {
return crypto.randomUUID();
}
function emptyPane(): WorkspacePane {
return { id: generateId(), kind: 'empty', chatIds: [], activeChatIdx: -1 };
}
function chatPane(chatId: string): WorkspacePane {
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
}
function loadPanes(sessionId: string): WorkspacePane[] | null {
try {
const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`);
if (!raw) return null;
const parsed = JSON.parse(raw) as WorkspacePane[];
if (!Array.isArray(parsed) || parsed.length === 0) return null;
return parsed;
} catch {
return null;
}
}
function savePanes(sessionId: string, panes: WorkspacePane[]): void {
try {
localStorage.setItem(`${STORAGE_KEY}.${sessionId}`, JSON.stringify(panes));
} catch { /* quota or disabled */ }
}
export function Workspace({ sessionId, projectId }: Props) {
const [panes, setPanes] = useState<WorkspacePane[]>(() => {
return loadPanes(sessionId) ?? [emptyPane()];
const {
panes,
activePaneIdx,
setActivePaneIdx,
activePaneIdxRef,
openChatInPane,
switchTab,
removeTab,
closeOtherTabs,
closeTabsToRight,
closeAllTabs,
showLandingPage,
addSplitPane,
removePane,
removeChatFromPanes,
initializeFirstChatIfEmpty,
handlePaneDragStart,
handlePaneDragOver,
handlePaneDragLeave,
handlePaneDrop,
handlePaneDragEnd,
dragOverIdx,
draggingIdxRef,
} = useWorkspacePanes(sessionId);
// Thin wrapper so useSessionChats can route open_chat_in_active_pane events
// without knowing about pane indexing.
const openChatInActivePane = useCallback(
(chatId: string) => openChatInPane(activePaneIdxRef.current, chatId),
[openChatInPane, activePaneIdxRef],
);
const {
chats,
createChat,
archiveChat,
unarchiveChat,
deleteChat,
renameChat,
handleLandingSend,
} = useSessionChats(sessionId, {
removeChatFromPanes,
openChatInPane,
openChatInActivePane,
initializeFirstChatIfEmpty,
});
const [activePaneIdx, setActivePaneIdx] = useState(0);
const [chats, setChats] = useState<Chat[]>([]);
const chatsRef = useRef<Chat[]>([]);
chatsRef.current = chats;
const draggingIdxRef = useRef<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
api.chats.listForSession(sessionId).then((list) => {
if (cancelled) return;
setChats(list);
const openChat = list.find((c) => c.status === 'open');
if (openChat) {
setPanes((prev) => {
if (prev.length === 1 && prev[0]!.kind === 'empty') {
return [chatPane(openChat.id)];
}
return prev;
});
}
}).catch(() => {});
return () => { cancelled = true; };
}, [sessionId]);
useEffect(() => {
savePanes(sessionId, panes);
}, [sessionId, panes]);
useEffect(() => {
return sessionEvents.subscribe((event) => {
if (event.type === 'chat_created' && event.session_id === sessionId) {
setChats((prev) => {
if (prev.some((c) => c.id === event.chat.id)) return prev;
return [event.chat, ...prev];
});
}
if (event.type === 'chat_updated') {
setChats((prev) => prev.map((c) =>
c.id === event.chat_id ? { ...c, name: event.name, updated_at: event.updated_at } : c
));
}
if (event.type === 'chat_archived') {
setChats((prev) => prev.map((c) =>
c.id === event.chat_id ? { ...c, status: 'archived' as const } : c
));
removeChatFromPanes(event.chat_id);
}
if (event.type === 'chat_unarchived') {
setChats((prev) => {
if (prev.some((c) => c.id === event.chat.id)) {
return prev.map((c) => c.id === event.chat.id ? { ...c, status: 'open' as const } : c);
}
return [event.chat, ...prev];
});
}
if (event.type === 'chat_deleted') {
setChats((prev) => prev.filter((c) => c.id !== event.chat_id));
removeChatFromPanes(event.chat_id);
}
});
}, [sessionId]);
function removeChatFromPanes(chatId: string) {
setPanes((prev) => prev.map((p) => {
const idx = p.chatIds.indexOf(chatId);
if (idx < 0) return p;
const nextIds = p.chatIds.filter((id) => id !== chatId);
if (nextIds.length === 0) {
return { ...p, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 };
}
const nextActiveIdx = Math.min(p.activeChatIdx, nextIds.length - 1);
return {
...p,
chatIds: nextIds,
activeChatIdx: nextActiveIdx,
chatId: nextIds[nextActiveIdx],
};
}));
}
const openChatInPane = useCallback((paneIdx: number, chatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const existing = pane.chatIds.indexOf(chatId);
if (existing >= 0) {
next[paneIdx] = { ...pane, kind: 'chat', chatId, activeChatIdx: existing };
} else {
const newIds = [...pane.chatIds, chatId];
next[paneIdx] = {
...pane,
kind: 'chat',
chatId,
chatIds: newIds,
activeChatIdx: newIds.length - 1,
};
}
return next;
});
setActivePaneIdx(paneIdx);
}, []);
const switchTab = useCallback((paneIdx: number, tabIdx: number) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const chatId = pane.chatIds[tabIdx];
if (!chatId) return prev;
next[paneIdx] = { ...pane, chatId, activeChatIdx: tabIdx };
return next;
});
}, []);
const removeTab = useCallback((paneIdx: number, chatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const nextIds = pane.chatIds.filter((id) => id !== chatId);
if (nextIds.length === 0) {
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
} else {
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
next[paneIdx] = {
...pane,
chatIds: nextIds,
activeChatIdx: nextActiveIdx,
chatId: nextIds[nextActiveIdx],
};
}
return next;
});
}, []);
// Keep only the right-clicked tab open in this pane.
const closeOtherTabs = useCallback((paneIdx: number, keepChatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const keepIdx = pane.chatIds.indexOf(keepChatId);
if (keepIdx < 0) return prev;
next[paneIdx] = {
...pane,
kind: 'chat',
chatId: keepChatId,
chatIds: [keepChatId],
activeChatIdx: 0,
};
return next;
});
}, []);
// Close every tab to the right of the right-clicked one.
const closeTabsToRight = useCallback((paneIdx: number, pivotChatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const pivotIdx = pane.chatIds.indexOf(pivotChatId);
if (pivotIdx < 0 || pivotIdx === pane.chatIds.length - 1) return prev;
const nextIds = pane.chatIds.slice(0, pivotIdx + 1);
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
next[paneIdx] = {
...pane,
chatIds: nextIds,
activeChatIdx: nextActiveIdx,
chatId: nextIds[nextActiveIdx],
};
return next;
});
}, []);
// Close every tab in this pane; land on landing page.
const closeAllTabs = useCallback((paneIdx: number) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
return next;
});
}, []);
const createChat = useCallback(async (paneIdx: number) => {
try {
const chat = await api.chats.create(sessionId);
// Optimistic local insert; the WS chat_created echo will be deduped by id.
setChats((prev) => {
if (prev.some((c) => c.id === chat.id)) return prev;
return [chat, ...prev];
});
openChatInPane(paneIdx, chat.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create chat');
}
}, [sessionId, openChatInPane]);
const archiveChat = useCallback(async (chatId: string) => {
try {
await api.chats.archive(chatId);
// Server publishes chat_archived; bus forwarder updates state.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to archive chat');
}
}, []);
const unarchiveChat = useCallback(async (chatId: string) => {
try {
await api.chats.unarchive(chatId);
// Server publishes chat_unarchived.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to restore chat');
}
}, []);
const deleteChat = useCallback(async (chatId: string) => {
try {
await api.chats.remove(chatId);
setChats((prev) => prev.filter((c) => c.id !== chatId));
removeChatFromPanes(chatId);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete chat');
}
}, []);
const renameChat = useCallback(async (chatId: string, name: string) => {
try {
await api.chats.update(chatId, { name });
setChats((prev) => prev.map((c) =>
c.id === chatId ? { ...c, name } : c
));
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to rename chat');
}
}, []);
const showLandingPage = useCallback((paneIdx: number) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined };
return next;
});
}, []);
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => {
if (kind === 'terminal') {
toast('Terminal panes coming in BooTerm');
return;
}
if (kind === 'agent') {
toast('Agent panes coming in BooCoder');
return;
}
setPanes((prev) => {
if (prev.length >= MAX_PANES) {
toast.error(`Maximum ${MAX_PANES} panes`);
return prev;
}
const next = [...prev, emptyPane()];
setActivePaneIdx(next.length - 1);
return next;
});
}, []);
const removePane = useCallback((idx: number) => {
setPanes((prev) => {
if (prev.length <= 1) return prev;
const next = prev.filter((_, i) => i !== idx);
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
return next;
});
}, []);
const handlePaneDragStart = useCallback(
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
draggingIdxRef.current = idx;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(idx));
},
[]
);
const handlePaneDragOver = useCallback(
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
if (draggingIdxRef.current === null) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragOverIdx !== idx) setDragOverIdx(idx);
},
[dragOverIdx]
);
const handlePaneDragLeave = useCallback(() => {
setDragOverIdx(null);
}, []);
const handlePaneDrop = useCallback(
(targetIdx: number) => (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
const fromIdx = draggingIdxRef.current;
draggingIdxRef.current = null;
setDragOverIdx(null);
if (fromIdx === null || fromIdx === targetIdx) return;
setPanes((prev) => {
const next = [...prev];
const [moved] = next.splice(fromIdx, 1);
if (!moved) return prev;
next.splice(targetIdx, 0, moved);
// Keep active selection on the same logical pane (the one being dragged).
setActivePaneIdx(targetIdx);
return next;
});
},
[]
);
const handlePaneDragEnd = useCallback(() => {
draggingIdxRef.current = null;
setDragOverIdx(null);
}, []);
const handleLandingSend = useCallback(async (paneIdx: number, content: string) => {
try {
const chat = await api.chats.create(sessionId);
setChats((prev) => {
if (prev.some((c) => c.id === chat.id)) return prev;
return [chat, ...prev];
});
openChatInPane(paneIdx, chat.id);
await api.messages.send(chat.id, content);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to send');
}
}, [sessionId, openChatInPane]);
function chatsForPane(pane: WorkspacePane): Chat[] {
return pane.chatIds

View File

@@ -3,8 +3,10 @@ import { ChevronDown, Square, X } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import { useSessionStream } from '@/hooks/useSessionStream';
import { useChatContextStats } from '@/hooks/useChatContextStats';
import { MessageList } from '@/components/MessageList';
import { ChatInput } from '@/components/ChatInput';
import { ChatContextPopover } from '@/components/ChatContextPopover';
import {
DropdownMenu,
DropdownMenuContent,
@@ -37,6 +39,7 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
const chatMessages = stream.messages.filter((m) => m.chat_id === chatId);
const streaming = chatMessages.some((m) => m.status === 'streaming');
const contextStats = useChatContextStats(chatId, chatMessages);
// Auto-send next queued message when streaming completes
useEffect(() => {
@@ -162,7 +165,10 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
</div>
)}
<ChatInput disabled={false} projectId={projectId} onSend={handleSend} onForceSend={streaming ? handleForceSend : undefined} />
<div className="relative">
<ChatContextPopover stats={contextStats} />
<ChatInput disabled={false} projectId={projectId} onSend={handleSend} onForceSend={streaming ? handleForceSend : undefined} />
</div>
</div>
);
}

View File

@@ -1,865 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { KeyboardEvent } from 'react';
import { Check, ChevronRight, ChevronDown, Copy, FileText, Folder, X } from 'lucide-react';
import { codeToHtml } from 'shiki';
import { api, ApiError } from '@/api/client';
import type {
FileBrowserPaneState,
FileEntry,
Pane,
ViewFileResult,
} from '@/api/types';
import { inferLanguage } from '@/lib/attachments';
import { sessionEvents } from '@/hooks/sessionEvents';
import { cn } from '@/lib/utils';
interface Props {
pane: Pane & { kind: 'file_browser' };
projectId: string;
onStateChange: (state: FileBrowserPaneState) => void;
}
const SHIKI_THEME = 'github-dark';
function splitShikiLines(html: string): string[] {
const match = html.match(/<code[^>]*>([\s\S]*)<\/code>/);
if (!match) return [];
const inner = match[1]!;
const lines = inner.split(/(?=<span class="line">)/);
return lines.filter(l => l.trim().length > 0);
}
interface FileViewerProps {
code: string;
lang: string | null;
selectedLines: Set<number>;
onLineClick: (lineNo: number, shiftKey: boolean) => void;
}
function FileViewer({ code, lang, selectedLines, onLineClick }: FileViewerProps) {
const [copied, setCopied] = useState(false);
const [lineHtmls, setLineHtmls] = useState<string[] | null>(null);
useEffect(() => {
let cancelled = false;
if (!lang) {
setLineHtmls(null);
return;
}
(async () => {
try {
const result = await codeToHtml(code, { lang, theme: SHIKI_THEME });
if (cancelled) return;
const lines = splitShikiLines(result);
setLineHtmls(lines.length > 0 ? lines : null);
} catch (err) {
console.warn('shiki failed', err);
if (!cancelled) setLineHtmls(null);
}
})();
return () => {
cancelled = true;
};
}, [code, lang]);
async function copy() {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {
/* ignore */
}
}
const plainLines = code.split('\n');
const totalLines = lineHtmls ? lineHtmls.length : plainLines.length;
return (
<div className="text-sm font-mono">
<div className="flex items-center justify-between px-2 py-1 border-b text-xs text-muted-foreground">
<span className="font-mono">{lang || 'code'}</span>
<button
type="button"
onClick={() => void copy()}
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground"
aria-label="Copy code"
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
<div className="overflow-x-auto">
{Array.from({ length: totalLines }, (_, i) => {
const lineNo = i + 1;
const isSelected = selectedLines.has(lineNo);
return (
<div
key={lineNo}
className={cn(
'flex',
isSelected && 'bg-blue-500/10'
)}
>
<button
type="button"
className="shrink-0 w-[3ch] text-right pr-2 text-xs text-muted-foreground select-none cursor-pointer hover:text-foreground"
style={{ fontVariantNumeric: 'tabular-nums' }}
onClick={(e) => onLineClick(lineNo, e.shiftKey)}
>
{lineNo}
</button>
{lineHtmls ? (
<div
className="flex-1 min-w-0 text-xs leading-relaxed [&>.line]:!bg-transparent"
// eslint-disable-next-line react/no-danger -- Shiki generates sanitized HTML spans, not user content
dangerouslySetInnerHTML={{ __html: lineHtmls[i] ?? '' }}
/>
) : (
<span className="flex-1 min-w-0 text-xs leading-relaxed whitespace-pre">
{plainLines[i] ?? ''}
</span>
)}
</div>
);
})}
</div>
</div>
);
}
function basename(path: string): string {
if (!path) return '';
const parts = path.split('/');
return parts[parts.length - 1] ?? path;
}
function joinPath(parent: string, name: string): string {
if (!parent || parent === '.' || parent === '') return name;
return `${parent}/${name}`;
}
interface TreeNodeProps {
parentPath: string; // '' for root children
entries: FileEntry[];
cache: Map<string, FileEntry[]>;
expanded: Set<string>;
openFile: string | null;
highlightedPath: string | null;
depth: number;
onToggleDir: (dirPath: string) => void;
onSelectFile: (path: string) => void;
setHighlightedPath: (p: string) => void;
}
function TreeNode({
parentPath,
entries,
cache,
expanded,
openFile,
highlightedPath,
depth,
onToggleDir,
onSelectFile,
setHighlightedPath,
}: TreeNodeProps) {
// Sort: dirs first, then files; alphabetical within each.
const sorted = useMemo(() => {
const copy = [...entries];
copy.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1;
return a.name.localeCompare(b.name);
});
return copy;
}, [entries]);
return (
<ul className="list-none">
{sorted.map((entry) => {
const fullPath = joinPath(parentPath, entry.name);
const isExpanded = entry.kind === 'dir' && expanded.has(fullPath);
const isActive = entry.kind === 'file' && openFile === fullPath;
const isHighlight = highlightedPath === fullPath;
return (
<li key={fullPath}>
<div
data-path={fullPath}
data-kind={entry.kind}
className={cn(
'flex items-center gap-1 px-1 py-0.5 text-xs cursor-default rounded hover:bg-muted/60',
isActive && 'bg-muted',
isHighlight && 'ring-1 ring-ring/40'
)}
style={{ paddingLeft: 4 + depth * 12 }}
onClick={() => {
setHighlightedPath(fullPath);
if (entry.kind === 'dir') {
onToggleDir(fullPath);
} else {
onSelectFile(fullPath);
}
}}
>
{entry.kind === 'dir' ? (
<button
type="button"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
className="p-0.5 hover:bg-muted rounded shrink-0"
onClick={(e) => {
e.stopPropagation();
setHighlightedPath(fullPath);
onToggleDir(fullPath);
}}
>
{isExpanded ? (
<ChevronDown size={10} />
) : (
<ChevronRight size={10} />
)}
</button>
) : (
<span className="w-[16px] shrink-0" />
)}
{entry.kind === 'dir' ? (
<Folder size={12} className="text-muted-foreground shrink-0" />
) : (
<FileText size={12} className="text-muted-foreground shrink-0" />
)}
<span className="truncate">{entry.name}</span>
</div>
{entry.kind === 'dir' && isExpanded && cache.has(fullPath) && (
<TreeNode
parentPath={fullPath}
entries={cache.get(fullPath) ?? []}
cache={cache}
expanded={expanded}
openFile={openFile}
highlightedPath={highlightedPath}
depth={depth + 1}
onToggleDir={onToggleDir}
onSelectFile={onSelectFile}
setHighlightedPath={setHighlightedPath}
/>
)}
</li>
);
})}
</ul>
);
}
export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
const openFile = pane.state.open_file ?? null;
const filter = pane.state.filter ?? '';
const expandedDirs = useMemo(
() => pane.state.expanded_dirs ?? [],
[pane.state.expanded_dirs]
);
// Local filter (debounced 100ms before pushing to onStateChange)
const [filterDraft, setFilterDraft] = useState(filter);
const filterDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Track previous external filter so we can sync local draft when the
// canonical state changes from outside (e.g. server snapshot, other tab).
const lastExternalFilter = useRef(filter);
useEffect(() => {
if (filter !== lastExternalFilter.current) {
lastExternalFilter.current = filter;
setFilterDraft(filter);
}
}, [filter]);
function onFilterInput(value: string) {
setFilterDraft(value);
if (filterDebounceRef.current !== null) {
clearTimeout(filterDebounceRef.current);
}
filterDebounceRef.current = setTimeout(() => {
filterDebounceRef.current = null;
lastExternalFilter.current = value;
onStateChange({
...pane.state,
filter: value,
open_file: openFile,
expanded_dirs: expandedDirs,
});
}, 100);
}
useEffect(() => {
return () => {
if (filterDebounceRef.current !== null) {
clearTimeout(filterDebounceRef.current);
}
};
}, []);
// Full file list fetched once on mount for filter mode (covers unexpanded dirs)
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const result = await api.projects.files(projectId);
if (!cancelled) setFullFileList(result.files);
} catch {
// Silently ignore; filter will fall back to cache-based list
}
})();
return () => {
cancelled = true;
};
// Intentionally run once per mount (projectId is stable per pane)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
// Directory cache: dirPath -> entries
const [cache, setCache] = useState<Map<string, FileEntry[]>>(new Map());
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set());
const [dirErrors, setDirErrors] = useState<Map<string, string>>(new Map());
const loadDir = useCallback(
async (dirPath: string) => {
// dirPath '' is root; server expects '.'
const apiPath = dirPath === '' ? '.' : dirPath;
setLoadingDirs((prev) => {
if (prev.has(dirPath)) return prev;
const next = new Set(prev);
next.add(dirPath);
return next;
});
try {
const result = await api.projects.listDir(projectId, apiPath);
setCache((prev) => {
const next = new Map(prev);
next.set(dirPath, result.entries);
return next;
});
setDirErrors((prev) => {
if (!prev.has(dirPath)) return prev;
const next = new Map(prev);
next.delete(dirPath);
return next;
});
} catch (err) {
const msg = err instanceof Error ? err.message : 'failed to list directory';
setDirErrors((prev) => {
const next = new Map(prev);
next.set(dirPath, msg);
return next;
});
} finally {
setLoadingDirs((prev) => {
if (!prev.has(dirPath)) return prev;
const next = new Set(prev);
next.delete(dirPath);
return next;
});
}
},
[projectId]
);
// Load root on mount + any expanded dirs from server state.
useEffect(() => {
if (!cache.has('')) {
void loadDir('');
}
for (const dir of expandedDirs) {
if (!cache.has(dir)) {
void loadDir(dir);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
// When expandedDirs grows (e.g. user expands), ensure new dir is loaded.
useEffect(() => {
for (const dir of expandedDirs) {
if (!cache.has(dir) && !loadingDirs.has(dir)) {
void loadDir(dir);
}
}
}, [expandedDirs, cache, loadingDirs, loadDir]);
const expandedSet = useMemo(() => new Set(expandedDirs), [expandedDirs]);
function toggleDir(dirPath: string) {
let nextDirs: string[];
if (expandedSet.has(dirPath)) {
nextDirs = expandedDirs.filter((d) => d !== dirPath);
} else {
nextDirs = [...expandedDirs, dirPath];
}
onStateChange({
...pane.state,
open_file: openFile,
filter: filterDraft,
expanded_dirs: nextDirs,
});
}
function selectFile(path: string) {
onStateChange({
...pane.state,
open_file: path,
filter: filterDraft,
expanded_dirs: expandedDirs,
});
}
function closeOpenFile() {
onStateChange({
...pane.state,
open_file: null,
filter: filterDraft,
expanded_dirs: expandedDirs,
});
}
// Build a flat list of all entries reachable through the loaded cache,
// for filter results and keyboard navigation.
interface FlatEntry {
path: string;
name: string;
kind: 'file' | 'dir';
}
const flattenedVisible = useMemo<FlatEntry[]>(() => {
const result: FlatEntry[] = [];
function walk(dirPath: string) {
const entries = cache.get(dirPath);
if (!entries) return;
const sorted = [...entries].sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1;
return a.name.localeCompare(b.name);
});
for (const e of sorted) {
const full = joinPath(dirPath, e.name);
result.push({ path: full, name: e.name, kind: e.kind });
if (e.kind === 'dir' && expandedSet.has(full)) {
walk(full);
}
}
}
walk('');
return result;
}, [cache, expandedSet]);
const flattenedAll = useMemo<FlatEntry[]>(() => {
const result: FlatEntry[] = [];
function walk(dirPath: string) {
const entries = cache.get(dirPath);
if (!entries) return;
for (const e of entries) {
const full = joinPath(dirPath, e.name);
result.push({ path: full, name: e.name, kind: e.kind });
if (e.kind === 'dir') walk(full);
}
}
walk('');
return result;
}, [cache]);
const trimmedFilter = filterDraft.trim();
const filterActive = trimmedFilter.length > 0;
interface FilterResult {
path: string;
name: string;
}
const filterResults = useMemo<FilterResult[]>(() => {
if (!filterActive) return [];
const needle = trimmedFilter.toLowerCase();
if (fullFileList !== null) {
// Use complete file list from API; rank filename matches above path-only matches
const filenameMatches: string[] = [];
const pathOnlyMatches: string[] = [];
for (const p of fullFileList) {
const lp = p.toLowerCase();
if (!lp.includes(needle)) continue;
const bn = basename(p).toLowerCase();
if (bn.includes(needle)) {
filenameMatches.push(p);
} else {
pathOnlyMatches.push(p);
}
}
filenameMatches.sort((a, b) => a.localeCompare(b));
pathOnlyMatches.sort((a, b) => a.localeCompare(b));
return [...filenameMatches, ...pathOnlyMatches]
.slice(0, 50)
.map((p) => ({ path: p, name: basename(p) }));
}
// Fallback: use cache-based flat list (only loaded directories, files only)
return flattenedAll
.filter((e) => e.kind === 'file' && e.path.toLowerCase().includes(needle))
.slice(0, 50)
.map((e) => ({ path: e.path, name: e.name }));
}, [filterActive, trimmedFilter, fullFileList, flattenedAll]);
// Keyboard navigation
const [highlightedPath, setHighlightedPath] = useState<string | null>(null);
const treeRef = useRef<HTMLDivElement | null>(null);
// Reset highlight if it falls out of the current list (e.g. when filter
// changes or dirs collapse).
useEffect(() => {
if (!highlightedPath) return;
const list = filterActive ? filterResults : flattenedVisible;
if (!list.some((e) => e.path === highlightedPath)) {
setHighlightedPath(null);
}
}, [highlightedPath, filterActive, filterResults, flattenedVisible]);
function onTreeKeyDown(e: KeyboardEvent<HTMLDivElement>) {
if (filterActive) {
if (filterResults.length === 0) return;
const idx = highlightedPath
? filterResults.findIndex((entry) => entry.path === highlightedPath)
: -1;
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = idx < 0 ? 0 : Math.min(filterResults.length - 1, idx + 1);
const target = filterResults[next];
if (target) setHighlightedPath(target.path);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
const next = idx <= 0 ? 0 : idx - 1;
const target = filterResults[next];
if (target) setHighlightedPath(target.path);
return;
}
if (e.key === 'Enter') {
if (idx < 0) return;
const target = filterResults[idx];
if (!target) return;
e.preventDefault();
// Filter results are always files (API returns only files)
selectFile(target.path);
}
return;
}
// Tree mode: use flattenedVisible which has kind info
const list = flattenedVisible;
if (list.length === 0) return;
const idx = highlightedPath
? list.findIndex((entry) => entry.path === highlightedPath)
: -1;
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = idx < 0 ? 0 : Math.min(list.length - 1, idx + 1);
const target = list[next];
if (target) setHighlightedPath(target.path);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
const next = idx <= 0 ? 0 : idx - 1;
const target = list[next];
if (target) setHighlightedPath(target.path);
return;
}
if (e.key === 'Enter') {
if (idx < 0) return;
const target = list[idx];
if (!target) return;
e.preventDefault();
if (target.kind === 'dir') {
toggleDir(target.path);
} else {
selectFile(target.path);
}
}
}
// Line selection state
const [selectedLines, setSelectedLines] = useState<Set<number>>(new Set());
const [selectionAnchor, setSelectionAnchor] = useState<number | null>(null);
function handleLineClick(lineNo: number, shiftKey: boolean) {
if (shiftKey && selectionAnchor !== null) {
const start = Math.min(selectionAnchor, lineNo);
const end = Math.max(selectionAnchor, lineNo);
const range = new Set<number>();
for (let i = start; i <= end; i++) range.add(i);
setSelectedLines(range);
} else {
setSelectedLines(prev => {
const next = new Set(prev);
if (next.has(lineNo)) {
next.delete(lineNo);
} else {
next.add(lineNo);
}
return next;
});
setSelectionAnchor(lineNo);
}
}
// Viewer state
const [viewer, setViewer] = useState<{
path: string;
state: 'loading' | 'ready' | 'error';
result?: ViewFileResult;
error?: string;
} | null>(null);
useEffect(() => {
if (!openFile) {
setViewer(null);
return;
}
let cancelled = false;
setViewer({ path: openFile, state: 'loading' });
(async () => {
try {
const result = await api.projects.viewFile(projectId, openFile);
if (cancelled) return;
setViewer({ path: openFile, state: 'ready', result });
} catch (err) {
if (cancelled) return;
let message: string;
if (err instanceof ApiError) {
const apiMsg =
typeof err.body === 'object' &&
err.body !== null &&
'error' in err.body
? String((err.body as { error: unknown }).error)
: err.message;
if (err.status === 404) {
message = 'File not found';
} else if (apiMsg.toLowerCase().includes('too large')) {
message = 'File too large to view';
} else if (
apiMsg.toLowerCase().includes('outside') ||
apiMsg.toLowerCase().includes('not a file') ||
apiMsg.toLowerCase().includes('path')
) {
message = 'Cannot view files outside project';
} else {
message = apiMsg;
}
} else if (err instanceof Error) {
message = err.message;
} else {
message = 'Failed to load file';
}
setViewer({ path: openFile, state: 'error', error: message });
}
})();
return () => {
cancelled = true;
};
}, [openFile, projectId]);
// Clear line selection when open file changes
useEffect(() => {
setSelectedLines(new Set());
setSelectionAnchor(null);
}, [openFile]);
// Compute selection range for the floating action bar (loop avoids call-stack limit on spread)
let selectionMin = 0;
let selectionMax = 0;
if (selectedLines.size > 0) {
for (const n of selectedLines) {
if (selectionMin === 0 || n < selectionMin) selectionMin = n;
if (n > selectionMax) selectionMax = n;
}
}
function handleAttachLines() {
if (!openFile || !viewer?.result || selectedLines.size === 0) return;
const min = selectionMin;
const max = selectionMax;
const selectedContent = viewer.result.content
.split('\n')
.slice(min - 1, max)
.join('\n');
sessionEvents.emit({
type: 'attach_chat_file',
attachment: {
kind: 'lines',
filename: openFile,
language: inferLanguage(openFile) ?? null,
content: selectedContent,
range: [min, max],
source: 'line-select',
},
});
setSelectedLines(new Set());
setSelectionAnchor(null);
}
// Root errors / loading
const rootEntries = cache.get('');
const rootLoading = loadingDirs.has('') && !rootEntries;
const rootError = dirErrors.get('');
return (
<div className="flex flex-col h-full min-h-0">
<div className="px-2 py-1.5 border-b border-border bg-muted/20">
<input
type="text"
value={filterDraft}
onChange={(e) => onFilterInput(e.target.value)}
placeholder="Filter files..."
className="w-full px-2 py-1 text-xs bg-background border border-border rounded outline-none focus:border-ring"
aria-label="Filter files"
/>
</div>
<div className="flex-1 min-h-0 grid grid-cols-[minmax(0,260px)_1fr]">
<div
ref={treeRef}
tabIndex={0}
onKeyDown={onTreeKeyDown}
className="overflow-y-auto border-r border-border outline-none focus:ring-1 focus:ring-inset focus:ring-ring/40"
role="tree"
aria-label="Project files"
>
{rootLoading && (
<div className="text-xs text-muted-foreground px-2 py-1.5">
Loading...
</div>
)}
{rootError && (
<div className="text-xs text-destructive px-2 py-1.5">
{rootError}
</div>
)}
{!rootLoading && !rootError && filterActive && (
<ul className="list-none">
{filterResults.length === 0 ? (
<li className="text-xs text-muted-foreground px-2 py-1.5">
No matches
</li>
) : (
filterResults.map((entry) => {
const isActive = openFile === entry.path;
const isHighlight = highlightedPath === entry.path;
return (
<li key={entry.path}>
<div
className={cn(
'flex items-center gap-1 px-2 py-0.5 text-xs cursor-default rounded hover:bg-muted/60',
isActive && 'bg-muted',
isHighlight && 'ring-1 ring-ring/40'
)}
onClick={() => {
setHighlightedPath(entry.path);
selectFile(entry.path);
}}
>
<FileText size={12} className="text-muted-foreground shrink-0" />
<span className="truncate">
<span className="font-bold">{entry.name}</span>
<span className="text-muted-foreground ml-1">{entry.path}</span>
</span>
</div>
</li>
);
})
)}
</ul>
)}
{!rootLoading && !rootError && !filterActive && rootEntries && (
<TreeNode
parentPath=""
entries={rootEntries}
cache={cache}
expanded={expandedSet}
openFile={openFile}
highlightedPath={highlightedPath}
depth={0}
onToggleDir={toggleDir}
onSelectFile={selectFile}
setHighlightedPath={setHighlightedPath}
/>
)}
</div>
<div className="flex flex-col min-h-0">
{!openFile && (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">
Select a file to view
</div>
)}
{openFile && (
<>
<div className="flex items-center justify-between px-2 py-1 border-b border-border bg-muted/20 shrink-0">
<span
className="text-xs font-mono truncate"
title={openFile}
>
{basename(openFile)}
</span>
<button
type="button"
onClick={closeOpenFile}
className="p-0.5 hover:bg-muted rounded shrink-0"
aria-label="Close file"
>
<X size={12} />
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto relative">
{viewer?.state === 'loading' && (
<div className="text-xs text-muted-foreground px-2 py-1.5">
Loading...
</div>
)}
{viewer?.state === 'error' && (
<div className="text-xs text-destructive px-2 py-1.5">
{viewer.error}
</div>
)}
{viewer?.state === 'ready' && viewer.result && (
<div className="p-2">
{selectedLines.size > 0 && (
<div className="sticky top-0 z-10 bg-muted border-b border-border flex items-center justify-between px-2 py-1 mb-2 rounded-t">
<span className="text-xs text-muted-foreground">
{selectedLines.size === 1
? `Attach line ${selectionMin} to chat`
: `Attach lines ${selectionMin}${selectionMax} to chat`}
</span>
<button
type="button"
className="text-xs font-medium text-primary hover:underline"
onClick={handleAttachLines}
>
Attach
</button>
</div>
)}
{viewer.result.truncated && (
<div className="text-[11px] text-muted-foreground mb-1 px-2 py-1 rounded bg-muted/40 border border-border">
Showing first {viewer.result.bytes_returned} bytes; file is {viewer.result.total_bytes} bytes total.
</div>
)}
<FileViewer
code={viewer.result.content}
lang={inferLanguage(openFile)}
selectedLines={selectedLines}
onLineClick={handleLineClick}
/>
</div>
)}
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,31 +0,0 @@
import type { ReactNode } from 'react';
import type { Pane } from '@/api/types';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
interface Props {
pane: Pane;
onClose: () => void;
className?: string;
children: ReactNode;
}
export function PaneShell({ pane, onClose, className, children }: Props) {
const label = pane.kind === 'chat' ? 'Chat' : 'Files';
return (
<div className={cn('flex flex-col h-full min-h-0 border-r border-border last:border-r-0', className)}>
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-muted/30">
<span className="text-xs font-medium text-muted-foreground">{label}</span>
<button
type="button"
onClick={onClose}
className="p-0.5 hover:bg-muted rounded"
aria-label="Close pane"
>
<X size={12} />
</button>
</div>
<div className="flex-1 min-h-0 overflow-hidden">{children}</div>
</div>
);
}

View File

@@ -57,6 +57,11 @@ export interface AttachChatFileEvent {
attachment: Omit<Attachment, 'id'>;
}
export interface OpenChatInActivePaneEvent {
type: 'open_chat_in_active_pane';
chat_id: string;
}
export interface SessionArchivedEvent {
type: 'session_archived';
session_id: string;
@@ -120,6 +125,7 @@ export type SessionEvent =
| SessionLoadedEvent
| OpenFileInBrowserEvent
| AttachChatFileEvent
| OpenChatInActivePaneEvent
| SessionArchivedEvent
| ChatCreatedEvent
| ChatUpdatedEvent

View File

@@ -0,0 +1,61 @@
import { useEffect, useState } from 'react';
import type { WorkspacePaneKind } from '@/api/types';
export interface ActivePaneSnapshot {
sessionId: string | null;
paneId: string | null;
kind: WorkspacePaneKind | null;
activeFile: string | null;
}
const EMPTY: ActivePaneSnapshot = {
sessionId: null,
paneId: null,
kind: null,
activeFile: null,
};
let current: ActivePaneSnapshot = EMPTY;
const subs = new Set<() => void>();
function notify(): void {
for (const sub of subs) {
try {
sub();
} catch {
// swallow — one bad listener shouldn't break others
}
}
}
function isSame(a: ActivePaneSnapshot, b: ActivePaneSnapshot): boolean {
return (
a.sessionId === b.sessionId &&
a.paneId === b.paneId &&
a.kind === b.kind &&
a.activeFile === b.activeFile
);
}
export function setActivePaneInfo(next: ActivePaneSnapshot): void {
if (isSame(current, next)) return;
current = next;
notify();
}
export function clearActivePane(): void {
setActivePaneInfo(EMPTY);
}
export function useActivePane(): ActivePaneSnapshot {
const [snap, setSnap] = useState<ActivePaneSnapshot>(current);
useEffect(() => {
const sub = () => setSnap(current);
subs.add(sub);
sub();
return () => {
subs.delete(sub);
};
}, []);
return snap;
}

View File

@@ -0,0 +1,37 @@
import { useMemo } from 'react';
import type { Message } from '@/api/types';
export interface ChatContextStats {
used: number;
max: number;
percent: number;
}
/**
* Returns the latest context-window usage for the given chat, derived from the
* assistant message (with both ctx_used and ctx_max populated) having the most
* recent created_at. Returns null when no such message exists.
*
* Re-evaluates whenever the `messages` reference or `chatId` changes, which
* matches the cadence of streaming updates from `useSessionStream`.
*/
export function useChatContextStats(
chatId: string,
messages: Message[],
): ChatContextStats | null {
return useMemo(() => {
let latest: Message | null = null;
for (const m of messages) {
if (m.chat_id !== chatId) continue;
if (m.role !== 'assistant') continue;
if (m.ctx_used == null || m.ctx_max == null) continue;
if (!latest || m.created_at > latest.created_at) latest = m;
}
if (!latest || latest.ctx_used == null || latest.ctx_max == null) return null;
const used = latest.ctx_used;
const max = latest.ctx_max;
if (max <= 0) return null;
const percent = Math.round((used / max) * 100);
return { used, max, percent };
}, [chatId, messages]);
}

View File

@@ -0,0 +1,175 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { Chat } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
export interface UseSessionChatsOpts {
removeChatFromPanes: (chatId: string) => void;
openChatInPane: (paneIdx: number, chatId: string) => void;
// Thin wrapper around openChatInPane(activePaneIdxRef.current, chatId);
// built by Workspace and passed in so this hook doesn't need to know
// about pane indexing.
openChatInActivePane: (chatId: string) => void;
initializeFirstChatIfEmpty: (chatId: string) => void;
}
export interface UseSessionChatsResult {
chats: Chat[];
setChats: React.Dispatch<React.SetStateAction<Chat[]>>;
createChat: (paneIdx: number) => Promise<void>;
archiveChat: (chatId: string) => Promise<void>;
unarchiveChat: (chatId: string) => Promise<void>;
deleteChat: (chatId: string) => Promise<void>;
renameChat: (chatId: string, name: string) => Promise<void>;
handleLandingSend: (paneIdx: number, content: string) => Promise<void>;
}
export function useSessionChats(
sessionId: string,
opts: UseSessionChatsOpts,
): UseSessionChatsResult {
const [chats, setChats] = useState<Chat[]>([]);
const chatsRef = useRef<Chat[]>([]);
chatsRef.current = chats;
// Stable refs to opts callbacks so the subscription effect — which only
// re-runs on sessionId change — always sees the latest closures without
// unsubscribe/resubscribe churn.
const removeChatFromPanesRef = useRef(opts.removeChatFromPanes);
removeChatFromPanesRef.current = opts.removeChatFromPanes;
const openChatInPaneRef = useRef(opts.openChatInPane);
openChatInPaneRef.current = opts.openChatInPane;
const openChatInActivePaneRef = useRef(opts.openChatInActivePane);
openChatInActivePaneRef.current = opts.openChatInActivePane;
const initializeFirstChatIfEmptyRef = useRef(opts.initializeFirstChatIfEmpty);
initializeFirstChatIfEmptyRef.current = opts.initializeFirstChatIfEmpty;
useEffect(() => {
let cancelled = false;
api.chats.listForSession(sessionId).then((list) => {
if (cancelled) return;
setChats(list);
const openChat = list.find((c) => c.status === 'open');
if (openChat) {
initializeFirstChatIfEmptyRef.current(openChat.id);
}
}).catch(() => {});
return () => { cancelled = true; };
}, [sessionId]);
useEffect(() => {
return sessionEvents.subscribe((event) => {
if (event.type === 'chat_created' && event.session_id === sessionId) {
setChats((prev) => {
if (prev.some((c) => c.id === event.chat.id)) return prev;
return [event.chat, ...prev];
});
}
if (event.type === 'chat_updated') {
setChats((prev) => prev.map((c) =>
c.id === event.chat_id ? { ...c, name: event.name, updated_at: event.updated_at } : c
));
}
if (event.type === 'chat_archived') {
setChats((prev) => prev.map((c) =>
c.id === event.chat_id ? { ...c, status: 'archived' as const } : c
));
removeChatFromPanesRef.current(event.chat_id);
}
if (event.type === 'chat_unarchived') {
setChats((prev) => {
if (prev.some((c) => c.id === event.chat.id)) {
return prev.map((c) => c.id === event.chat.id ? { ...c, status: 'open' as const } : c);
}
return [event.chat, ...prev];
});
}
if (event.type === 'chat_deleted') {
setChats((prev) => prev.filter((c) => c.id !== event.chat_id));
removeChatFromPanesRef.current(event.chat_id);
}
if (event.type === 'open_chat_in_active_pane') {
openChatInActivePaneRef.current(event.chat_id);
}
});
}, [sessionId]);
const createChat = useCallback(async (paneIdx: number) => {
try {
const chat = await api.chats.create(sessionId);
// Optimistic local insert; the WS chat_created echo will be deduped by id.
setChats((prev) => {
if (prev.some((c) => c.id === chat.id)) return prev;
return [chat, ...prev];
});
openChatInPaneRef.current(paneIdx, chat.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create chat');
}
}, [sessionId]);
const archiveChat = useCallback(async (chatId: string) => {
try {
await api.chats.archive(chatId);
// Server publishes chat_archived; bus forwarder updates state.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to archive chat');
}
}, []);
const unarchiveChat = useCallback(async (chatId: string) => {
try {
await api.chats.unarchive(chatId);
// Server publishes chat_unarchived.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to restore chat');
}
}, []);
const deleteChat = useCallback(async (chatId: string) => {
try {
await api.chats.remove(chatId);
setChats((prev) => prev.filter((c) => c.id !== chatId));
removeChatFromPanesRef.current(chatId);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete chat');
}
}, []);
const renameChat = useCallback(async (chatId: string, name: string) => {
try {
await api.chats.update(chatId, { name });
setChats((prev) => prev.map((c) =>
c.id === chatId ? { ...c, name } : c
));
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to rename chat');
}
}, []);
const handleLandingSend = useCallback(async (paneIdx: number, content: string) => {
try {
const chat = await api.chats.create(sessionId);
setChats((prev) => {
if (prev.some((c) => c.id === chat.id)) return prev;
return [chat, ...prev];
});
openChatInPaneRef.current(paneIdx, chat.id);
await api.messages.send(chat.id, content);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to send');
}
}, [sessionId]);
return {
chats,
setChats,
createChat,
archiveChat,
unarchiveChat,
deleteChat,
renameChat,
handleLandingSend,
};
}

View File

@@ -2,6 +2,10 @@ import { useEffect, useRef, useState } from 'react';
import type { Message, WsFrame } from '@/api/types';
import { sessionEvents } from './sessionEvents';
// session_renamed frame removed from WsFrame — it was declared but never
// published on the per-session WS channel (server publishes via broker.publishUser
// since v1.4). chat_renamed remains; auto_name.ts publishes it on session WS.
interface State {
messages: Message[];
connected: boolean;
@@ -118,14 +122,6 @@ function applyFrame(state: State, frame: WsFrame): State {
messages: state.messages.filter((m) => !removeSet.has(m.id)),
};
}
case 'session_renamed': {
sessionEvents.emit({
type: 'session_renamed',
session_id: frame.session_id,
name: frame.name,
});
return state;
}
case 'chat_renamed': {
sessionEvents.emit({
type: 'chat_updated',

View File

@@ -148,6 +148,9 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
return prev;
case 'attach_chat_file':
return prev;
case 'open_chat_in_active_pane':
// Consumed by Workspace; sidebar has no business with pane state.
return prev;
case 'session_archived': {
let changed = false;
const projects = prev.projects.map((p) => {

View File

@@ -40,7 +40,8 @@ export function useUserEvents(): void {
};
ws.onerror = () => {
// close handler will trigger reconnect
// close handler will trigger reconnect; best-effort, ignore failure
// because the socket may already be closing
try { ws?.close(); } catch {}
};
};
@@ -50,6 +51,7 @@ export function useUserEvents(): void {
return () => {
unmounted = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
// best-effort cleanup; ignore failure because the socket may already be closed
if (ws) try { ws.close(); } catch {}
};
}, []);

View File

@@ -0,0 +1,339 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { DragEvent } from 'react';
import { toast } from 'sonner';
import type { WorkspacePane } from '@/api/types';
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
export const MAX_PANES = 5;
const STORAGE_KEY = 'boocode.workspace.panes';
function generateId(): string {
return crypto.randomUUID();
}
function emptyPane(): WorkspacePane {
return { id: generateId(), kind: 'empty', chatIds: [], activeChatIdx: -1 };
}
function chatPane(chatId: string): WorkspacePane {
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
}
function loadPanes(sessionId: string): WorkspacePane[] | null {
try {
const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`);
if (!raw) return null;
const parsed = JSON.parse(raw) as WorkspacePane[];
if (!Array.isArray(parsed) || parsed.length === 0) return null;
return parsed;
} catch {
return null;
}
}
function savePanes(sessionId: string, panes: WorkspacePane[]): void {
try {
localStorage.setItem(`${STORAGE_KEY}.${sessionId}`, JSON.stringify(panes));
} catch { /* quota or disabled */ }
}
export interface UseWorkspacePanesResult {
panes: WorkspacePane[];
activePaneIdx: number;
setActivePaneIdx: React.Dispatch<React.SetStateAction<number>>;
activePaneIdxRef: React.MutableRefObject<number>;
openChatInPane: (paneIdx: number, chatId: string) => void;
switchTab: (paneIdx: number, tabIdx: number) => void;
removeTab: (paneIdx: number, chatId: string) => void;
closeOtherTabs: (paneIdx: number, keepChatId: string) => void;
closeTabsToRight: (paneIdx: number, pivotChatId: string) => void;
closeAllTabs: (paneIdx: number) => void;
showLandingPage: (paneIdx: number) => void;
addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => void;
removePane: (idx: number) => void;
removeChatFromPanes: (chatId: string) => void;
initializeFirstChatIfEmpty: (chatId: string) => void;
handlePaneDragStart: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
handlePaneDragOver: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
handlePaneDragLeave: () => void;
handlePaneDrop: (targetIdx: number) => (e: DragEvent<HTMLDivElement>) => void;
handlePaneDragEnd: () => void;
dragOverIdx: number | null;
draggingIdxRef: React.MutableRefObject<number | null>;
}
export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
const [panes, setPanes] = useState<WorkspacePane[]>(() => {
return loadPanes(sessionId) ?? [emptyPane()];
});
const [activePaneIdx, setActivePaneIdx] = useState(0);
const draggingIdxRef = useRef<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
useEffect(() => {
savePanes(sessionId, panes);
}, [sessionId, panes]);
useEffect(() => {
const active = panes[activePaneIdx];
if (!active) {
clearActivePane();
return;
}
setActivePaneInfo({
sessionId,
paneId: active.id,
kind: active.kind,
activeFile: null,
});
}, [sessionId, panes, activePaneIdx]);
useEffect(() => {
return () => {
clearActivePane();
};
}, []);
const activePaneIdxRef = useRef(activePaneIdx);
activePaneIdxRef.current = activePaneIdx;
const openChatInPane = useCallback((paneIdx: number, chatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const existing = pane.chatIds.indexOf(chatId);
if (existing >= 0) {
next[paneIdx] = { ...pane, kind: 'chat', chatId, activeChatIdx: existing };
} else {
const newIds = [...pane.chatIds, chatId];
next[paneIdx] = {
...pane,
kind: 'chat',
chatId,
chatIds: newIds,
activeChatIdx: newIds.length - 1,
};
}
return next;
});
setActivePaneIdx(paneIdx);
}, []);
const switchTab = useCallback((paneIdx: number, tabIdx: number) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const chatId = pane.chatIds[tabIdx];
if (!chatId) return prev;
next[paneIdx] = { ...pane, chatId, activeChatIdx: tabIdx };
return next;
});
}, []);
const removeTab = useCallback((paneIdx: number, chatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const nextIds = pane.chatIds.filter((id) => id !== chatId);
if (nextIds.length === 0) {
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
} else {
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
next[paneIdx] = {
...pane,
chatIds: nextIds,
activeChatIdx: nextActiveIdx,
chatId: nextIds[nextActiveIdx],
};
}
return next;
});
}, []);
// Keep only the right-clicked tab open in this pane.
const closeOtherTabs = useCallback((paneIdx: number, keepChatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const keepIdx = pane.chatIds.indexOf(keepChatId);
if (keepIdx < 0) return prev;
next[paneIdx] = {
...pane,
kind: 'chat',
chatId: keepChatId,
chatIds: [keepChatId],
activeChatIdx: 0,
};
return next;
});
}, []);
// Close every tab to the right of the right-clicked one.
const closeTabsToRight = useCallback((paneIdx: number, pivotChatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const pivotIdx = pane.chatIds.indexOf(pivotChatId);
if (pivotIdx < 0 || pivotIdx === pane.chatIds.length - 1) return prev;
const nextIds = pane.chatIds.slice(0, pivotIdx + 1);
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
next[paneIdx] = {
...pane,
chatIds: nextIds,
activeChatIdx: nextActiveIdx,
chatId: nextIds[nextActiveIdx],
};
return next;
});
}, []);
// Close every tab in this pane; land on landing page.
const closeAllTabs = useCallback((paneIdx: number) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
return next;
});
}, []);
const showLandingPage = useCallback((paneIdx: number) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined };
return next;
});
}, []);
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => {
if (kind === 'terminal') {
toast('Terminal panes coming in BooTerm');
return;
}
if (kind === 'agent') {
toast('Agent panes coming in BooCoder');
return;
}
setPanes((prev) => {
if (prev.length >= MAX_PANES) {
toast.error(`Maximum ${MAX_PANES} panes`);
return prev;
}
const next = [...prev, emptyPane()];
setActivePaneIdx(next.length - 1);
return next;
});
}, []);
const removePane = useCallback((idx: number) => {
setPanes((prev) => {
if (prev.length <= 1) return prev;
const next = prev.filter((_, i) => i !== idx);
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
return next;
});
}, []);
// Replaces a single empty default pane with a chat pane. Used by the initial
// chat fetch to land on the most-recent open chat if no saved pane state.
const initializeFirstChatIfEmpty = useCallback((chatId: string) => {
setPanes((prev) => {
if (prev.length === 1 && prev[0]!.kind === 'empty') {
return [chatPane(chatId)];
}
return prev;
});
}, []);
const removeChatFromPanes = useCallback((chatId: string) => {
setPanes((prev) => prev.map((p) => {
const idx = p.chatIds.indexOf(chatId);
if (idx < 0) return p;
const nextIds = p.chatIds.filter((id) => id !== chatId);
if (nextIds.length === 0) {
return { ...p, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 };
}
const nextActiveIdx = Math.min(p.activeChatIdx, nextIds.length - 1);
return {
...p,
chatIds: nextIds,
activeChatIdx: nextActiveIdx,
chatId: nextIds[nextActiveIdx],
};
}));
}, []);
const handlePaneDragStart = useCallback(
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
draggingIdxRef.current = idx;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(idx));
},
[]
);
const handlePaneDragOver = useCallback(
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
if (draggingIdxRef.current === null) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragOverIdx !== idx) setDragOverIdx(idx);
},
[dragOverIdx]
);
const handlePaneDragLeave = useCallback(() => {
setDragOverIdx(null);
}, []);
const handlePaneDrop = useCallback(
(targetIdx: number) => (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
const fromIdx = draggingIdxRef.current;
draggingIdxRef.current = null;
setDragOverIdx(null);
if (fromIdx === null || fromIdx === targetIdx) return;
setPanes((prev) => {
const next = [...prev];
const [moved] = next.splice(fromIdx, 1);
if (!moved) return prev;
next.splice(targetIdx, 0, moved);
// Keep active selection on the same logical pane (the one being dragged).
setActivePaneIdx(targetIdx);
return next;
});
},
[]
);
const handlePaneDragEnd = useCallback(() => {
draggingIdxRef.current = null;
setDragOverIdx(null);
}, []);
return {
panes,
activePaneIdx,
setActivePaneIdx,
activePaneIdxRef,
openChatInPane,
switchTab,
removeTab,
closeOtherTabs,
closeTabsToRight,
closeAllTabs,
showLandingPage,
addSplitPane,
removePane,
removeChatFromPanes,
initializeFirstChatIfEmpty,
handlePaneDragStart,
handlePaneDragOver,
handlePaneDragLeave,
handlePaneDrop,
handlePaneDragEnd,
dragOverIdx,
draggingIdxRef,
};
}

View File

@@ -1,9 +1,10 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { ChevronLeft } from 'lucide-react';
import { ChevronRight } from 'lucide-react';
import { api } from '@/api/client';
import type { Session as SessionType } from '@/api/types';
import type { Project, Session as SessionType } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
import { useActivePane } from '@/hooks/useActivePane';
import { Workspace } from '@/components/Workspace';
import { ModelPicker } from '@/components/ModelPicker';
@@ -11,12 +12,15 @@ export function Session() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [session, setSession] = useState<SessionType | null>(null);
const [project, setProject] = useState<Project | null>(null);
const [name, setName] = useState('');
const [editingName, setEditingName] = useState(false);
const active = useActivePane();
useEffect(() => {
if (!id) return;
setSession(null);
setProject(null);
let cancelled = false;
api.sessions
.get(id)
@@ -24,18 +28,19 @@ export function Session() {
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,
});
// Load project for breadcrumb. Listing is fine — small N, cached by client.
api.projects.list().then((projects) => {
if (cancelled) return;
const p = projects.find((x) => x.id === s.project_id);
if (p) setProject(p);
}).catch((err) => console.warn('Session: failed to load project for breadcrumb', err));
})
.catch(() => {});
.catch((err) => console.warn('Session: failed to fetch session', err));
return () => {
cancelled = true;
};
@@ -68,26 +73,33 @@ 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);
// Server publishes session_renamed via broker.publishUser; no local emit needed.
}
// Workspace only sets activeFile for file-browser panes; checking it alone
// suffices and is forward-compatible with future pane kinds.
const showActiveFile = active.sessionId === id && !!active.activeFile;
return (
<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">
{session && (
<header className="border-b px-4 py-2 flex items-center gap-1.5 shrink-0 text-sm">
<Link to="/" className="text-muted-foreground hover:text-foreground">
Projects
</Link>
<ChevronRight className="size-3 text-muted-foreground/60" />
{project ? (
<Link
to={`/project/${session.project_id}`}
className="text-muted-foreground hover:text-foreground"
aria-label="Back to project"
to={`/project/${project.id}`}
className="text-muted-foreground hover:text-foreground truncate max-w-[200px]"
title={project.name}
>
<ChevronLeft className="size-4" />
{project.name}
</Link>
) : (
<span className="text-muted-foreground/60"></span>
)}
<ChevronRight className="size-3 text-muted-foreground/60" />
{editingName ? (
<input
autoFocus
@@ -106,21 +118,35 @@ export function Session() {
) : (
<button
type="button"
className="text-sm font-medium hover:underline"
className="text-sm font-medium hover:underline truncate max-w-[280px]"
onClick={() => setEditingName(true)}
title={session?.name ?? ''}
>
{session?.name ?? '…'}
</button>
)}
{showActiveFile && active.activeFile && (
<>
<span className="text-muted-foreground/40 mx-1">·</span>
<span
className="text-xs font-mono text-muted-foreground truncate max-w-[320px]"
title={active.activeFile}
>
{active.activeFile}
</span>
</>
)}
<div className="ml-auto">
{session && (
<ModelPicker
value={session.model}
onChange={async (model) => {
const updated = await api.sessions.update(session.id, { model });
setSession(updated);
}}
/>
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1">
<ModelPicker
value={session.model}
onChange={async (model) => {
const updated = await api.sessions.update(session.id, { model });
setSession(updated);
}}
/>
</div>
)}
</div>
</header>

View File

@@ -9,7 +9,12 @@ services:
environment:
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
volumes:
- /opt:/opt:rw
# Read-only mount for legacy/existing project add-existing flow.
- /opt:/opt:ro
# Writable mount only for the create-new-project bootstrap target.
# Host must `mkdir -p /opt/projects` before container start.
- /opt/projects:/opt/projects:rw
- ./secrets/boocode_gitea:/root/.ssh/id_ed25519:ro
depends_on:
- boocode_db
networks:

322
pnpm-lock.yaml generated
View File

@@ -45,6 +45,9 @@ importers:
typescript:
specifier: ^5.5.0
version: 5.9.3
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.13)(@types/node@20.19.41)(lightningcss@1.32.0)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))
apps/web:
dependencies:
@@ -1697,9 +1700,15 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
@@ -1756,6 +1765,35 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vitest/expect@3.2.4':
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
'@vitest/mocker@3.2.4':
resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==}
peerDependencies:
msw: ^2.4.9
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@3.2.4':
resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
'@vitest/runner@3.2.4':
resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==}
'@vitest/snapshot@3.2.4':
resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==}
'@vitest/spy@3.2.4':
resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
'@vitest/utils@3.2.4':
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
abstract-logging@2.0.1:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
@@ -1809,6 +1847,10 @@ packages:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
ast-types@0.16.1:
resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
engines: {node: '>=4'}
@@ -1863,6 +1905,10 @@ packages:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
@@ -1881,6 +1927,10 @@ packages:
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
chalk@5.6.2:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
@@ -1897,6 +1947,10 @@ packages:
character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
@@ -2021,6 +2075,10 @@ packages:
babel-plugin-macros:
optional: true
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
@@ -2121,6 +2179,9 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
@@ -2154,6 +2215,9 @@ packages:
estree-util-is-identifier-name@3.0.0:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
etag@1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
@@ -2174,6 +2238,10 @@ packages:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
express-rate-limit@8.5.2:
resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==}
engines: {node: '>= 16'}
@@ -2529,6 +2597,9 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
@@ -2653,6 +2724,9 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -2998,6 +3072,13 @@ packages:
path-to-regexp@8.4.2:
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pathval@2.0.1:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -3308,6 +3389,9 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
@@ -3342,6 +3426,9 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
@@ -3350,6 +3437,9 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
stdin-discarder@0.2.2:
resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==}
engines: {node: '>=18'}
@@ -3402,6 +3492,9 @@ packages:
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
engines: {node: '>=18'}
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
style-to-js@1.1.21:
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
@@ -3428,6 +3521,28 @@ packages:
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
tinypool@1.1.1:
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
engines: {node: ^18.0.0 || >=20.0.0}
tinyrainbow@2.0.0:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'}
tinyspy@4.0.4:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'}
tldts-core@7.0.30:
resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==}
@@ -3572,6 +3687,11 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
vite@5.4.21:
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -3603,6 +3723,34 @@ packages:
terser:
optional: true
vitest@3.2.4:
resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/debug': ^4.1.12
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
'@vitest/browser': 3.2.4
'@vitest/ui': 3.2.4
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@types/debug':
optional: true
'@types/node':
optional: true
'@vitest/browser':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
@@ -3617,6 +3765,11 @@ packages:
engines: {node: ^16.13.0 || >=18.0.0}
hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -5196,10 +5349,17 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/debug@4.1.13':
dependencies:
'@types/ms': 2.1.0
'@types/deep-eql@4.0.2': {}
'@types/estree-jsx@1.0.5':
dependencies:
'@types/estree': 1.0.8
@@ -5261,6 +5421,49 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/expect@3.2.4':
dependencies:
'@types/chai': 5.2.3
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.4(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@5.4.21(@types/node@20.19.41)(lightningcss@1.32.0))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.14.6(@types/node@20.19.41)(typescript@5.9.3)
vite: 5.4.21(@types/node@20.19.41)(lightningcss@1.32.0)
'@vitest/pretty-format@3.2.4':
dependencies:
tinyrainbow: 2.0.0
'@vitest/runner@3.2.4':
dependencies:
'@vitest/utils': 3.2.4
pathe: 2.0.3
strip-literal: 3.1.0
'@vitest/snapshot@3.2.4':
dependencies:
'@vitest/pretty-format': 3.2.4
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@3.2.4':
dependencies:
tinyspy: 4.0.4
'@vitest/utils@3.2.4':
dependencies:
'@vitest/pretty-format': 3.2.4
loupe: 3.2.1
tinyrainbow: 2.0.0
abstract-logging@2.0.1: {}
accepts@2.0.0:
@@ -5301,6 +5504,8 @@ snapshots:
dependencies:
tslib: 2.8.1
assertion-error@2.0.1: {}
ast-types@0.16.1:
dependencies:
tslib: 2.8.1
@@ -5360,6 +5565,8 @@ snapshots:
bytes@3.1.2: {}
cac@6.7.14: {}
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@@ -5376,6 +5583,14 @@ snapshots:
ccount@2.0.1: {}
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
check-error: 2.1.3
deep-eql: 5.0.2
loupe: 3.2.1
pathval: 2.0.1
chalk@5.6.2: {}
character-entities-html4@2.1.0: {}
@@ -5386,6 +5601,8 @@ snapshots:
character-reference-invalid@2.0.1: {}
check-error@2.1.3: {}
class-variance-authority@0.7.1:
dependencies:
clsx: 2.1.1
@@ -5474,6 +5691,8 @@ snapshots:
dedent@1.7.2: {}
deep-eql@5.0.2: {}
deepmerge@4.3.1: {}
default-browser-id@5.0.1: {}
@@ -5556,6 +5775,8 @@ snapshots:
es-errors@1.3.0: {}
es-module-lexer@1.7.0: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
@@ -5625,6 +5846,10 @@ snapshots:
estree-util-is-identifier-name@3.0.0: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
etag@1.8.1: {}
eventsource-parser@3.0.8: {}
@@ -5660,6 +5885,8 @@ snapshots:
strip-final-newline: 4.0.0
yoctocolors: 2.1.2
expect-type@1.3.0: {}
express-rate-limit@8.5.2(express@5.2.1):
dependencies:
express: 5.2.1
@@ -6053,6 +6280,8 @@ snapshots:
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
@@ -6149,6 +6378,8 @@ snapshots:
dependencies:
js-tokens: 4.0.0
loupe@3.2.1: {}
lru-cache@10.4.3: {}
lru-cache@5.1.1:
@@ -6700,6 +6931,10 @@ snapshots:
path-to-regexp@8.4.2: {}
pathe@2.0.3: {}
pathval@2.0.1: {}
picocolors@1.1.1: {}
picomatch@2.3.2: {}
@@ -7178,6 +7413,8 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
siginfo@2.0.0: {}
signal-exit@3.0.7: {}
signal-exit@4.1.0: {}
@@ -7201,10 +7438,14 @@ snapshots:
split2@4.2.0: {}
stackback@0.0.2: {}
statuses@2.0.1: {}
statuses@2.0.2: {}
std-env@3.10.0: {}
stdin-discarder@0.2.2: {}
stream-shift@1.0.3: {}
@@ -7258,6 +7499,10 @@ snapshots:
strip-final-newline@4.0.0: {}
strip-literal@3.1.0:
dependencies:
js-tokens: 9.0.1
style-to-js@1.1.21:
dependencies:
style-to-object: 1.0.14
@@ -7280,6 +7525,21 @@ snapshots:
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
tinyglobby@0.2.16:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinypool@1.1.1: {}
tinyrainbow@2.0.0: {}
tinyspy@4.0.4: {}
tldts-core@7.0.30: {}
tldts@7.0.30:
@@ -7419,6 +7679,24 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite-node@3.2.4(@types/node@20.19.41)(lightningcss@1.32.0):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 5.4.21(@types/node@20.19.41)(lightningcss@1.32.0)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
vite@5.4.21(@types/node@20.19.41)(lightningcss@1.32.0):
dependencies:
esbuild: 0.21.5
@@ -7429,6 +7707,45 @@ snapshots:
fsevents: 2.3.3
lightningcss: 1.32.0
vitest@3.2.4(@types/debug@4.1.13)(@types/node@20.19.41)(lightningcss@1.32.0)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3)):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@5.4.21(@types/node@20.19.41)(lightningcss@1.32.0))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
debug: 4.4.3
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 2.0.3
picomatch: 4.0.4
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.16
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 5.4.21(@types/node@20.19.41)(lightningcss@1.32.0)
vite-node: 3.2.4(@types/node@20.19.41)(lightningcss@1.32.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.13
'@types/node': 20.19.41
transitivePeerDependencies:
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
web-streams-polyfill@3.3.3: {}
which@2.0.2:
@@ -7439,6 +7756,11 @@ snapshots:
dependencies:
isexe: 3.1.5
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0