Compare commits

..

4 Commits

Author SHA1 Message Date
e09c67d65c tab-close + chat archive/delete + landing-card buttons + 1000px content cap
Feature 1 — Tab close menu (pure local pane state, no API):
- ChatTabBar context menu: Rename / sep / Close / Close others / Close to right / Close all
- Workspace bulk-tab primitives: closeOtherTabs, closeTabsToRight, closeAllTabs (manipulate panes[].chatIds, no fetch)
- Drop in-bar Delete; landing card's name-typed Delete is the canonical destructive path

Feature 2 — Chat archive + delete:
- chats.status vocabulary aligned with projects ('open' | 'archived'); DROP old inline CHECK, UPDATE 'closed' → 'archived', ADD new named chats_status_chk
- POST /api/chats/:id/archive (204) + POST /api/chats/:id/unarchive (200) + GET /api/sessions/:id/chats?status=archived; DELETE publishes chat_deleted; PATCH simplified to name-only
- 3 new WS frames: chat_archived, chat_unarchived, chat_deleted (renamed from chat_closed)
- Same dedup discipline: server-only publish, no local sessionEvents.emit in client
- SessionLandingPage: right-click ContextMenu (Open / Rename / Archive / sep / Delete-destructive), inline rename, archive confirm dialog, delete dialog with name-typed Input gated until typed text === chat.name, Archived chats collapsible section with Restore
- Card-level Archive + Delete icon buttons reusing the same dialog state setters; stopPropagation on both so card click still opens the chat; archived cards keep only Restore

UX — chat content width cap:
- ChatPane content (MessageList, queue chips, stop button, ChatInput) wrapped in inner max-w-[1000px] mx-auto w-full so messages center; outer border-t / scroll containers stay full-width so pane chrome and backgrounds remain edge-to-edge
- No new deps, no media queries (narrow viewports collapse to width naturally)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 03:21:26 +00:00
48a972e139 project-ux: archive/rename/Open-in-Gitea sidebar context menu, archived projects landing, create-project bootstrap with Gitea remote
Server:
- projects.status + projects.gitea_remote (additive) with CHECK ('open','archived')
- GET /api/projects?status=archived; PATCH /api/projects/:id (rename);
  POST /api/projects/:id/archive | unarchive; POST /api/projects/create
- POST /api/projects ON CONFLICT (path) DO UPDATE SET status='open': re-add
  of archived path restores existing row (preserves id + FKs); already-open
  path returns 409. Detected-repos picker now excludes only status='open'.
- New gitea.ts (createGiteaRepo + GiteaRepoExistsError) and
  project_bootstrap.ts (sanitize name, mkdir under PROJECT_ROOT_WHITELIST,
  git init -b main + first commit with -c user.name/email per-command, optional
  Gitea repo create + remote add + push; all via execFile, no shell).
- 3 new user-stream frames: project_archived, project_unarchived, project_updated.
- sidebar.ts now selects path + gitea_remote and filters status='open'.
- Gitea env added to config.ts (GITEA_BASE_URL, GITEA_USER, GITEA_TOKEN,
  GITEA_SSH_HOST).
- docker-compose.yml /opt mount flipped to rw so create-project can mkdir.
- auto_name.ts gate relaxed from `!== 1` to `< 1` (fires on every turn while
  chat name is empty, not only the first).

Web:
- ProjectSidebar: project rows use proper Radix ContextMenu; items Rename /
  Archive / Open in Gitea. Inline rename, archive confirm dialog.
  Removed obsolete handleRemove + DropdownMenu hack.
- Home: Add-existing + Create-new buttons; collapsible Archived Projects
  section with Restore.
- New CreateProjectModal: name + live folder preview, commit msg, Private/
  Public radio, create-Gitea-remote checkbox, toast on success/warnings.
- New projectUrls.ts giteaUrlFor() — uses gitea_remote when present,
  falls back to convention URL.
- 3 new event types in sessionEvents.ts with idempotent useSidebar handlers.
- SidebarProject extended with path + gitea_remote so Open-in-Gitea can
  resolve without a separate fetch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 02:51:59 +00:00
051f3b96ae batch4.1-5.1: dedup audit, archive 400 fix, sidebar Delete, landing-page enrichment, auto-name tool-call fix
- Fastify global empty-JSON-body parser fixes archive/unarchive/stop 400s
- Removed redundant local sessionEvents.emit at all 5+2 sites with server-side WS publishers; added dedupe guards in useSidebar/Workspace/Project handlers
- Sidebar session right-click adds Delete (destructive) with confirm Dialog
- Session.tsx navigates away on session_deleted/session_archived for the active session
- SessionLandingPage chat rows show message_count, effective_context_tokens, last_message_preview via LATERAL joins on GET /api/sessions/:id/chats
- Workspace.tsx pane drag-to-reorder using native HTML5 events (no new deps)
- CompactCard: Copy toast, Send-to-chat with target chat name, empty-state in share popover, Re-run button
- auto_name.ts: filter count gate and assistant-fetch by content <> '' so tool-call assistant rows don't trip the once-and-only-once guard
- Adds CLAUDE.md and apps/web/src/lib/format.ts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:36:01 +00:00
c35ec65fc4 batch4: chats-in-sessions, force-send, /compact, right-rail file browser
Session 1:N Chat data model with backfill. Workspace switches to client-side
multi-tab pane management. Right-rail file browser with float-over viewer and
click-drag line selection replaces FileBrowserPane. Adds /compact streaming
summarizer (respects compact markers in context builder), force-send (cancels
in-flight, persists partial as 'cancelled', awaits cancellation completion via
deferred Promise + 5s timeout), message queue, stop generation, chat
auto-rename, session archive/unarchive with Closed Sessions section on repo
landing page. CHECK constraints on sessions.status, messages.role,
messages.status with KEEP IN SYNC comments tying to MESSAGE_ROLES /
MESSAGE_STATUSES const arrays. Deletes dead pane routes/hook and the
api.panes.* client block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:39:48 +00:00
48 changed files with 4936 additions and 1117 deletions

106
CLAUDE.md Normal file
View File

@@ -0,0 +1,106 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What is BooCode
Self-hosted single-user developer chat app. AI assistant with read-only file tools (view_file, list_dir, grep, find_files) running against a local llama-swap inference server. Sessions organized by project, with a multi-pane workspace (chat + file browser side by side).
## Commands
```bash
# Development (run in separate terminals)
pnpm dev:server # tsx watch, port 3000
pnpm dev:web # Vite dev server, port 5173 (proxies /api to :3000)
# Build
pnpm build # builds web then server
pnpm -C apps/server build # server only (tsc + copy schema.sql)
pnpm -C apps/web build # web only (vite)
# Type checking (no emit)
npx tsc --noEmit # project references (root)
npx tsc -p apps/web/tsconfig.app.json --noEmit # web app specifically
# IMPORTANT: root tsc --noEmit uses project references and can miss errors
# that the per-app tsconfig catches. Always verify with the per-app command
# when editing web code. The server build (pnpm -C apps/server build) is
# authoritative for server code.
# Production
docker compose build --no-cache boocode && docker compose up -d
```
There are no tests or linters configured.
## Architecture
**Monorepo**: pnpm workspaces with `apps/server` (Fastify + postgres) and `apps/web` (React + Vite).
### Server (`apps/server/src/`)
- **Fastify** with `@fastify/websocket` and `@fastify/static` (serves built frontend)
- **postgres** (porsager/postgres) with tagged-template SQL — no ORM. Schema in `schema.sql`, applied on startup. LSP may false-positive on `sql<Type[]>\`...\`` generics; CLI `tsc` / `pnpm build` is authoritative.
- **Zod** for request validation and config parsing.
Key services:
- **`services/inference.ts`** — Streams LLM responses, executes tool loops (max 5 depth), flushes to DB every 500ms. Publishes `InferenceFrame` events through the broker.
- **`services/broker.ts`** — In-memory pub/sub with two channel types: per-session (message streaming) and per-user (sidebar updates). No persistence; clients reconnect on restart.
- **`services/tools.ts`** — Four read-only file tools exposed as OpenAI function-calling schemas. All file access goes through `path_guard.ts` which resolves against project root.
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
- **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply.
Route registration: all routes registered in `index.ts` via `register*Routes(app, sql, ...)` functions. Routes are in `routes/*.ts`.
### Frontend (`apps/web/src/`)
- **React 18** + React Router v6 + **Tailwind v4** + shadcn/radix-ui primitives.
- **Shiki** for syntax highlighting (async `codeToHtml` in `CodeBlock.tsx` and `FileViewer` in `FileBrowserPane.tsx`).
- Path alias: `@/` maps to `src/`.
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.
- **`api/client.ts`** — Centralized typed fetch wrapper. All endpoints under `api.*` namespace.
### Data flow for chat
1. User sends message → POST `/api/sessions/:id/messages` creates user + assistant (status=streaming) rows
2. `inference.enqueue()` starts async streaming loop
3. LLM deltas published via `broker.publish(sessionId, frame)`
4. Client's `useSessionStream` WS receives frames, `applyFrame` reducer updates message list
5. Tool calls: inference executes tools server-side, publishes tool_call/tool_result frames, loops back to LLM
6. Terminal states (complete/error): DB updated with final content + token counts, `session_updated` frame published on user channel
### 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.
## 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.
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.
## Environment
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt), `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`
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
## Conventions
- `overflowWrap` not `wordWrap` — TypeScript's CSSStyleDeclaration marks `wordWrap` as deprecated (error 6385).
- No app-layer auth. Authelia handles auth at the reverse proxy. All `broker.publishUser`/`subscribeUser` calls use `'default'` as the user key.
- TypeScript strict mode. Both apps share `tsconfig.base.json`.
- Server uses NodeNext module resolution (`.js` extensions in imports).
- Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`).
- shadcn primitives live in `components/ui/`. Don't modify them unless adding a new primitive.
- `inferLanguage()` from `lib/attachments.ts` is the canonical file-extension-to-language map. `CodeBlock.tsx` keeps its own `LANG_MAP` because it also resolves markdown fence names.

View File

@@ -1,31 +0,0 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
declare module 'fastify' {
interface FastifyRequest {
user?: string;
}
}
const PUBLIC_PATHS = new Set<string>(['/api/health']);
export function registerAuth(app: FastifyInstance): void {
app.addHook('onRequest', async (req, reply) => {
if (!req.url.startsWith('/api')) return;
if (PUBLIC_PATHS.has(req.routeOptions.url ?? req.url.split('?')[0]!)) return;
const header = req.headers['remote-user'];
const user = Array.isArray(header) ? header[0] : header;
if (!user || user.trim() === '') {
reply.code(401).send({ error: 'unauthenticated' });
return reply;
}
req.user = user.trim();
});
}
export function requireUser(req: FastifyRequest): string {
if (!req.user) {
throw new Error('user not set on request — auth hook must run first');
}
return req.user;
}

View File

@@ -9,6 +9,10 @@ const ConfigSchema = z.object({
PROJECT_ROOT_WHITELIST: z.string().default('/opt'),
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'),
GITEA_USER: z.string().default('indifferentketchup'),
GITEA_TOKEN: z.string().optional(),
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
});
export type Config = z.infer<typeof ConfigSchema>;

View File

@@ -5,15 +5,15 @@ import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { loadConfig } from './config.js';
import { getSql, applySchema, pingDb, closeDb } from './db.js';
import { registerAuth } from './auth.js';
import { registerProjectRoutes } from './routes/projects.js';
import { registerSessionRoutes } from './routes/sessions.js';
import { registerSettingsRoutes } from './routes/settings.js';
import { registerMessageRoutes } from './routes/messages.js';
import { registerChatRoutes } from './routes/chats.js';
import { registerSidebarRoutes } from './routes/sidebar.js';
import { registerWebSocket } from './routes/ws.js';
import { registerModelRoutes } from './routes/models.js';
import { registerPaneRoutes } from './routes/panes.js';
import { createInferenceRunner } from './services/inference.js';
import { createBroker } from './services/broker.js';
@@ -24,14 +24,28 @@ async function main() {
logger: { level: config.LOG_LEVEL },
});
// Allow empty JSON bodies on POSTs that don't take a body (archive, unarchive, stop, etc.).
// Default Fastify parser throws FST_ERR_CTP_EMPTY_JSON_BODY on empty string.
app.removeContentTypeParser(['application/json']);
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
const str = (body as string) ?? '';
if (str.trim().length === 0) {
done(null, {});
return;
}
try {
done(null, JSON.parse(str));
} catch (err) {
done(err as Error, undefined);
}
});
const sql = getSql(config);
await applySchema(sql);
app.log.info('database schema applied');
await app.register(fastifyWebsocket);
registerAuth(app);
app.get('/api/health', async () => {
const dbOk = await pingDb(sql);
return { status: dbOk ? 'ok' : 'degraded', db: dbOk };
@@ -44,7 +58,7 @@ async function main() {
registerSettingsRoutes(app, sql);
registerModelRoutes(app, config);
registerSidebarRoutes(app, sql);
registerPaneRoutes(app, sql);
registerChatRoutes(app, sql, broker);
const inference = createInferenceRunner(
{
@@ -60,29 +74,39 @@ async function main() {
}
);
registerMessageRoutes(app, sql, {
enqueueInference: (sessionId, assistantId, user) => {
inference.enqueue(sessionId, assistantId, user);
enqueueInference: (sessionId, chatId, assistantId, user) => {
inference.enqueue(sessionId, chatId, assistantId, user);
},
publishUserMessage: (sessionId, userMessageId, content) => {
enqueueCompact: (sessionId, chatId, compactId, user) => {
inference.enqueueCompact(sessionId, chatId, compactId, user);
},
cancelInference: async (sessionId, chatId) => {
return inference.cancel(sessionId, chatId);
},
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
broker.publish(sessionId, {
type: 'message_started',
message_id: userMessageId,
chat_id: chatId,
role: 'user',
});
broker.publish(sessionId, {
type: 'delta',
message_id: userMessageId,
chat_id: chatId,
content,
});
broker.publish(sessionId, {
type: 'message_complete',
message_id: userMessageId,
chat_id: chatId,
});
},
publishMessagesDeleted: (sessionId, messageIds) => {
publishMessagesDeleted: (sessionId, chatId, messageIds) => {
broker.publish(sessionId, {
type: 'messages_deleted',
message_ids: messageIds,
chat_id: chatId,
});
},
});

View File

@@ -0,0 +1,202 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Broker } from '../services/broker.js';
import type { Chat, Message } from '../types/api.js';
const CreateBody = z.object({
name: z.string().min(1).max(200).optional(),
});
const PatchBody = z.object({
name: z.string().min(1).max(200),
});
export function registerChatRoutes(
app: FastifyInstance,
sql: Sql,
broker: Broker
): void {
app.get<{ Params: { id: string }; Querystring: { status?: string } }>(
'/api/sessions/:id/chats',
async (req, reply) => {
const session = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`;
if (session.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
const status = req.query.status === 'archived' ? 'archived' : 'open';
// Enriched list: computed per-chat fields via LATERAL joins.
const rows = await sql<Chat[]>`
SELECT
c.id, c.session_id, c.name, c.status, c.created_at, c.updated_at,
COALESCE(mc.cnt, 0)::int AS message_count,
lp.preview AS last_message_preview,
ec.tokens AS effective_context_tokens
FROM chats c
LEFT JOIN LATERAL (
SELECT COUNT(*) AS cnt FROM messages WHERE chat_id = c.id
) mc ON TRUE
LEFT JOIN LATERAL (
SELECT LEFT(BTRIM(REGEXP_REPLACE(content, E'[\\n\\r]+', ' ', 'g')), 80) AS preview
FROM messages
WHERE chat_id = c.id AND kind = 'message' AND content <> ''
ORDER BY created_at DESC
LIMIT 1
) lp ON TRUE
LEFT JOIN LATERAL (
SELECT ctx_used AS tokens
FROM messages
WHERE chat_id = c.id AND kind = 'message' AND role = 'assistant'
AND status = 'complete' AND ctx_used IS NOT NULL
ORDER BY created_at DESC
LIMIT 1
) ec ON TRUE
WHERE c.session_id = ${req.params.id} AND c.status = ${status}
ORDER BY c.updated_at DESC
`;
return rows;
}
);
app.post<{ Params: { id: string } }>(
'/api/sessions/:id/chats',
async (req, reply) => {
const parsed = CreateBody.safeParse(req.body ?? {});
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const session = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`;
if (session.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
const [chat] = await sql<Chat[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${req.params.id}, ${parsed.data.name ?? null}, 'open')
RETURNING id, session_id, name, status, created_at, updated_at
`;
broker.publishUser('default', {
type: 'chat_created',
chat: chat!,
session_id: req.params.id,
});
reply.code(201);
return chat;
}
);
app.patch<{ Params: { id: string } }>(
'/api/chats/:id',
async (req, reply) => {
const parsed = PatchBody.safeParse(req.body ?? {});
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const rows = await sql<Chat[]>`
UPDATE chats
SET name = ${parsed.data.name},
updated_at = clock_timestamp()
WHERE id = ${req.params.id}
RETURNING id, session_id, name, status, created_at, updated_at
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const chat = rows[0]!;
broker.publishUser('default', {
type: 'chat_updated',
chat_id: chat.id,
session_id: chat.session_id,
name: chat.name,
updated_at: chat.updated_at,
});
return chat;
}
);
app.post<{ Params: { id: string } }>(
'/api/chats/:id/archive',
async (req, reply) => {
const rows = await sql<{ id: string; session_id: string }[]>`
UPDATE chats SET status = 'archived', updated_at = clock_timestamp()
WHERE id = ${req.params.id} AND status = 'open'
RETURNING id, session_id
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'chat not found or already archived' };
}
const row = rows[0]!;
broker.publishUser('default', {
type: 'chat_archived',
chat_id: row.id,
session_id: row.session_id,
});
reply.code(204);
return null;
}
);
app.post<{ Params: { id: string } }>(
'/api/chats/:id/unarchive',
async (req, reply) => {
const rows = await sql<Chat[]>`
UPDATE chats SET status = 'open', updated_at = clock_timestamp()
WHERE id = ${req.params.id} AND status = 'archived'
RETURNING id, session_id, name, status, created_at, updated_at
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'chat not found or not archived' };
}
const chat = rows[0]!;
broker.publishUser('default', { type: 'chat_unarchived', chat });
return chat;
}
);
app.delete<{ Params: { id: string } }>(
'/api/chats/:id',
async (req, reply) => {
const result = await sql<{ id: string; session_id: string }[]>`
DELETE FROM chats WHERE id = ${req.params.id}
RETURNING id, session_id
`;
if (result.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const row = result[0]!;
broker.publishUser('default', {
type: 'chat_deleted',
chat_id: row.id,
session_id: row.session_id,
});
reply.code(204);
return null;
}
);
app.get<{ Params: { id: string } }>(
'/api/chats/:id/messages',
async (req, reply) => {
const chat = await sql`SELECT id FROM chats WHERE id = ${req.params.id}`;
if (chat.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const rows = await sql<Message[]>`
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at
FROM messages
WHERE chat_id = ${req.params.id}
ORDER BY created_at ASC, id ASC
`;
return rows;
}
);
}

View File

@@ -1,21 +1,23 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Message, Session } from '../types/api.js';
import { requireUser } from '../auth.js';
import type { Chat, Message, Session } from '../types/api.js';
const SendBody = z.object({
content: z.string().min(1).max(64_000),
});
interface MessageHandlers {
enqueueInference: (sessionId: string, assistantMessageId: string, user: string) => void;
enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
enqueueCompact: (sessionId: string, chatId: string, compactMessageId: string, user: string) => void;
publishUserMessage: (
sessionId: string,
chatId: string,
userMessageId: string,
content: string
) => void;
publishMessagesDeleted: (sessionId: string, messageIds: string[]) => void;
publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void;
cancelInference: (sessionId: string, chatId: string) => Promise<boolean>;
}
export function registerMessageRoutes(
@@ -32,7 +34,7 @@ export function registerMessageRoutes(
return { error: 'session not found' };
}
const rows = await sql<Message[]>`
SELECT id, session_id, role, content, tool_calls, tool_results, status, last_seq,
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at
FROM messages
WHERE session_id = ${req.params.id}
@@ -43,7 +45,7 @@ export function registerMessageRoutes(
);
app.post<{ Params: { id: string } }>(
'/api/sessions/:id/messages',
'/api/chats/:id/messages',
async (req, reply) => {
const parsed = SendBody.safeParse(req.body);
if (!parsed.success) {
@@ -51,33 +53,39 @@ export function registerMessageRoutes(
return { error: 'invalid body', details: parsed.error.flatten() };
}
const session = await sql<Session[]>`SELECT id FROM sessions WHERE id = ${req.params.id}`;
if (session.length === 0) {
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'session not found' };
return { error: 'chat not found' };
}
const chat = chatRows[0]!;
const sessionId = chat.session_id;
const result = await sql.begin(async (tx) => {
const [userMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, role, content, status, created_at)
VALUES (${req.params.id}, 'user', ${parsed.data.content}, 'complete', clock_timestamp())
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'user', ${parsed.data.content}, 'complete', clock_timestamp())
RETURNING id
`;
const [assistantMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, role, content, status, created_at)
VALUES (${req.params.id}, 'assistant', '', 'streaming', clock_timestamp())
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await tx`UPDATE sessions SET updated_at = NOW() WHERE id = ${req.params.id}`;
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
});
handlers.publishUserMessage(
req.params.id,
sessionId,
chat.id,
result.user_message_id,
parsed.data.content
);
handlers.enqueueInference(req.params.id, result.assistant_message_id, requireUser(req));
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
reply.code(202);
return result;
@@ -85,14 +93,24 @@ export function registerMessageRoutes(
);
app.post<{ Params: { id: string; message_id: string } }>(
'/api/sessions/:id/messages/:message_id/regenerate',
'/api/chats/:id/messages/:message_id/regenerate',
async (req, reply) => {
const { id: sessionId, message_id: targetId } = req.params;
const { id: chatId, message_id: targetId } = req.params;
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${chatId}
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const chat = chatRows[0]!;
const sessionId = chat.session_id;
const target = await sql<{ id: string; role: string; status: string }[]>`
SELECT id, role, status
FROM messages
WHERE session_id = ${sessionId} AND id = ${targetId}
WHERE chat_id = ${chatId} AND id = ${targetId}
`;
if (target.length === 0) {
reply.code(404);
@@ -109,34 +127,141 @@ export function registerMessageRoutes(
}
const { newAssistantId, deletedIds } = await sql.begin(async (tx) => {
// Subquery keeps created_at in postgres at TIMESTAMPTZ µs precision.
// Round-tripping through JS Date loses sub-ms precision and can pull
// earlier rows (e.g. the triggering user message) into the >= bound.
const deletedRows = await tx<{ id: string }[]>`
DELETE FROM messages
WHERE session_id = ${sessionId}
WHERE chat_id = ${chatId}
AND created_at >= (
SELECT created_at FROM messages WHERE id = ${targetId}
)
RETURNING id
`;
const [row] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, role, content, status, created_at)
VALUES (${sessionId}, 'assistant', '', 'streaming', clock_timestamp())
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await tx`UPDATE sessions SET updated_at = NOW() WHERE id = ${sessionId}`;
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
return {
newAssistantId: row!.id,
deletedIds: deletedRows.map((r) => r.id),
};
});
handlers.publishMessagesDeleted(sessionId, deletedIds);
handlers.enqueueInference(sessionId, newAssistantId, requireUser(req));
handlers.publishMessagesDeleted(sessionId, chatId, deletedIds);
handlers.enqueueInference(sessionId, chatId, newAssistantId, 'default');
reply.code(202);
return { assistant_message_id: newAssistantId };
}
);
app.post<{ Params: { id: string } }>(
'/api/chats/:id/compact',
async (req, reply) => {
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const chat = chatRows[0]!;
const sessionId = chat.session_id;
const [compactMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, kind, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'system', '', 'compact', 'streaming', clock_timestamp())
RETURNING id
`;
handlers.enqueueCompact(sessionId, chat.id, compactMsg!.id, 'default');
reply.code(202);
return { compact_message_id: compactMsg!.id };
}
);
app.post<{ Params: { id: string } }>(
'/api/chats/:id/stop',
async (req, reply) => {
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${req.params.id}
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const chat = chatRows[0]!;
const cancelled = await handlers.cancelInference(chat.session_id, chat.id);
if (!cancelled) {
reply.code(409);
return { error: 'no active generation to stop' };
}
reply.code(200);
return { stopped: true };
}
);
app.post<{ Params: { id: string } }>(
'/api/chats/:id/force_send',
async (req, reply) => {
const parsed = SendBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const chat = chatRows[0]!;
const sessionId = chat.session_id;
// Await actual cancellation completion (catch block persists state).
// 5s timeout guards against llama-swap stalls; if hit, proceed anyway.
await Promise.race([
handlers.cancelInference(sessionId, chat.id).then(() => undefined),
new Promise<void>((_, rej) =>
setTimeout(() => rej(new Error('cancel-timeout')), 5000)
),
]).catch((e: Error) => {
if (e.message !== 'cancel-timeout') throw e;
req.log.warn({ chatId: chat.id }, 'cancel timeout exceeded, proceeding with force-send');
});
const result = await sql.begin(async (tx) => {
const [userMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'user', ${parsed.data.content}, 'complete', clock_timestamp())
RETURNING id
`;
const [assistantMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
});
handlers.publishUserMessage(
sessionId,
chat.id,
result.user_message_id,
parsed.data.content
);
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
reply.code(202);
return result;
}
);
}

View File

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

View File

@@ -6,16 +6,32 @@ import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import type { Broker } from '../services/broker.js';
import type { Project, AvailableProject } from '../types/api.js';
import { requireUser } from '../auth.js';
import { resolveProjectRoot, PathScopeError } from '../services/path_guard.js';
import { listDir, viewFile } from '../services/file_ops.js';
import { getProjectFiles } from '../services/file_index.js';
import {
bootstrapProject,
BootstrapNameError,
BootstrapCollisionError,
BootstrapPathError,
} from '../services/project_bootstrap.js';
const AddProjectBody = z.object({
path: z.string().min(1),
name: z.string().min(1).optional(),
});
const PatchProjectBody = z.object({
name: z.string().min(1).max(200),
});
const CreateProjectBody = z.object({
name: z.string().min(1).max(64),
commit_message: z.string().min(1).max(200).optional(),
visibility: z.enum(['private', 'public']).optional(),
create_gitea_remote: z.boolean().optional(),
});
async function isDir(path: string): Promise<boolean> {
try {
const s = await stat(path);
@@ -50,15 +66,83 @@ export function registerProjectRoutes(
config: Config,
broker: Broker
): void {
app.get('/api/projects', async () => {
app.get<{ Querystring: { status?: string } }>('/api/projects', async (req) => {
const status = req.query.status === 'archived' ? 'archived' : 'open';
const rows = await sql<Project[]>`
SELECT id, name, path, added_at, last_session_id
SELECT id, name, path, added_at, last_session_id, status, gitea_remote
FROM projects
WHERE status = ${status}
ORDER BY added_at DESC
`;
return rows;
});
app.post('/api/projects/create', async (req, reply) => {
const parsed = CreateProjectBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const visibility = parsed.data.visibility ?? 'private';
const createRemote = parsed.data.create_gitea_remote ?? true;
const commitMessage = parsed.data.commit_message ?? 'Initial commit';
let bootstrap;
try {
bootstrap = await bootstrapProject(config, app.log, {
name: parsed.data.name,
commitMessage,
visibility,
createGiteaRemote: createRemote,
});
} catch (err) {
if (err instanceof BootstrapNameError) {
reply.code(400);
return { error: `invalid project name: ${err.message}` };
}
if (err instanceof BootstrapCollisionError) {
reply.code(409);
return { error: err.message };
}
if (err instanceof BootstrapPathError) {
reply.code(400);
return { error: err.message };
}
app.log.error({ err }, 'bootstrap failed');
reply.code(500);
return { error: err instanceof Error ? err.message : 'bootstrap failed' };
}
// Insert into projects table only after bootstrap succeeded.
try {
const [row] = await sql<Project[]>`
INSERT INTO projects (name, path, gitea_remote)
VALUES (${parsed.data.name}, ${bootstrap.folder_real_path}, ${bootstrap.gitea_remote_url})
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote
`;
broker.publishUser('default', { type: 'project_created', project: row as unknown as Project });
reply.code(201);
return {
project: row,
bootstrap: {
folder_created: bootstrap.folder_created,
git_initialized: bootstrap.git_initialized,
first_commit: bootstrap.first_commit,
gitea_remote_created: bootstrap.gitea_remote_created,
gitea_pushed: bootstrap.gitea_pushed,
warnings: bootstrap.warnings,
},
};
} catch (err) {
app.log.error({ err, folder: bootstrap.folder_real_path }, 'project insert failed after bootstrap');
reply.code(500);
return {
error: 'project created on disk but DB insert failed',
folder: bootstrap.folder_real_path,
};
}
});
app.post('/api/projects', async (req, reply) => {
const parsed = AddProjectBody.safeParse(req.body);
if (!parsed.success) {
@@ -71,22 +155,88 @@ export function registerProjectRoutes(
return { error: resolved.error };
}
const name = parsed.data.name?.trim() || resolved.name;
try {
const [row] = await sql<Project[]>`
INSERT INTO projects (name, path)
VALUES (${name}, ${resolved.real})
RETURNING id, name, path, added_at, last_session_id
`;
broker.publishUser(requireUser(req), { type: 'project_created', project: row as unknown as Project });
reply.code(201);
return row;
} catch (err) {
if (err instanceof Error && err.message.includes('duplicate key')) {
reply.code(409);
return { error: 'project already exists' };
}
throw err;
// Pre-check the current row (if any) so we can distinguish three cases:
// - no row INSERT fresh, 201, project_created
// - row archived → ON CONFLICT UPDATE flips to 'open', 200, project_unarchived
// - row already open → 409 (true duplicate)
const existing = await sql<{ status: string }[]>`
SELECT status FROM projects WHERE path = ${resolved.real}
`;
if (existing.length > 0 && existing[0]!.status === 'open') {
reply.code(409);
return { error: 'project already exists' };
}
const [row] = await sql<Project[]>`
INSERT INTO projects (name, path)
VALUES (${name}, ${resolved.real})
ON CONFLICT (path) DO UPDATE SET status = 'open'
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote
`;
if (existing.length === 0) {
broker.publishUser('default', { type: 'project_created', project: row as unknown as Project });
reply.code(201);
} else {
// existing.status was 'archived' — row has been restored.
broker.publishUser('default', { type: 'project_unarchived', project: row as unknown as Project });
reply.code(200);
}
return row;
});
app.patch<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => {
const parsed = PatchProjectBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const rows = await sql<Project[]>`
UPDATE projects SET name = ${parsed.data.name}
WHERE id = ${req.params.id}
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'not found' };
}
const project = rows[0]!;
broker.publishUser('default', {
type: 'project_updated',
project_id: project.id,
name: project.name,
});
return project;
});
app.post<{ Params: { id: string } }>('/api/projects/:id/archive', async (req, reply) => {
const result = await sql`
UPDATE projects SET status = 'archived'
WHERE id = ${req.params.id} AND status = 'open'
`;
if (result.count === 0) {
reply.code(404);
return { error: 'not found or already archived' };
}
broker.publishUser('default', { type: 'project_archived', project_id: req.params.id });
reply.code(204);
return null;
});
app.post<{ Params: { id: string } }>('/api/projects/:id/unarchive', async (req, reply) => {
const rows = await sql<Project[]>`
UPDATE projects SET status = 'open'
WHERE id = ${req.params.id} AND status = 'archived'
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'not found or not archived' };
}
const project = rows[0]!;
broker.publishUser('default', { type: 'project_unarchived', project });
return project;
});
app.delete<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => {
@@ -96,7 +246,7 @@ export function registerProjectRoutes(
reply.code(404);
return { error: 'not found' };
}
broker.publishUser(requireUser(req), { type: 'project_deleted', project_id: id });
broker.publishUser('default', { type: 'project_deleted', project_id: id });
reply.code(204);
return null;
});
@@ -110,7 +260,12 @@ export function registerProjectRoutes(
return [] as AvailableProject[];
}
const existing = await sql<{ path: string }[]>`SELECT path FROM projects`;
// Only exclude paths registered with status='open'. Archived projects'
// folders should reappear as available so re-add via the picker restores
// the existing row (see POST /api/projects ON CONFLICT below).
const existing = await sql<{ path: string }[]>`
SELECT path FROM projects WHERE status = 'open'
`;
const existingSet = new Set(existing.map((r) => r.path));
const out: AvailableProject[] = [];
@@ -144,7 +299,7 @@ export function registerProjectRoutes(
const relPath = req.query.path ?? '.';
const rows = await sql<Project[]>`
SELECT id, name, path, added_at, last_session_id
SELECT id, name, path, added_at, last_session_id, status, gitea_remote
FROM projects WHERE id = ${id}
`;
if (rows.length === 0) {
@@ -189,7 +344,7 @@ export function registerProjectRoutes(
}
const rows = await sql<Project[]>`
SELECT id, name, path, added_at, last_session_id
SELECT id, name, path, added_at, last_session_id, status, gitea_remote
FROM projects WHERE id = ${id}
`;
if (rows.length === 0) {
@@ -233,7 +388,7 @@ export function registerProjectRoutes(
const { id } = req.params;
const rows = await sql<Project[]>`
SELECT id, name, path, added_at, last_session_id
SELECT id, name, path, added_at, last_session_id, status, gitea_remote
FROM projects WHERE id = ${id}
`;
if (rows.length === 0) {

View File

@@ -5,7 +5,6 @@ import type { Config } from '../config.js';
import type { Broker } from '../services/broker.js';
import type { Session } from '../types/api.js';
import { getSetting } from './settings.js';
import { requireUser } from '../auth.js';
const CreateBody = z.object({
name: z.string().min(1).max(200).optional(),
@@ -31,7 +30,7 @@ export function registerSessionRoutes(
config: Config,
broker: Broker
): void {
app.get<{ Params: { id: string } }>(
app.get<{ Params: { id: string }; Querystring: { status?: string } }>(
'/api/projects/:id/sessions',
async (req, reply) => {
const project = await sql`SELECT id FROM projects WHERE id = ${req.params.id}`;
@@ -39,10 +38,11 @@ export function registerSessionRoutes(
reply.code(404);
return { error: 'project not found' };
}
const status = req.query.status === 'archived' ? 'archived' : 'open';
const rows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, created_at, updated_at
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at
FROM sessions
WHERE project_id = ${req.params.id}
WHERE project_id = ${req.params.id} AND status = ${status}
ORDER BY updated_at DESC
`;
return rows;
@@ -81,15 +81,15 @@ export function registerSessionRoutes(
const [session] = await tx<Session[]>`
INSERT INTO sessions (project_id, name, model, system_prompt)
VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt})
RETURNING id, project_id, name, model, system_prompt, created_at, updated_at
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at
`;
await tx`
INSERT INTO session_panes (session_id, position, kind, state)
VALUES (${session!.id}, 0, 'chat', '{}'::jsonb)
INSERT INTO chats (session_id, name, status)
VALUES (${session!.id}, NULL, 'open')
`;
return session!;
});
broker.publishUser(requireUser(req), {
broker.publishUser('default', {
type: 'session_created',
session: row,
project_id: row.project_id,
@@ -101,7 +101,7 @@ export function registerSessionRoutes(
app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
const rows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, created_at, updated_at
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at
FROM sessions WHERE id = ${req.params.id}
`;
if (rows.length === 0) {
@@ -128,7 +128,7 @@ export function registerSessionRoutes(
system_prompt = COALESCE(${system_prompt ?? null}, system_prompt),
updated_at = clock_timestamp()
WHERE id = ${req.params.id}
RETURNING id, project_id, name, model, system_prompt, created_at, updated_at
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at
`;
if (rows.length === 0) {
reply.code(404);
@@ -138,6 +138,51 @@ export function registerSessionRoutes(
}
);
app.post<{ Params: { id: string } }>(
'/api/sessions/:id/archive',
async (req, reply) => {
const rows = await sql<{ id: string; project_id: string }[]>`
UPDATE sessions SET status = 'archived', updated_at = clock_timestamp()
WHERE id = ${req.params.id} AND status = 'open'
RETURNING id, project_id
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'session not found or already archived' };
}
broker.publishUser('default', {
type: 'session_archived',
session_id: rows[0]!.id,
project_id: rows[0]!.project_id,
});
reply.code(204);
return null;
}
);
app.post<{ Params: { id: string } }>(
'/api/sessions/:id/unarchive',
async (req, reply) => {
const rows = await sql<Session[]>`
UPDATE sessions SET status = 'open', updated_at = clock_timestamp()
WHERE id = ${req.params.id} AND status = 'archived'
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'session not found or not archived' };
}
const session = rows[0]!;
broker.publishUser('default', {
type: 'session_created',
session: session,
project_id: session.project_id,
});
reply.code(200);
return session;
}
);
app.delete<{ Params: { id: string } }>(
'/api/sessions/:id',
async (req, reply) => {
@@ -150,7 +195,7 @@ export function registerSessionRoutes(
return { error: 'not found' };
}
const project_id = deleted[0]!.project_id;
broker.publishUser(requireUser(req), { type: 'session_deleted', session_id: id, project_id });
broker.publishUser('default', { type: 'session_deleted', session_id: id, project_id });
reply.code(204);
return null;
}

View File

@@ -8,9 +8,10 @@ import type {
export function registerSidebarRoutes(app: FastifyInstance, sql: Sql): void {
app.get('/api/sidebar', async (): Promise<SidebarResponse> => {
const projects = await sql<{ id: string; name: string }[]>`
SELECT id, name
const projects = await sql<{ id: string; name: string; path: string; gitea_remote: string | null }[]>`
SELECT id, name, path, gitea_remote
FROM projects
WHERE status = 'open'
ORDER BY added_at DESC
`;
@@ -20,19 +21,21 @@ export function registerSidebarRoutes(app: FastifyInstance, sql: Sql): void {
sql<SidebarSession[]>`
SELECT id, project_id, name, model, updated_at
FROM sessions
WHERE project_id = ${p.id}
WHERE project_id = ${p.id} AND status = 'open'
ORDER BY updated_at DESC
LIMIT 6
`,
sql<{ n: number }[]>`
SELECT COUNT(*)::int AS n
FROM sessions
WHERE project_id = ${p.id}
WHERE project_id = ${p.id} AND status = 'open'
`,
]);
return {
id: p.id,
name: p.name,
path: p.path,
gitea_remote: p.gitea_remote,
recent_sessions,
total_sessions: countRows[0]?.n ?? 0,
};

View File

@@ -22,7 +22,7 @@ export function registerWebSocket(
}
const messages = await sql<Message[]>`
SELECT id, session_id, role, content, tool_calls, tool_results, status, last_seq,
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at
FROM messages
WHERE session_id = ${sessionId}
@@ -44,15 +44,8 @@ export function registerWebSocket(
}
);
app.get('/api/ws/user', { websocket: true }, async (socket, req) => {
const user = req.user;
// defensive: global auth hook (auth.ts) already rejects unauthenticated /api/* requests;
// keep the explicit check here to close the WS cleanly (1008) rather than throwing.
if (!user) {
socket.close(1008, 'unauthenticated');
return;
}
// No snapshot — user channel is purely live updates.
app.get('/api/ws/user', { websocket: true }, async (socket) => {
const user = 'default';
const unsubscribe = broker.subscribeUser(user, (frame) => {
if (socket.readyState !== socket.OPEN) return;
try {

View File

@@ -21,11 +21,11 @@ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id, updated_
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'tool')),
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
tool_calls JSONB,
tool_results JSONB,
status TEXT NOT NULL DEFAULT 'complete' CHECK (status IN ('streaming', 'complete', 'failed')),
status TEXT NOT NULL DEFAULT 'complete',
last_seq INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -47,6 +47,8 @@ CREATE TABLE IF NOT EXISTS settings (
INSERT INTO settings (key, value) VALUES ('default_model', '"qwen3.6-35b-a3b-mxfp4"') ON CONFLICT (key) DO NOTHING;
-- DEPRECATED: client-side pane state as of v1.2-batch4. Table retained per
-- additive schema rule; no writes. Drop in a future destructive migration.
CREATE TABLE IF NOT EXISTS session_panes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
@@ -66,3 +68,93 @@ FROM sessions s
WHERE NOT EXISTS (
SELECT 1 FROM session_panes p WHERE p.session_id = s.id
);
-- v1.2: sessions.status (open | archived)
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
-- v1.2: chats table
CREATE TABLE IF NOT EXISTS chats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
name TEXT,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'archived')),
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS idx_chats_session_status ON chats (session_id, status, updated_at DESC);
-- v1.2: messages.chat_id + messages.kind
ALTER TABLE messages ADD COLUMN IF NOT EXISTS chat_id UUID REFERENCES chats(id) ON DELETE CASCADE;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'message';
CREATE INDEX IF NOT EXISTS idx_messages_chat ON messages (chat_id, created_at);
-- Backfill: one chat per existing session that has none yet
INSERT INTO chats (session_id, name, status, created_at, updated_at)
SELECT s.id, s.name, 'open', s.created_at, s.updated_at
FROM sessions s
WHERE NOT EXISTS (
SELECT 1 FROM chats c WHERE c.session_id = s.id
);
-- Backfill: link orphaned messages to their session's first chat
UPDATE messages SET chat_id = (
SELECT c.id FROM chats c WHERE c.session_id = messages.session_id ORDER BY c.created_at ASC LIMIT 1
)
WHERE chat_id IS NULL;
-- Enforce NOT NULL on chat_id once all rows are backfilled
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'messages' AND column_name = 'chat_id' AND is_nullable = 'YES'
) AND NOT EXISTS (
SELECT 1 FROM messages WHERE chat_id IS NULL
) THEN
ALTER TABLE messages ALTER COLUMN chat_id SET NOT NULL;
END IF;
END $$;
-- v1.2.1: CHECK constraints for sessions.status and messages (role, status)
-- KEEP IN SYNC: apps/server/src/types/api.ts (MESSAGE_ROLES, MESSAGE_STATUSES, SessionStatus)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'sessions_status_chk') THEN
ALTER TABLE sessions ADD CONSTRAINT sessions_status_chk
CHECK (status IN ('open', 'archived'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_role_chk') THEN
ALTER TABLE messages ADD CONSTRAINT messages_role_chk
CHECK (role IN ('user', 'assistant', 'system', 'tool'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_status_chk') THEN
ALTER TABLE messages ADD CONSTRAINT messages_status_chk
CHECK (status IN ('streaming', 'complete', 'failed', 'cancelled'));
END IF;
END $$;
-- v1.2-project-ux: projects.status + projects.gitea_remote
-- KEEP IN SYNC: apps/server/src/types/api.ts PROJECT_STATUSES
ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS gitea_remote TEXT;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'projects_status_chk') THEN
ALTER TABLE projects ADD CONSTRAINT projects_status_chk
CHECK (status IN ('open', 'archived'));
END IF;
END $$;
-- v1.3-tab-close-chat-archive: align chats.status vocabulary with projects ('archived' not 'closed')
-- KEEP IN SYNC: apps/server/src/types/api.ts CHAT_STATUSES
-- Order matters: (1) drop the OLD inline CHECK that only allowed ('open','closed');
-- (2) migrate existing rows; (3) add new named CHECK allowing ('open','archived').
ALTER TABLE chats DROP CONSTRAINT IF EXISTS chats_status_check;
UPDATE chats SET status = 'archived' WHERE status = 'closed';
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chats_status_chk') THEN
ALTER TABLE chats ADD CONSTRAINT chats_status_chk
CHECK (status IN ('open', 'archived'));
END IF;
END $$;

View File

@@ -5,31 +5,12 @@ const NAMING_SYSTEM_PROMPT =
const MAX_TITLE_CHARS = 60;
// QWEN3 NON-STREAMING UTILITY-CALL PATTERN
// ----------------------------------------
// Qwen3-family chat templates default to chain-of-thought reasoning: the
// model emits a long <think>…</think> block into `reasoning_content` and
// only finalizes a real reply in `content`. For short utility calls
// (naming, classification, routing, summarization) with a tight token
// budget, the model burns the entire budget on reasoning and returns:
// - content: ""
// - reasoning_content: "Thinking Process: 1. ..." (mid-thought, truncated)
// - finish_reason: "length"
// Fix: pass `chat_template_kwargs: { enable_thinking: false }` to skip the
// thinking block, and keep `max_tokens` low (~30 is plenty for a 4-word
// title). The kwarg is a no-op for non-Qwen chat templates, so it's safe
// to apply unconditionally for any short non-streaming model call.
// Apply this same pattern to: fork-message (planned), agent-routing
// (planned), web-search summarization (planned).
function cleanTitle(raw: string): string {
let name = raw.trim();
// Strip surrounding straight or smart quotes (one layer).
const quotes = ['"', "'", '`', '', '', '“', '”'];
while (name.length >= 2 && quotes.includes(name[0]!) && quotes.includes(name[name.length - 1]!)) {
name = name.slice(1, -1).trim();
}
// Drop a leading "Title:" prefix if the model added one despite instructions.
name = name.replace(/^title\s*:\s*/i, '').trim();
if (name.length > MAX_TITLE_CHARS) {
name = name.slice(0, MAX_TITLE_CHARS).trim();
@@ -46,13 +27,10 @@ interface NamingResponse {
}>;
}
// Some Qwen-family models emit "thinking" tokens into reasoning_content and
// only finalize a real reply in content. Pull a sensible candidate string.
function pickTitleSource(data: NamingResponse): string {
const choice = data.choices?.[0]?.message;
if (!choice) return '';
if (choice.content && choice.content.trim().length > 0) return choice.content;
// Fallback: try to extract a last-line title from reasoning, if present.
const reasoning = choice.reasoning_content ?? '';
if (reasoning.length === 0) return '';
const lines = reasoning
@@ -62,40 +40,48 @@ function pickTitleSource(data: NamingResponse): string {
return lines[lines.length - 1] ?? '';
}
export async function maybeAutoNameSession(
export async function maybeAutoNameChat(
ctx: InferenceContext,
chatId: string,
sessionId: string
): Promise<void> {
const counts = await ctx.sql<{ n: number }[]>`
SELECT COUNT(*)::int AS n
FROM messages
WHERE session_id = ${sessionId}
WHERE chat_id = ${chatId}
AND role = 'assistant'
AND status = 'complete'
AND content <> ''
`;
if (counts[0]?.n !== 1) return;
if ((counts[0]?.n ?? 0) < 1) return;
const sessionRows = await ctx.sql<
{ id: string; name: string; model: string }[]
const chatRows = await ctx.sql<
{ id: string; name: string | null; session_id: string }[]
>`
SELECT id, name, model FROM sessions WHERE id = ${sessionId}
SELECT id, name, session_id FROM chats WHERE id = ${chatId}
`;
const session = sessionRows[0];
if (!session) return;
const existingName = session.name ?? '';
if (existingName !== '' && existingName !== 'New session') return;
const chat = chatRows[0];
if (!chat) return;
if (chat.name !== null && chat.name !== '') return;
const sessionRows = await ctx.sql<{ model: string }[]>`
SELECT model FROM sessions WHERE id = ${sessionId}
`;
const model = sessionRows[0]?.model;
if (!model) return;
const userMsg = await ctx.sql<{ content: string }[]>`
SELECT content FROM messages
WHERE session_id = ${sessionId} AND role = 'user'
WHERE chat_id = ${chatId} AND role = 'user'
ORDER BY created_at ASC
LIMIT 1
`;
const assistantMsg = await ctx.sql<{ content: string }[]>`
SELECT content FROM messages
WHERE session_id = ${sessionId}
WHERE chat_id = ${chatId}
AND role = 'assistant'
AND status = 'complete'
AND content <> ''
ORDER BY created_at ASC
LIMIT 1
`;
@@ -105,7 +91,7 @@ export async function maybeAutoNameSession(
const assistantText = assistantMsg[0].content.slice(0, 2000);
const body = {
model: session.model,
model,
messages: [
{ role: 'system', content: NAMING_SYSTEM_PROMPT },
{
@@ -116,9 +102,6 @@ export async function maybeAutoNameSession(
max_tokens: 30,
temperature: 0.3,
stream: false,
// Qwen-family models default to chain-of-thought; this template kwarg
// tells llama.cpp's chat template renderer to skip the thinking block.
// Harmless for non-Qwen models.
chat_template_kwargs: { enable_thinking: false },
};
@@ -135,23 +118,30 @@ export async function maybeAutoNameSession(
const raw = pickTitleSource(data);
const name = cleanTitle(raw);
if (!name) {
ctx.log.warn({ sessionId, raw }, 'auto-name: empty title from model');
ctx.log.warn({ chatId, raw }, 'auto-name: empty title from model');
return;
}
const updated = await ctx.sql<{ id: string; name: string }[]>`
UPDATE sessions
SET name = ${name}, updated_at = NOW()
WHERE id = ${sessionId}
AND (name IS NULL OR name = '' OR name = 'New session')
RETURNING id, name
const updated = await ctx.sql<{ id: string; name: string; session_id: string; updated_at: string }[]>`
UPDATE chats
SET name = ${name}, updated_at = clock_timestamp()
WHERE id = ${chatId}
AND (name IS NULL OR name = '')
RETURNING id, name, session_id, updated_at
`;
if (updated.length === 0) return;
ctx.publish(sessionId, {
type: 'session_renamed',
session_id: sessionId,
type: 'chat_renamed',
chat_id: chatId,
name,
});
ctx.log.info({ sessionId, name }, 'session auto-named');
ctx.publishUser({
type: 'chat_updated',
chat_id: chatId,
session_id: sessionId,
name,
updated_at: updated[0]!.updated_at,
});
ctx.log.info({ chatId, name }, 'chat auto-named');
}

View File

@@ -0,0 +1,50 @@
export interface GiteaConfig {
baseUrl: string;
user: string;
token: string;
}
export interface GiteaRepo {
clone_url: string;
ssh_url: string;
html_url: string;
}
export class GiteaRepoExistsError extends Error {
constructor() {
super('gitea-repo-exists');
}
}
export async function createGiteaRepo(
cfg: GiteaConfig,
name: string,
options: { private: boolean }
): Promise<GiteaRepo> {
const res = await fetch(`${cfg.baseUrl}/api/v1/user/repos`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `token ${cfg.token}`,
},
body: JSON.stringify({
name,
private: options.private,
auto_init: false,
}),
});
if (res.status === 409) throw new GiteaRepoExistsError();
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`gitea-api-${res.status}: ${text.slice(0, 200)}`);
}
const body = (await res.json()) as { ssh_url?: string; clone_url?: string; html_url?: string };
if (!body.ssh_url || !body.html_url || !body.clone_url) {
throw new Error(`gitea-api-unexpected-shape: ${JSON.stringify(body).slice(0, 200)}`);
}
return {
ssh_url: body.ssh_url,
clone_url: body.clone_url,
html_url: body.html_url,
};
}

View File

@@ -4,7 +4,7 @@ import type { Config } from '../config.js';
import type { Message, Project, Session, ToolCall, UserStreamFrame } from '../types/api.js';
import { ALL_TOOLS, TOOLS_BY_NAME, toolJsonSchemas } from './tools.js';
import { PathScopeError, resolveProjectRoot } from './path_guard.js';
import { maybeAutoNameSession } from './auto_name.js';
import { maybeAutoNameChat } from './auto_name.js';
const BASE_SYSTEM_PROMPT = (projectPath: string) =>
`You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`;
@@ -21,9 +21,11 @@ export interface InferenceFrame {
| 'message_complete'
| 'messages_deleted'
| 'session_renamed'
| 'chat_renamed'
| 'error';
message_id?: string;
message_ids?: string[];
chat_id?: string;
tool_message_id?: string;
tool_call_id?: string;
role?: 'assistant' | 'tool' | 'user';
@@ -101,8 +103,23 @@ export function buildMessagesPayload(
}
out.push({ role: 'system', content: systemPrompt });
for (const m of history) {
// Find the latest compact marker — only send messages from that point onwards
let startIdx = 0;
for (let i = history.length - 1; i >= 0; i--) {
if (history[i]!.kind === 'compact') {
startIdx = i;
break;
}
}
for (let i = startIdx; i < history.length; i++) {
const m = history[i]!;
if (m.kind === 'compact') {
out.push({ role: 'system', content: m.content });
continue;
}
if (m.role === 'assistant' && m.status === 'streaming') continue;
if (m.role === 'assistant' && m.status === 'cancelled') continue;
if (m.role === 'tool') {
const tr = m.tool_results;
if (!tr) continue;
@@ -140,10 +157,11 @@ export function buildMessagesPayload(
async function loadContext(
sql: Sql,
sessionId: string
sessionId: string,
chatId: string
): Promise<{ session: Session; project: Project; history: Message[] } | null> {
const sessionRows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, created_at, updated_at
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at
FROM sessions WHERE id = ${sessionId}
`;
if (sessionRows.length === 0) return null;
@@ -157,10 +175,10 @@ async function loadContext(
const project = projectRows[0]!;
const history = await sql<Message[]>`
SELECT id, session_id, role, content, tool_calls, tool_results, status, last_seq,
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at
FROM messages
WHERE session_id = ${sessionId}
WHERE chat_id = ${chatId}
ORDER BY created_at ASC, id ASC
`;
@@ -204,7 +222,8 @@ async function streamCompletion(
model: string,
messages: OpenAiMessage[],
includeTools: boolean,
onDelta: (content: string) => void
onDelta: (content: string) => void,
signal?: AbortSignal
): Promise<StreamResult> {
const body: Record<string, unknown> = {
model,
@@ -221,6 +240,7 @@ async function streamCompletion(
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal,
});
if (!res.ok || !res.body) {
const text = await res.text().catch(() => '');
@@ -331,8 +351,10 @@ async function executeToolCall(
async function runAssistantTurn(
ctx: InferenceContext,
sessionId: string,
chatId: string,
assistantMessageId: string,
depth: number
depth: number,
signal?: AbortSignal
): Promise<void> {
if (depth > MAX_TOOL_LOOP_DEPTH) {
await ctx.sql`
@@ -345,12 +367,13 @@ async function runAssistantTurn(
ctx.publish(sessionId, {
type: 'error',
message_id: assistantMessageId,
chat_id: chatId,
error: 'tool loop depth exceeded',
});
return;
}
const loaded = await loadContext(ctx.sql, sessionId);
const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (!loaded) {
ctx.log.warn({ sessionId }, 'inference: session or project missing');
return;
@@ -370,6 +393,7 @@ async function runAssistantTurn(
ctx.publish(sessionId, {
type: 'message_started',
message_id: assistantMessageId,
chat_id: chatId,
role: 'assistant',
});
@@ -408,21 +432,25 @@ async function runAssistantTurn(
ctx.publish(sessionId, {
type: 'delta',
message_id: assistantMessageId,
chat_id: chatId,
content: delta,
});
ctx.log.debug({ sessionId, delta }, 'inference delta');
scheduleFlush();
}
},
signal
);
} catch (err) {
if (pendingFlushTimer) {
clearTimeout(pendingFlushTimer);
pendingFlushTimer = null;
}
const errMsg = err instanceof Error ? err.message : String(err);
await flushPromise;
const isAbort = err instanceof Error && err.name === 'AbortError';
const finalStatus = isAbort ? 'cancelled' : 'failed';
await ctx.sql`
UPDATE messages
SET status = 'failed',
SET status = ${finalStatus},
content = ${accumulated},
finished_at = clock_timestamp()
WHERE id = ${assistantMessageId}
@@ -433,12 +461,23 @@ async function runAssistantTurn(
RETURNING project_id, name, updated_at
`;
ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: failSessRow!.project_id, name: failSessRow!.name, updated_at: failSessRow!.updated_at });
ctx.publish(sessionId, {
type: 'error',
message_id: assistantMessageId,
error: errMsg,
});
ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed');
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;
}
@@ -475,12 +514,14 @@ async function runAssistantTurn(
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,
@@ -492,8 +533,8 @@ async function runAssistantTurn(
await Promise.all(
toolCalls.map(async (tc) => {
const [toolRow] = await ctx.sql<{ id: string }[]>`
INSERT INTO messages (session_id, role, content, status, created_at)
VALUES (${sessionId}, 'tool', '', 'complete', clock_timestamp())
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;
@@ -512,6 +553,7 @@ async function runAssistantTurn(
ctx.publish(sessionId, {
type: 'tool_result',
tool_message_id: toolMessageId,
chat_id: chatId,
tool_call_id: tc.id,
output: tres.output,
truncated: tres.truncated,
@@ -521,11 +563,11 @@ async function runAssistantTurn(
);
const [nextAssistant] = await ctx.sql<{ id: string }[]>`
INSERT INTO messages (session_id, role, content, status, created_at)
VALUES (${sessionId}, 'assistant', '', 'streaming', clock_timestamp())
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, nextAssistant!.id, depth + 1);
await runAssistantTurn(ctx, sessionId, chatId, nextAssistant!.id, depth + 1, signal);
return;
}
@@ -551,6 +593,7 @@ async function runAssistantTurn(
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,
@@ -561,6 +604,7 @@ async function runAssistantTurn(
ctx.log.info(
{
sessionId,
chatId,
assistantMessageId,
finishReason,
chars: content.length,
@@ -574,36 +618,153 @@ async function runAssistantTurn(
export async function runInference(
ctx: InferenceContext,
sessionId: string,
assistantMessageId: string
chatId: string,
assistantMessageId: string,
signal?: AbortSignal
): Promise<void> {
return runAssistantTurn(ctx, sessionId, assistantMessageId, 0);
return runAssistantTurn(ctx, sessionId, chatId, assistantMessageId, 0, signal);
}
const COMPACT_SYSTEM_PROMPT =
'Summarize the preceding conversation into a dense but complete context paragraph. Preserve all key facts, decisions, file paths, code patterns, and action items. Do not add any new information. Output only the summary paragraph.';
async function runCompact(
ctx: InferenceContext,
sessionId: string,
chatId: string,
compactMessageId: string
): Promise<void> {
const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (!loaded) return;
const { session, project, history } = loaded;
const messagesForSummary = buildMessagesPayload(session, project,
history.filter((m) => m.id !== compactMessageId)
);
messagesForSummary.push({
role: 'system',
content: COMPACT_SYSTEM_PROMPT,
});
ctx.publish(sessionId, {
type: 'message_started',
message_id: compactMessageId,
chat_id: chatId,
role: 'assistant',
});
let content = '';
try {
const result = await streamCompletion(
ctx,
session.model,
messagesForSummary,
false,
(delta) => {
content += delta;
ctx.publish(sessionId, {
type: 'delta',
message_id: compactMessageId,
chat_id: chatId,
content: delta,
});
}
);
content = result.content;
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
await ctx.sql`
UPDATE messages SET status = 'failed', content = ${content}, finished_at = clock_timestamp()
WHERE id = ${compactMessageId}
`;
ctx.publish(sessionId, {
type: 'error',
message_id: compactMessageId,
chat_id: chatId,
error: errMsg,
});
return;
}
const preCompactCount = history.filter((m) => m.id !== compactMessageId && m.kind !== 'compact').length;
const summary = `[Context compacted — ${preCompactCount} messages summarized]\n\n${content}`;
await ctx.sql`
UPDATE messages SET content = ${summary}, status = 'complete', finished_at = clock_timestamp()
WHERE id = ${compactMessageId}
`;
ctx.publish(sessionId, {
type: 'message_complete',
message_id: compactMessageId,
chat_id: chatId,
});
}
interface InferenceRegistration {
controller: AbortController;
completed: Promise<void>;
}
export function createInferenceRunner(
ctx: Omit<InferenceContext, 'publishUser'>,
publishUserFn: (user: string, frame: UserStreamFrame) => void
) {
const registry = new Map<string, InferenceRegistration>();
return {
enqueue(sessionId: string, assistantMessageId: string, user: string) {
enqueue(sessionId: string, chatId: string, assistantMessageId: string, user: string) {
const callCtx: InferenceContext = {
...ctx,
publishUser: (frame) => publishUserFn(user, frame),
};
const controller = new AbortController();
let resolveCompleted!: () => void;
const completed = new Promise<void>((res) => { resolveCompleted = res; });
const registration: InferenceRegistration = { controller, completed };
registry.set(chatId, registration);
void (async () => {
try {
await runInference(callCtx, sessionId, chatId, assistantMessageId, controller.signal);
setImmediate(() => {
void maybeAutoNameChat(callCtx, chatId, sessionId).catch((err: Error) => {
callCtx.log.warn({ err, chatId }, 'auto-name failed');
});
});
} catch (err) {
callCtx.log.error({ err }, 'unhandled inference error');
} finally {
resolveCompleted();
// Only clear our own registration; a force-send may have replaced it.
if (registry.get(chatId) === registration) {
registry.delete(chatId);
}
}
})();
},
enqueueCompact(sessionId: string, chatId: string, compactMessageId: string, user: string) {
const callCtx: InferenceContext = {
...ctx,
publishUser: (frame) => publishUserFn(user, frame),
};
void (async () => {
try {
await runInference(callCtx, sessionId, assistantMessageId);
setImmediate(() => {
void maybeAutoNameSession(callCtx, sessionId).catch((err) => {
callCtx.log.warn({ err, sessionId }, 'auto-name failed');
});
});
await runCompact(callCtx, sessionId, chatId, compactMessageId);
} catch (err) {
callCtx.log.error({ err }, 'unhandled inference error');
callCtx.log.error({ err }, 'unhandled compact error');
}
})();
},
async cancel(_sessionId: string, chatId: string): Promise<boolean> {
const reg = registry.get(chatId);
if (!reg) return false;
reg.controller.abort();
// Swallow — we just need to wait for the catch/finally to persist state.
await reg.completed.catch(() => {});
return true;
},
};
}
// Reference to keep ALL_TOOLS imported for type checks if needed
export const _toolNames = ALL_TOOLS.map((t) => t.name);

View File

@@ -0,0 +1,179 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { mkdir, writeFile, realpath } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { resolve, sep } from 'node:path';
import type { FastifyBaseLogger } from 'fastify';
import type { Config } from '../config.js';
import { createGiteaRepo, GiteaRepoExistsError } from './gitea.js';
const execFileAsync = promisify(execFile);
const GITIGNORE_TEMPLATE = `# OS / editor
.DS_Store
*.swp
*~
# Node
node_modules/
dist/
build/
.env
.env.local
# Python
__pycache__/
*.pyc
.venv/
venv/
# AI agents
.claude/
.opencode/
# Backups
*.bak*
`;
const GIT_USER_NAME = 'indifferentketchup';
const GIT_USER_EMAIL = 'samkintop@gmail.com';
export interface BootstrapResult {
folder_real_path: string;
folder_name: string;
gitea_remote_url: string | null;
folder_created: boolean;
git_initialized: boolean;
first_commit: boolean;
gitea_remote_created: boolean;
gitea_pushed: boolean;
warnings: string[];
}
const SAFE_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
export function sanitizeFolderName(raw: string): string {
return raw
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64);
}
export class BootstrapNameError extends Error {}
export class BootstrapCollisionError extends Error {}
export class BootstrapPathError extends Error {}
export async function bootstrapProject(
config: Config,
log: FastifyBaseLogger,
options: {
name: string;
commitMessage: string;
visibility: 'private' | 'public';
createGiteaRemote: boolean;
}
): Promise<BootstrapResult> {
const folder = sanitizeFolderName(options.name);
if (folder.length === 0 || !SAFE_NAME.test(folder)) {
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');
}
if (existsSync(fullPath)) {
throw new BootstrapCollisionError(`path already exists: ${fullPath}`);
}
const warnings: string[] = [];
let folder_created = false;
let git_initialized = false;
let first_commit = false;
let gitea_remote_created = false;
let gitea_pushed = false;
let gitea_remote_url: string | null = null;
// Step 1: mkdir
await mkdir(fullPath, { recursive: false });
folder_created = true;
log.info({ fullPath }, 'project_bootstrap: folder created');
// Step 2: write .gitignore
await writeFile(resolve(fullPath, '.gitignore'), GITIGNORE_TEMPLATE, 'utf8');
// Step 3: git init -b main
await execFileAsync('git', ['init', '-b', 'main'], { cwd: fullPath });
git_initialized = true;
// Step 4: git add + commit (per-command -c, no global config touch)
await execFileAsync('git', ['add', '.gitignore'], { cwd: fullPath });
await execFileAsync(
'git',
[
'-c', `user.name=${GIT_USER_NAME}`,
'-c', `user.email=${GIT_USER_EMAIL}`,
'commit',
'-m', options.commitMessage,
],
{ cwd: fullPath }
);
first_commit = true;
log.info({ folder }, 'project_bootstrap: initial commit');
// Step 5: optional Gitea remote
if (options.createGiteaRemote) {
if (!config.GITEA_TOKEN) {
warnings.push('Gitea remote skipped — token not configured');
} else {
try {
const repo = await createGiteaRepo(
{ baseUrl: config.GITEA_BASE_URL, user: config.GITEA_USER, token: config.GITEA_TOKEN },
folder,
{ private: options.visibility === 'private' }
);
gitea_remote_created = true;
gitea_remote_url = repo.html_url;
log.info({ folder, html_url: repo.html_url }, 'project_bootstrap: gitea repo created');
// Step 6: git remote add + push
try {
await execFileAsync('git', ['remote', 'add', 'origin', repo.ssh_url], { cwd: fullPath });
await execFileAsync('git', ['push', '-u', 'origin', 'main'], { cwd: fullPath });
gitea_pushed = true;
log.info({ folder }, 'project_bootstrap: pushed to gitea');
} catch (pushErr) {
const msg = pushErr instanceof Error ? pushErr.message : String(pushErr);
warnings.push(`Push to Gitea failed: ${msg.slice(0, 200)}`);
log.warn({ err: pushErr, folder }, 'project_bootstrap: push failed');
}
} catch (err) {
if (err instanceof GiteaRepoExistsError) {
warnings.push('Gitea repo already exists with this name; local repo created without remote');
} else {
const msg = err instanceof Error ? err.message : String(err);
warnings.push(`Gitea remote creation failed: ${msg.slice(0, 200)}`);
}
log.warn({ err, folder }, 'project_bootstrap: gitea remote step failed');
}
}
}
return {
folder_real_path: fullPath,
folder_name: folder,
gitea_remote_url,
folder_created,
git_initialized,
first_commit,
gitea_remote_created,
gitea_pushed,
warnings,
};
}

View File

@@ -1,9 +1,15 @@
// KEEP IN SYNC: apps/server/src/schema.sql projects_status_chk
export const PROJECT_STATUSES = ['open', 'archived'] as const;
export type ProjectStatus = typeof PROJECT_STATUSES[number];
export interface Project {
id: string;
name: string;
path: string;
added_at: string;
last_session_id: string | null;
status: ProjectStatus;
gitea_remote: string | null;
}
export interface AvailableProject {
@@ -11,18 +17,45 @@ export interface AvailableProject {
name: string;
}
export type SessionStatus = 'open' | 'archived';
export interface Session {
id: string;
project_id: string;
name: string;
model: string;
system_prompt: string;
status: SessionStatus;
created_at: string;
updated_at: string;
}
export type MessageRole = 'user' | 'assistant' | 'tool';
export type MessageStatus = 'streaming' | 'complete' | 'failed';
// KEEP IN SYNC: apps/server/src/schema.sql chats_status_chk
export const CHAT_STATUSES = ['open', 'archived'] as const;
export type ChatStatus = typeof CHAT_STATUSES[number];
export interface Chat {
id: string;
session_id: string;
name: string | null;
status: ChatStatus;
created_at: string;
updated_at: string;
// Populated by GET /api/sessions/:id/chats only.
message_count?: number;
last_message_preview?: string | null;
effective_context_tokens?: number | null;
}
// KEEP IN SYNC: apps/server/src/schema.sql messages_role_chk / messages_status_chk
export const MESSAGE_ROLES = ['user', 'assistant', 'system', 'tool'] as const;
export type MessageRole = typeof MESSAGE_ROLES[number];
export const MESSAGE_STATUSES = ['streaming', 'complete', 'failed', 'cancelled'] as const;
export type MessageStatus = typeof MESSAGE_STATUSES[number];
export const MESSAGE_KINDS = ['message', 'compact'] as const;
export type MessageKind = typeof MESSAGE_KINDS[number];
export interface ToolCall {
id: string;
@@ -40,8 +73,10 @@ export interface ToolResult {
export interface Message {
id: string;
session_id: string;
chat_id: string;
role: MessageRole;
content: string;
kind: MessageKind;
tool_calls: ToolCall[] | null;
tool_results: ToolResult | null;
status: MessageStatus;
@@ -70,6 +105,8 @@ export interface SidebarSession {
export interface SidebarProject {
id: string;
name: string;
path: string;
gitea_remote: string | null;
recent_sessions: SidebarSession[];
total_sessions: number;
}
@@ -139,9 +176,62 @@ export interface SessionUpdatedFrame {
name: string;
updated_at: string;
}
export interface SessionArchivedFrame {
type: 'session_archived';
session_id: string;
project_id: string;
}
export interface ChatCreatedFrame {
type: 'chat_created';
chat: Chat;
session_id: string;
}
export interface ChatUpdatedFrame {
type: 'chat_updated';
chat_id: string;
session_id: string;
name: string | null;
updated_at: string;
}
export interface ChatArchivedFrame {
type: 'chat_archived';
chat_id: string;
session_id: string;
}
export interface ChatUnarchivedFrame {
type: 'chat_unarchived';
chat: Chat;
}
export interface ChatDeletedFrame {
type: 'chat_deleted';
chat_id: string;
session_id: string;
}
export interface ProjectArchivedFrame {
type: 'project_archived';
project_id: string;
}
export interface ProjectUnarchivedFrame {
type: 'project_unarchived';
project: Project;
}
export interface ProjectUpdatedFrame {
type: 'project_updated';
project_id: string;
name: string;
}
export type UserStreamFrame =
| ProjectCreatedFrame
| ProjectDeletedFrame
| SessionCreatedFrame
| SessionDeletedFrame
| SessionUpdatedFrame;
| SessionUpdatedFrame
| SessionArchivedFrame
| ChatCreatedFrame
| ChatUpdatedFrame
| ChatArchivedFrame
| ChatUnarchivedFrame
| ChatDeletedFrame
| ProjectArchivedFrame
| ProjectUnarchivedFrame
| ProjectUpdatedFrame;

View File

@@ -1,11 +1,29 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { BrowserRouter, Routes, Route, useParams } from 'react-router-dom';
import { api } from '@/api/client';
import { ProjectSidebar } from '@/components/ProjectSidebar';
import { RightRail } from '@/components/RightRail';
import { Home } from '@/pages/Home';
import { Project } from '@/pages/Project';
import { Session } from '@/pages/Session';
import { Toaster } from '@/components/ui/sonner';
import { useUserEvents } from '@/hooks/useUserEvents';
function SessionRightRail() {
const { id } = useParams<{ id: string }>();
if (!id) return null;
return <RightRailForSession sessionId={id} />;
}
function RightRailForSession({ sessionId }: { sessionId: string }) {
const [projectId, setProjectId] = useState<string | null>(null);
useEffect(() => {
api.sessions.get(sessionId).then((s) => setProjectId(s.project_id)).catch(() => {});
}, [sessionId]);
if (!projectId) return null;
return <RightRail projectId={projectId} />;
}
function AppShell() {
useUserEvents();
return (
@@ -18,6 +36,9 @@ function AppShell() {
<Route path="/session/:id" element={<Session />} />
</Routes>
</main>
<Routes>
<Route path="/session/:id" element={<SessionRightRail />} />
</Routes>
<Toaster position="bottom-right" />
</div>
);

View File

@@ -2,14 +2,12 @@ import type {
Project,
AvailableProject,
Session,
Chat,
Message,
ModelInfo,
SidebarResponse,
ListDirResult,
ViewFileResult,
Pane,
PaneCreateRequest,
PaneUpdateRequest,
} from './types';
export class ApiError extends Error {
@@ -43,13 +41,43 @@ export const api = {
health: () => request<{ status: string; db: boolean }>('/api/health'),
projects: {
list: () => request<Project[]>('/api/projects'),
list: (params?: { status?: 'open' | 'archived' }) =>
request<Project[]>(`/api/projects${params?.status ? `?status=${params.status}` : ''}`),
available: () => request<AvailableProject[]>('/api/projects/available'),
add: (body: { path: string; name?: string }) =>
request<Project>('/api/projects', {
method: 'POST',
body: JSON.stringify(body),
}),
update: (id: string, body: { name: string }) =>
request<Project>(`/api/projects/${id}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
archive: (id: string) =>
request<void>(`/api/projects/${id}/archive`, { method: 'POST' }),
unarchive: (id: string) =>
request<Project>(`/api/projects/${id}/unarchive`, { method: 'POST' }),
create: (body: {
name: string;
commit_message?: string;
visibility?: 'private' | 'public';
create_gitea_remote?: boolean;
}) =>
request<{
project: Project;
bootstrap: {
folder_created: boolean;
git_initialized: boolean;
first_commit: boolean;
gitea_remote_created: boolean;
gitea_pushed: boolean;
warnings: string[];
};
}>(`/api/projects/create`, {
method: 'POST',
body: JSON.stringify(body),
}),
remove: (id: string) =>
request<void>(`/api/projects/${id}`, { method: 'DELETE' }),
listDir: (id: string, path: string) =>
@@ -61,8 +89,8 @@ export const api = {
},
sessions: {
listForProject: (projectId: string) =>
request<Session[]>(`/api/projects/${projectId}/sessions`),
listForProject: (projectId: string, status?: 'open' | 'archived') =>
request<Session[]>(`/api/projects/${projectId}/sessions${status ? `?status=${status}` : ''}`),
create: (
projectId: string,
body: { name?: string; model?: string; system_prompt?: string }
@@ -82,22 +110,60 @@ export const api = {
}),
remove: (id: string) =>
request<void>(`/api/sessions/${id}`, { method: 'DELETE' }),
archive: (id: string) =>
request<void>(`/api/sessions/${id}/archive`, { method: 'POST' }),
unarchive: (id: string) =>
request<Session>(`/api/sessions/${id}/unarchive`, { method: 'POST' }),
},
chats: {
listForSession: (sessionId: string, params?: { status?: 'open' | 'archived' }) =>
request<Chat[]>(
`/api/sessions/${sessionId}/chats${params?.status ? `?status=${params.status}` : ''}`
),
create: (sessionId: string, body?: { name?: string }) =>
request<Chat>(`/api/sessions/${sessionId}/chats`, {
method: 'POST',
body: JSON.stringify(body ?? {}),
}),
update: (chatId: string, body: { name: string }) =>
request<Chat>(`/api/chats/${chatId}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
archive: (chatId: string) =>
request<void>(`/api/chats/${chatId}/archive`, { method: 'POST' }),
unarchive: (chatId: string) =>
request<Chat>(`/api/chats/${chatId}/unarchive`, { method: 'POST' }),
remove: (chatId: string) =>
request<void>(`/api/chats/${chatId}`, { method: 'DELETE' }),
messages: (chatId: string) =>
request<Message[]>(`/api/chats/${chatId}/messages`),
compact: (chatId: string) =>
request<{ compact_message_id: string }>(`/api/chats/${chatId}/compact`, { method: 'POST' }),
stop: (chatId: string) =>
request<{ stopped: boolean }>(`/api/chats/${chatId}/stop`, { method: 'POST' }),
forceSend: (chatId: string, content: string) =>
request<{ user_message_id: string; assistant_message_id: string }>(
`/api/chats/${chatId}/force_send`,
{ method: 'POST', body: JSON.stringify({ content }) }
),
},
messages: {
list: (sessionId: string) =>
request<Message[]>(`/api/sessions/${sessionId}/messages`),
send: (sessionId: string, content: string) =>
send: (chatId: string, content: string) =>
request<{ user_message_id: string; assistant_message_id: string }>(
`/api/sessions/${sessionId}/messages`,
`/api/chats/${chatId}/messages`,
{
method: 'POST',
body: JSON.stringify({ content }),
}
),
regenerate: (sessionId: string, messageId: string) =>
regenerate: (chatId: string, messageId: string) =>
request<{ assistant_message_id: string }>(
`/api/sessions/${sessionId}/messages/${messageId}/regenerate`,
`/api/chats/${chatId}/messages/${messageId}/regenerate`,
{ method: 'POST' }
),
},
@@ -116,21 +182,4 @@ export const api = {
sidebar: {
get: () => request<SidebarResponse>('/api/sidebar'),
},
panes: {
getForSession: (sessionId: string) =>
request<{ panes: Pane[] }>(`/api/sessions/${sessionId}/panes`),
create: (sessionId: string, body: PaneCreateRequest) =>
request<Pane>(`/api/sessions/${sessionId}/panes`, {
method: 'POST',
body: JSON.stringify(body),
}),
update: (id: string, body: PaneUpdateRequest) =>
request<Pane>(`/api/panes/${id}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
remove: (id: string) =>
request<void>(`/api/panes/${id}`, { method: 'DELETE' }),
},
};

View File

@@ -1,9 +1,14 @@
export const PROJECT_STATUSES = ['open', 'archived'] as const;
export type ProjectStatus = typeof PROJECT_STATUSES[number];
export interface Project {
id: string;
name: string;
path: string;
added_at: string;
last_session_id: string | null;
status: ProjectStatus;
gitea_remote: string | null;
}
export interface AvailableProject {
@@ -11,18 +16,38 @@ export interface AvailableProject {
name: string;
}
export type SessionStatus = 'open' | 'archived';
export interface Session {
id: string;
project_id: string;
name: string;
model: string;
system_prompt: string;
status: SessionStatus;
created_at: string;
updated_at: string;
}
export type MessageRole = 'user' | 'assistant' | 'tool';
export type MessageStatus = 'streaming' | 'complete' | 'failed';
export const CHAT_STATUSES = ['open', 'archived'] as const;
export type ChatStatus = typeof CHAT_STATUSES[number];
export interface Chat {
id: string;
session_id: string;
name: string | null;
status: ChatStatus;
created_at: string;
updated_at: string;
// Populated by GET /api/sessions/:id/chats only.
message_count?: number;
last_message_preview?: string | null;
effective_context_tokens?: number | null;
}
export type MessageRole = 'user' | 'assistant' | 'tool' | 'system';
export type MessageStatus = 'streaming' | 'complete' | 'failed' | 'cancelled';
export type MessageKind = 'message' | 'compact';
export interface ToolCall {
id: string;
@@ -40,8 +65,10 @@ export interface ToolResult {
export interface Message {
id: string;
session_id: string;
chat_id: string;
role: MessageRole;
content: string;
kind: MessageKind;
tool_calls: ToolCall[] | null;
tool_results: ToolResult | null;
status: MessageStatus;
@@ -70,6 +97,8 @@ export interface SidebarSession {
export interface SidebarProject {
id: string;
name: string;
path: string;
gitea_remote: string | null;
recent_sessions: SidebarSession[];
total_sessions: number;
}
@@ -127,14 +156,25 @@ export interface PaneUpdateRequest {
position?: number;
}
export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty';
export interface WorkspacePane {
id: string;
kind: WorkspacePaneKind;
chatId?: string;
chatIds: string[];
activeChatIdx: number;
}
export type WsFrame =
| { type: 'snapshot'; messages: Message[] }
| { type: 'message_started'; message_id: string; role: MessageRole }
| { type: 'delta'; message_id: string; content: string }
| { type: 'tool_call'; message_id: string; tool_call: ToolCall }
| { type: 'message_started'; message_id: string; chat_id?: string; role: MessageRole }
| { type: 'delta'; message_id: string; chat_id?: string; content: string }
| { type: 'tool_call'; message_id: string; chat_id?: string; tool_call: ToolCall }
| {
type: 'tool_result';
tool_message_id: string;
chat_id?: string;
tool_call_id: string;
output: unknown;
truncated: boolean;
@@ -143,12 +183,14 @@ export type WsFrame =
| {
type: 'message_complete';
message_id: string;
chat_id?: string;
tokens_used?: number | null;
ctx_used?: number | null;
ctx_max?: number | null;
started_at?: string | null;
finished_at?: string | null;
}
| { type: 'messages_deleted'; message_ids: string[] }
| { type: 'session_renamed'; session_id: string; name: string }
| { type: 'error'; message_id?: string; error: string };
| { 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

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

View File

@@ -0,0 +1,41 @@
import { FileText, X } from 'lucide-react';
import type { Attachment } from '@/lib/attachments';
interface Props {
attachment: Attachment;
onRemove: (id: string) => void;
onPreview: (attachment: Attachment) => void;
}
export function AttachmentChip({ attachment, onRemove, onPreview }: Props) {
const lineCount = attachment.content.split('\n').length;
const label =
attachment.kind === 'lines' && attachment.range
? `${attachment.filename}:${attachment.range[0]}-${attachment.range[1]}`
: attachment.filename;
return (
<div className="flex items-center gap-1.5 bg-muted/60 border border-border rounded px-2 py-0.5 text-xs font-mono">
<button
type="button"
onClick={() => onPreview(attachment)}
className="flex items-center gap-1.5 hover:bg-muted/60 transition-colors min-w-0"
>
<FileText className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate max-w-[200px]">{label}</span>
<span className="text-muted-foreground whitespace-nowrap">
+{lineCount} lines
</span>
</button>
<button
type="button"
onClick={() => onRemove(attachment.id)}
className="ml-0.5 rounded hover:bg-muted-foreground/20 p-0.5 shrink-0"
aria-label="Remove attachment"
>
<X className="size-3" />
</button>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import type { Attachment } from '@/lib/attachments';
import { CodeBlock } from '@/components/CodeBlock';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface Props {
attachment: Attachment | null;
onClose: () => void;
}
export function AttachmentPreviewModal({ attachment, onClose }: Props) {
const title = attachment
? attachment.kind === 'lines' && attachment.range
? `${attachment.filename}:${attachment.range[0]}-${attachment.range[1]}`
: attachment.filename
: '';
return (
<Dialog open={attachment !== null} onOpenChange={() => onClose()}>
<DialogContent className="sm:max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="font-mono text-sm">{title}</DialogTitle>
</DialogHeader>
{attachment && (
<CodeBlock
code={attachment.content}
lang={attachment.language ?? undefined}
/>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -1,25 +1,73 @@
import { useState, type KeyboardEvent } from 'react';
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react';
import { Send } from 'lucide-react';
import { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { flattenToMessage, inferLanguage, type Attachment } from '@/lib/attachments';
import { AttachmentChip } from '@/components/AttachmentChip';
import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
import { FileMentionPopover } from '@/components/FileMentionPopover';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
interface Props {
disabled?: boolean;
projectId: string;
onSend: (content: string) => void | Promise<void>;
onForceSend?: (content: string) => void | Promise<void>;
}
export function ChatInput({ disabled, onSend }: Props) {
export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
const [value, setValue] = useState('');
const [busy, setBusy] = useState(false);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [previewAttachment, setPreviewAttachment] = useState<Attachment | null>(null);
const [mentionState, setMentionState] = useState<{
open: boolean;
query: string;
atIdx: number;
anchorRect: { top: number; left: number };
} | null>(null);
const [fileIndex, setFileIndex] = useState<string[] | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
function addAttachment(a: Attachment) {
setAttachments(prev => {
if (prev.length >= 10) {
toast.error('Max 10 attachments per message');
return prev;
}
return [...prev, a];
});
}
const addAttachmentRef = useRef(addAttachment);
addAttachmentRef.current = addAttachment;
useEffect(() => {
return sessionEvents.subscribe((event) => {
if (event.type !== 'attach_chat_file') return;
addAttachmentRef.current({
id: crypto.randomUUID(),
...event.attachment,
});
});
}, []);
function removeAttachment(id: string) {
setAttachments(prev => prev.filter(a => a.id !== id));
}
async function submit() {
const text = value.trim();
if (!text || disabled || busy) return;
if (!text && attachments.length === 0) return;
if (disabled || busy) return;
setBusy(true);
try {
await onSend(text);
const body = flattenToMessage(attachments, text);
await onSend(body);
setValue('');
setAttachments([]);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to send');
} finally {
@@ -27,32 +75,198 @@ export function ChatInput({ disabled, onSend }: Props) {
}
}
function getCaretCoords(textarea: HTMLTextAreaElement): { top: number; left: number } {
const mirror = document.createElement('div');
const style = window.getComputedStyle(textarea);
const properties = [
'fontFamily', 'fontSize', 'fontWeight', 'fontStyle',
'letterSpacing', 'lineHeight', 'textTransform', 'wordSpacing',
'textIndent', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth',
'boxSizing', 'whiteSpace', 'overflowWrap',
] as const;
mirror.style.position = 'absolute';
mirror.style.visibility = 'hidden';
mirror.style.overflow = 'hidden';
mirror.style.width = style.width;
for (const prop of properties) {
mirror.style[prop] = style[prop];
}
mirror.style.whiteSpace = 'pre-wrap';
mirror.style.overflowWrap = 'break-word';
const textBefore = textarea.value.slice(0, textarea.selectionStart);
mirror.textContent = textBefore;
const span = document.createElement('span');
span.textContent = ''; // zero-width space
mirror.appendChild(span);
document.body.appendChild(mirror);
const taRect = textarea.getBoundingClientRect();
const spanRect = span.getBoundingClientRect();
const mirrorRect = mirror.getBoundingClientRect();
const top = taRect.top + (spanRect.top - mirrorRect.top) - textarea.scrollTop + span.offsetHeight;
const left = taRect.left + (spanRect.left - mirrorRect.left);
document.body.removeChild(mirror);
return { top, left };
}
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
const newValue = e.target.value;
setValue(newValue);
const ta = e.target;
const pos = ta.selectionStart;
// Check for @ trigger
if (pos > 0 && newValue[pos - 1] === '@') {
const charBefore = pos >= 2 ? newValue[pos - 2] : null;
if (charBefore === null || charBefore === ' ' || charBefore === '\n') {
const coords = getCaretCoords(ta);
setMentionState({ open: true, query: '', atIdx: pos - 1, anchorRect: coords });
if (!fileIndex) {
api.projects.files(projectId).then(r => setFileIndex(r.files)).catch(() => {});
}
return;
}
}
// Update query if popover is open — use stored atIdx
if (mentionState?.open) {
const { atIdx } = mentionState;
if (atIdx < pos && newValue[atIdx] === '@') {
const query = newValue.slice(atIdx + 1, pos);
setMentionState(prev => prev ? { ...prev, query } : null);
} else {
setMentionState(null);
}
}
}
async function handleMentionSelect(path: string) {
const atIdx = mentionState?.atIdx ?? -1;
const ta = textareaRef.current;
const caretPos = ta?.selectionStart ?? value.length;
setMentionState(null);
try {
const result = await api.projects.viewFile(projectId, path);
if (atIdx >= 0) {
const cleaned = value.slice(0, atIdx) + value.slice(caretPos);
setValue(cleaned);
if (ta) {
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = atIdx;
ta.focus();
});
}
}
addAttachment({
id: crypto.randomUUID(),
kind: 'file',
filename: path,
language: inferLanguage(path),
content: result.content,
source: '@',
});
} catch {
toast.error('Failed to load file');
}
}
const closeMention = useCallback(() => setMentionState(null), []);
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
if (mentionState?.open) return;
if (e.key === 'Enter' && e.shiftKey && (e.metaKey || e.ctrlKey) && onForceSend) {
e.preventDefault();
void forceSubmit();
return;
}
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
void submit();
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void submit();
}
}
async function forceSubmit() {
const text = value.trim();
if (!text || !onForceSend) return;
if (busy) return;
setBusy(true);
try {
const body = flattenToMessage(attachments, text);
await onForceSend(body);
setValue('');
setAttachments([]);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'force send failed');
} finally {
setBusy(false);
}
}
return (
<div className="border-t px-4 py-3 flex items-end gap-2">
<Textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Ask about this project. Cmd/Ctrl+Enter to send."
disabled={disabled || busy}
rows={3}
className="resize-none min-h-[68px] max-h-[240px]"
<div className="border-t">
<div className="max-w-[1000px] mx-auto w-full">
{attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-4 pt-3">
{attachments.map(a => (
<AttachmentChip
key={a.id}
attachment={a}
onRemove={removeAttachment}
onPreview={setPreviewAttachment}
/>
))}
</div>
)}
<div className="px-4 py-3 flex items-end gap-2">
<Textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onKeyDown={onKeyDown}
placeholder="Ask about this project. Enter to send, Shift+Enter for newline."
disabled={disabled || busy}
rows={3}
className="resize-none min-h-[68px] max-h-[240px]"
/>
<Button
onClick={() => void submit()}
disabled={disabled || busy || (!value.trim() && attachments.length === 0)}
size="icon-lg"
aria-label="Send"
>
<Send />
</Button>
</div>
</div>
<AttachmentPreviewModal
attachment={previewAttachment}
onClose={() => setPreviewAttachment(null)}
/>
<Button
onClick={() => void submit()}
disabled={disabled || busy || !value.trim()}
size="icon-lg"
aria-label="Send"
>
<Send />
</Button>
{mentionState?.open && (
<FileMentionPopover
query={mentionState.query}
files={fileIndex ?? []}
anchorRect={mentionState.anchorRect}
onSelect={handleMentionSelect}
onClose={closeMention}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,177 @@
import { useState } from 'react';
import { History, MessageSquare, Plus, X } from 'lucide-react';
import type { Chat, WorkspacePane } from '@/api/types';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import { cn } from '@/lib/utils';
interface Props {
pane: WorkspacePane;
tabs: Chat[];
onSwitchTab: (tabIdx: number) => void;
onRemoveTab: (chatId: string) => void;
onCloseOthers: (chatId: string) => void;
onCloseToRight: (chatId: string) => void;
onCloseAll: () => void;
onNewChat: () => void;
onShowHistory: () => void;
onRename: (chatId: string, name: string) => Promise<void>;
onRemovePane?: () => void;
}
export function ChatTabBar({
pane,
tabs,
onSwitchTab,
onRemoveTab,
onCloseOthers,
onCloseToRight,
onCloseAll,
onNewChat,
onShowHistory,
onRename,
onRemovePane,
}: Props) {
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
function startRename(chatId: string, currentName: string | null) {
setRenamingId(chatId);
setRenameValue(currentName ?? '');
}
async function finishRename() {
if (renamingId && renameValue.trim()) {
await onRename(renamingId, renameValue.trim());
}
setRenamingId(null);
}
return (
<div className="flex items-center border-b border-border bg-muted/20 h-8 shrink-0 overflow-x-auto">
{tabs.map((chat, tabIdx) => {
const isActive = tabIdx === pane.activeChatIdx;
const isLast = tabIdx === tabs.length - 1;
const onlyTab = tabs.length === 1;
const label = chat.name ?? 'New chat';
return (
<ContextMenu key={chat.id}>
<ContextMenuTrigger asChild>
<div
onClick={() => onSwitchTab(tabIdx)}
className={cn(
'group flex items-center gap-1.5 px-3 py-1.5 text-xs border-r border-border cursor-default select-none shrink-0',
isActive
? 'bg-background text-foreground'
: 'bg-muted/30 text-muted-foreground hover:bg-muted/60'
)}
>
<MessageSquare size={12} className="shrink-0" />
{renamingId === chat.id ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => void finishRename()}
onKeyDown={(e) => {
if (e.key === 'Enter') void finishRename();
if (e.key === 'Escape') setRenamingId(null);
}}
onClick={(e) => e.stopPropagation()}
className="bg-transparent border-b border-border text-xs outline-none w-28"
/>
) : (
<span className="truncate max-w-[140px]" title={label}>
{label}
</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemoveTab(chat.id);
}}
className="p-0.5 hover:bg-muted rounded opacity-0 group-hover:opacity-60 hover:!opacity-100 shrink-0"
aria-label="Close tab"
>
<X size={10} />
</button>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => startRename(chat.id, chat.name)}>
Rename
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => onRemoveTab(chat.id)}>
Close
</ContextMenuItem>
<ContextMenuItem
disabled={onlyTab}
onSelect={() => onCloseOthers(chat.id)}
>
Close others
</ContextMenuItem>
<ContextMenuItem
disabled={isLast}
onSelect={() => onCloseToRight(chat.id)}
>
Close to right
</ContextMenuItem>
<ContextMenuItem onSelect={() => onCloseAll()}>
Close all
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
})}
{tabs.length === 0 && (
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground">
<History size={12} className="shrink-0" />
<span>Session</span>
</div>
)}
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
<button
type="button"
onClick={onNewChat}
className="p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="New chat"
title="New chat"
>
<Plus size={12} />
</button>
<button
type="button"
onClick={onShowHistory}
className={cn(
'p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground',
pane.kind === 'empty' && 'text-foreground bg-muted/50'
)}
aria-label="Session history"
title="Session history"
>
<History size={12} />
</button>
{onRemovePane && (
<button
type="button"
onClick={onRemovePane}
className="p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Close pane"
title="Close pane"
>
<X size={12} />
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,171 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { api } from '@/api/client';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
}
function previewFolderName(raw: string): string {
return raw
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64);
}
export function CreateProjectModal({ open, onOpenChange }: Props) {
const navigate = useNavigate();
const [name, setName] = useState('');
const [commitMessage, setCommitMessage] = useState('Initial commit');
const [visibility, setVisibility] = useState<'private' | 'public'>('private');
const [createRemote, setCreateRemote] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setName('');
setCommitMessage('Initial commit');
setVisibility('private');
setCreateRemote(true);
setBusy(false);
setError(null);
}, [open]);
const folderPreview = previewFolderName(name);
async function submit() {
if (!folderPreview) {
setError('Project name must contain at least one letter or digit.');
return;
}
setBusy(true);
setError(null);
try {
const result = await api.projects.create({
name: name.trim(),
commit_message: commitMessage.trim() || 'Initial commit',
visibility,
create_gitea_remote: createRemote,
});
const warnings = result.bootstrap.warnings;
if (warnings.length > 0) {
toast.warning(`Project created with warnings: ${warnings.join('; ')}`);
} else {
toast.success(`Project "${result.project.name}" created`);
}
onOpenChange(false);
navigate(`/project/${result.project.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'failed to create project');
} finally {
setBusy(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create New Project</DialogTitle>
<DialogDescription>
Creates a folder under /opt with a git repo, .gitignore, and optionally a Gitea remote.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="proj-name">Project name</Label>
<Input
id="proj-name"
placeholder="My new project"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={busy}
autoFocus
/>
{name && (
<div className="text-xs text-muted-foreground font-mono">
Folder: /opt/{folderPreview || <span className="text-destructive">(empty after sanitization)</span>}
</div>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="commit-msg">Initial commit message</Label>
<Input
id="commit-msg"
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
disabled={busy}
/>
</div>
<div className="space-y-1.5">
<Label>Visibility</Label>
<div className="flex gap-4 text-sm">
<label className="flex items-center gap-1.5">
<input
type="radio"
checked={visibility === 'private'}
onChange={() => setVisibility('private')}
disabled={busy}
/>
Private
</label>
<label className="flex items-center gap-1.5">
<input
type="radio"
checked={visibility === 'public'}
onChange={() => setVisibility('public')}
disabled={busy}
/>
Public
</label>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={createRemote}
onChange={(e) => setCreateRemote(e.target.checked)}
disabled={busy}
/>
Create Gitea remote and push
</label>
{error && (
<div className="text-sm text-destructive">{error}</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
Cancel
</Button>
<Button onClick={() => void submit()} disabled={busy || !folderPreview}>
{busy ? 'Creating…' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,145 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
interface Props {
query: string;
files: string[];
anchorRect: { top: number; left: number };
onSelect: (path: string) => void;
onClose: () => void;
}
function filterAndRank(files: string[], query: string): string[] {
const q = query.toLowerCase();
if (!q) {
return files.slice(0, 20);
}
const filenameMatches: string[] = [];
const pathOnlyMatches: string[] = [];
for (const file of files) {
const lower = file.toLowerCase();
if (!lower.includes(q)) continue;
const basename = file.split('/').pop() ?? file;
if (basename.toLowerCase().includes(q)) {
filenameMatches.push(file);
} else {
pathOnlyMatches.push(file);
}
}
filenameMatches.sort((a, b) => a.localeCompare(b));
pathOnlyMatches.sort((a, b) => a.localeCompare(b));
return [...filenameMatches, ...pathOnlyMatches].slice(0, 20);
}
export function FileMentionPopover({
query,
files,
anchorRect,
onSelect,
onClose,
}: Props) {
const [highlightIndex, setHighlightIndex] = useState(0);
const popoverRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => filterAndRank(files, query), [files, query]);
// Reset highlight when query changes
useEffect(() => {
setHighlightIndex(0);
}, [query]);
// Keyboard navigation
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightIndex(prev =>
prev < filtered.length - 1 ? prev + 1 : 0
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightIndex(prev =>
prev > 0 ? prev - 1 : filtered.length - 1
);
} else if (e.key === 'Enter') {
e.preventDefault();
if (filtered.length > 0) {
onSelect(filtered[highlightIndex] ?? filtered[0]!);
}
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [filtered, highlightIndex, onSelect, onClose]);
// Click outside to close
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
if (
popoverRef.current &&
!popoverRef.current.contains(e.target as Node)
) {
onClose();
}
}
document.addEventListener('mousedown', handleMouseDown);
return () => document.removeEventListener('mousedown', handleMouseDown);
}, [onClose]);
// Scroll highlighted item into view
useEffect(() => {
const el = popoverRef.current?.querySelector('[data-highlighted="true"]');
if (el) {
el.scrollIntoView({ block: 'nearest' });
}
}, [highlightIndex]);
if (filtered.length === 0) {
return (
<div
ref={popoverRef}
className="fixed z-50 bg-popover border border-border rounded-md shadow min-w-[260px] p-2"
style={{ top: anchorRect.top, left: anchorRect.left }}
>
<div className="text-xs text-muted-foreground px-2 py-1">
No matching files
</div>
</div>
);
}
return (
<div
ref={popoverRef}
className="fixed z-50 bg-popover border border-border rounded-md shadow min-w-[260px] max-h-[240px] overflow-y-auto"
style={{ top: anchorRect.top, left: anchorRect.left }}
>
{filtered.map((file, i) => (
<button
key={file}
type="button"
data-highlighted={i === highlightIndex}
className={cn(
'w-full text-left text-xs font-mono px-2 py-1.5 cursor-pointer',
i === highlightIndex && 'bg-muted'
)}
onMouseEnter={() => setHighlightIndex(i)}
onMouseDown={(e) => {
e.preventDefault();
onSelect(file);
}}
>
{file}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,241 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Check, Copy, X, Paperclip } from 'lucide-react';
import { codeToHtml } from 'shiki';
import { sessionEvents } from '@/hooks/sessionEvents';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface Props {
path: string;
content: string;
lang: string | null;
projectId: string;
onClose: () => void;
onNavigate: (path: string) => void;
}
const SHIKI_THEME = 'github-dark';
function splitShikiLines(html: string): string[] {
const match = html.match(/<code[^>]*>([\s\S]*)<\/code>/);
if (!match) return [];
const inner = match[1]!;
const lines = inner.split(/(?=<span class="line">)/);
return lines.filter(l => l.trim().length > 0);
}
function basename(path: string): string {
const parts = path.split('/');
return parts[parts.length - 1] ?? path;
}
export function FileViewerOverlay({ path, content, lang, onClose }: Props) {
const [copied, setCopied] = useState(false);
const [lineHtmls, setLineHtmls] = useState<string[] | null>(null);
const [selectedLines, setSelectedLines] = useState<Set<number>>(new Set());
const [showAttachPopover, setShowAttachPopover] = useState(false);
const draggingRef = useRef(false);
const dragStartRef = useRef<number | null>(null);
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setSelectedLines(new Set());
setShowAttachPopover(false);
if (!lang) { setLineHtmls(null); return; }
let cancelled = false;
(async () => {
try {
const result = await codeToHtml(content, { lang, theme: SHIKI_THEME });
if (!cancelled) {
const lines = splitShikiLines(result);
setLineHtmls(lines.length > 0 ? lines : null);
}
} catch { if (!cancelled) setLineHtmls(null); }
})();
return () => { cancelled = true; };
}, [content, lang]);
const plainLines = content.split('\n');
const totalLines = lineHtmls ? lineHtmls.length : plainLines.length;
async function copyAll() {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch { /* ignore */ }
}
function handleLineMouseDown(lineNo: number, e: React.MouseEvent) {
if (e.shiftKey && dragStartRef.current !== null) {
const start = dragStartRef.current;
const min = Math.min(start, lineNo);
const max = Math.max(start, lineNo);
const next = new Set<number>();
for (let i = min; i <= max; i++) next.add(i);
setSelectedLines(next);
setShowAttachPopover(true);
return;
}
draggingRef.current = true;
dragStartRef.current = lineNo;
setSelectedLines(new Set([lineNo]));
setShowAttachPopover(false);
}
function handleLineMouseEnter(lineNo: number) {
if (!draggingRef.current || dragStartRef.current === null) return;
const start = dragStartRef.current;
const min = Math.min(start, lineNo);
const max = Math.max(start, lineNo);
const next = new Set<number>();
for (let i = min; i <= max; i++) next.add(i);
setSelectedLines(next);
}
const handleMouseUp = useCallback(() => {
if (draggingRef.current) {
draggingRef.current = false;
if (selectedLines.size > 0) setShowAttachPopover(true);
}
}, [selectedLines.size]);
useEffect(() => {
document.addEventListener('mouseup', handleMouseUp);
return () => document.removeEventListener('mouseup', handleMouseUp);
}, [handleMouseUp]);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (overlayRef.current && !overlayRef.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [onClose]);
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [onClose]);
function getSelectionRange(): { min: number; max: number } | null {
if (selectedLines.size === 0) return null;
let min = Infinity;
let max = -Infinity;
for (const n of selectedLines) {
if (n < min) min = n;
if (n > max) max = n;
}
return { min, max };
}
function handleAttach() {
const range = getSelectionRange();
if (!range) return;
const lines = content.split('\n').slice(range.min - 1, range.max);
sessionEvents.emit({
type: 'attach_chat_file',
attachment: {
kind: 'lines',
filename: path,
language: lang,
content: lines.join('\n'),
range: [range.min, range.max],
source: 'line-select',
},
});
setSelectedLines(new Set());
setShowAttachPopover(false);
}
const range = getSelectionRange();
const attachLabel = range
? range.min === range.max
? `Attach line ${range.min} to chat`
: `Attach lines ${range.min}${range.max} to chat`
: '';
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-12 pb-12">
<div className="absolute inset-0 bg-black/40" />
<div
ref={overlayRef}
className="relative bg-background border rounded-lg shadow-xl flex flex-col w-[80vw] max-w-[1000px] max-h-[80vh] overflow-hidden"
>
<div className="flex items-center gap-2 px-4 py-2 border-b shrink-0">
<span className="text-sm font-medium truncate flex-1" title={path}>
{basename(path)}
</span>
<span className="text-xs text-muted-foreground truncate">{path}</span>
<button
type="button"
onClick={() => void copyAll()}
className="flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted"
>
{copied ? <Check size={12} /> : <Copy size={12} />}
{copied ? 'Copied' : 'Copy'}
</button>
<button
type="button"
onClick={onClose}
className="p-1 rounded hover:bg-muted"
aria-label="Close"
>
<X size={16} />
</button>
</div>
{/* Shiki-highlighted code lines are generated from source code files, not user content */}
<div className="flex-1 overflow-auto text-sm font-mono select-none">
{Array.from({ length: totalLines }, (_, i) => {
const lineNo = i + 1;
const isSelected = selectedLines.has(lineNo);
return (
<div
key={lineNo}
className={cn('flex', isSelected && 'bg-blue-500/10')}
onMouseDown={(e) => handleLineMouseDown(lineNo, e)}
onMouseEnter={() => handleLineMouseEnter(lineNo)}
>
<div
className="shrink-0 w-[3.5ch] text-right pr-2 text-xs text-muted-foreground select-none cursor-pointer hover:text-foreground"
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{lineNo}
</div>
{lineHtmls ? (
<div
className="flex-1 min-w-0 text-xs leading-relaxed [&>.line]:!bg-transparent"
dangerouslySetInnerHTML={{ __html: lineHtmls[i] ?? '' }}
/>
) : (
<span className="flex-1 min-w-0 text-xs leading-relaxed whitespace-pre">
{plainLines[i] ?? ''}
</span>
)}
</div>
);
})}
</div>
{showAttachPopover && range && (
<div className="sticky bottom-0 border-t bg-background px-4 py-2 flex items-center gap-2">
<Paperclip size={14} className="text-muted-foreground" />
<span className="text-xs flex-1">{attachLabel}</span>
<Button size="sm" onClick={handleAttach}>
Attach
</Button>
<Button size="sm" variant="ghost" onClick={() => { setSelectedLines(new Set()); setShowAttachPopover(false); }}>
Cancel
</Button>
</div>
)}
</div>
</div>
);
}

View File

@@ -2,9 +2,9 @@ import { Children, cloneElement, isValidElement, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Copy, RefreshCw, Check } from 'lucide-react';
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw } from 'lucide-react';
import { toast } from 'sonner';
import type { Message } from '@/api/types';
import type { Chat, Message } from '@/api/types';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { ToolCallCard } from './ToolCallCard';
@@ -84,7 +84,7 @@ function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
interface Props {
message: Message;
sessionId: string;
sessionChats?: Chat[];
}
function MarkdownBody({ content }: { content: string }) {
@@ -193,10 +193,8 @@ function StatsLine({ message }: { message: Message }) {
function ActionRow({
message,
sessionId,
}: {
message: Message;
sessionId: string;
}) {
const [justCopied, setJustCopied] = useState(false);
const [regenerating, setRegenerating] = useState(false);
@@ -215,7 +213,7 @@ function ActionRow({
if (regenerating || message.status === 'streaming') return;
setRegenerating(true);
try {
await api.messages.regenerate(sessionId, message.id);
await api.messages.regenerate(message.chat_id, message.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'regenerate failed');
} finally {
@@ -253,7 +251,131 @@ function ActionRow({
);
}
export function MessageBubble({ message, sessionId }: Props) {
function CompactCard({ message, sessionChats }: { message: Message; sessionChats?: Chat[] }) {
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [rerunning, setRerunning] = useState(false);
const headerMatch = message.content.match(/^\[Context compacted — (\d+) messages summarized\]/);
const headerText = headerMatch ? headerMatch[0] : 'Context compacted';
const summaryText = headerMatch
? message.content.slice(headerMatch[0].length).trim()
: message.content;
async function handleCopy() {
try {
await navigator.clipboard.writeText(summaryText);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
toast.success('Summary copied to clipboard');
} catch {
toast.error('Copy failed');
}
}
async function handleShareToChat(chat: Chat) {
try {
await api.messages.send(chat.id, summaryText);
toast.success(`Summary sent to ${chat.name ?? 'New chat'}`);
setShareOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to share');
}
}
async function handleRerun() {
if (rerunning) return;
setRerunning(true);
try {
await api.chats.compact(message.chat_id);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Re-run failed');
} finally {
setRerunning(false);
}
}
const otherChats = (sessionChats ?? []).filter(
(c) => c.id !== message.chat_id && c.status === 'open'
);
return (
<div className="rounded-lg border bg-muted/30 text-sm">
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1.5 flex-1 min-w-0 text-left text-muted-foreground hover:text-foreground"
>
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span className="text-xs font-medium truncate">{headerText}</span>
</button>
<button
type="button"
onClick={() => void handleCopy()}
className="p-1 rounded hover:bg-muted text-muted-foreground"
aria-label="Copy summary"
title="Copy summary"
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</button>
<div className="relative">
<button
type="button"
onClick={() => setShareOpen(!shareOpen)}
className="p-1 rounded hover:bg-muted text-muted-foreground"
aria-label="Send to chat"
title="Send to chat"
>
<Share2 size={12} />
</button>
{shareOpen && (
<div className="absolute right-0 top-full mt-1 z-50 bg-popover border rounded-md shadow-md min-w-[180px] py-1">
{otherChats.length === 0 ? (
<div className="px-3 py-1.5 text-xs text-muted-foreground">
No other chats in this session
</div>
) : (
otherChats.map((c) => (
<button
key={c.id}
type="button"
onClick={() => void handleShareToChat(c)}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent truncate"
>
{c.name ?? 'New chat'}
</button>
))
)}
</div>
)}
</div>
<button
type="button"
onClick={() => void handleRerun()}
disabled={rerunning}
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-40"
aria-label="Re-run compact"
title="Re-run compact"
>
<RotateCw size={12} className={rerunning ? 'animate-spin' : ''} />
</button>
</div>
{expanded && (
<div className="px-3 pb-3 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap border-t pt-2">
{summaryText}
</div>
)}
</div>
);
}
export function MessageBubble({ message, sessionChats }: Props) {
if (message.kind === 'compact') {
return <CompactCard message={message} sessionChats={sessionChats} />;
}
if (message.role === 'tool') {
return <ToolCallCard message={message} />;
}
@@ -264,7 +386,7 @@ export function MessageBubble({ message, sessionId }: Props) {
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap">
{message.content}
</div>
<ActionRow message={message} sessionId={sessionId} />
<ActionRow message={message} />
</div>
);
}
@@ -292,7 +414,7 @@ export function MessageBubble({ message, sessionId }: Props) {
)}
{!isStreaming && <StatsLine message={message} />}
{!isStreaming && (hasContent || hasToolCalls) && (
<ActionRow message={message} sessionId={sessionId} />
<ActionRow message={message} />
)}
</div>
);

View File

@@ -1,13 +1,13 @@
import { useEffect, useRef } from 'react';
import type { Message } from '@/api/types';
import type { Chat, Message } from '@/api/types';
import { MessageBubble } from './MessageBubble';
interface Props {
messages: Message[];
sessionId: string;
sessionChats?: Chat[];
}
export function MessageList({ messages, sessionId }: Props) {
export function MessageList({ messages, sessionChats }: Props) {
const endRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -23,11 +23,13 @@ export function MessageList({ messages, sessionId }: Props) {
}
return (
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{messages.map((m) => (
<MessageBubble key={m.id} message={m} sessionId={sessionId} />
))}
<div ref={endRef} />
<div className="flex-1 overflow-y-auto">
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
{messages.map((m) => (
<MessageBubble key={m.id} message={m} sessionChats={sessionChats} />
))}
<div ref={endRef} />
</div>
</div>
);
}

View File

@@ -1,19 +1,28 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { ChevronRight, Folder, MessageSquare, Plus } from 'lucide-react';
import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} 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';
import { cn } from '@/lib/utils';
const EXPANDED_KEY = 'boocode.sidebar.expanded';
@@ -91,6 +100,12 @@ export function ProjectSidebar() {
useSidebar();
const [addOpen, setAddOpen] = useState(false);
const [expanded, setExpanded] = useState<Set<string>>(() => readExpanded());
const [renamingSession, setRenamingSession] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null);
const [renamingProject, setRenamingProject] = useState<string | null>(null);
const [renameProjectValue, setRenameProjectValue] = useState('');
const [archiveProjectConfirm, setArchiveProjectConfirm] = useState<{ id: string; name: string } | null>(null);
const navigate = useNavigate();
const location = useLocation();
const lastToastedError = useRef<string | null>(null);
@@ -123,13 +138,57 @@ export function ProjectSidebar() {
});
}
async function handleRemove(id: string) {
async function handleArchiveProject(id: string) {
try {
await api.projects.remove(id);
sessionEvents.emit({ type: 'project_deleted', project_id: id });
navigate('/');
await api.projects.archive(id);
// Server publishes project_archived via WS.
if (activeProject === id) navigate('/');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to remove project');
toast.error(err instanceof Error ? err.message : 'failed to archive project');
}
}
async function handleRenameProject(id: string) {
const trimmed = renameProjectValue.trim();
setRenamingProject(null);
if (!trimmed) return;
try {
await api.projects.update(id, { name: trimmed });
// Server publishes project_updated via WS.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to rename project');
}
}
async function handleArchiveSession(sessionId: string, projectId: string) {
try {
await api.sessions.archive(sessionId);
// Server publishes session_archived via WS; useUserEvents delivers it.
if (activeSession === sessionId) navigate(`/project/${projectId}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to archive session');
}
}
async function handleDeleteSession(sessionId: string, projectId: string) {
try {
await api.sessions.remove(sessionId);
// Server publishes session_deleted via WS; useUserEvents delivers it.
if (activeSession === sessionId) navigate(`/project/${projectId}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to delete session');
}
}
async function handleRenameSession(sessionId: string) {
const trimmed = renameValue.trim();
setRenamingSession(null);
if (!trimmed) return;
try {
await api.sessions.update(sessionId, { name: trimmed });
sessionEvents.emit({ type: 'session_renamed', session_id: sessionId, name: trimmed });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to rename session');
}
}
@@ -175,67 +234,126 @@ export function ProjectSidebar() {
const visible = p.recent_sessions.slice(0, MAX_VISIBLE_SESSIONS);
return (
<div key={p.id} className="px-2">
<DropdownMenu>
<div
className={`group flex items-center gap-1 rounded-md px-2 py-1.5 text-sm ${rowCls(isActiveProject)}`}
onContextMenu={(e) => {
e.preventDefault();
(
e.currentTarget.parentElement?.querySelector(
'[data-ctxtrigger]'
) as HTMLElement | null
)?.click();
}}
>
<button
type="button"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
aria-expanded={isExpanded}
disabled={isActiveProject}
onClick={(e) => {
e.stopPropagation();
if (isActiveProject) return;
toggle(p.id);
}}
className={cn(
'flex items-center justify-center size-4 shrink-0 opacity-70 hover:opacity-100',
isActiveProject &&
'opacity-50 cursor-not-allowed hover:opacity-50'
)}
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className={`group flex items-center gap-1 rounded-md px-2 py-1.5 text-sm ${rowCls(isActiveProject)}`}
>
<ChevronRight
className={`size-3.5 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
/>
</button>
<NavLink to={`/project/${p.id}`} className="flex items-center gap-2 min-w-0 flex-1">
<Folder className="size-3.5 shrink-0 opacity-70" />
<span className="truncate" title={p.name}>{p.name}</span>
</NavLink>
</div>
<DropdownMenuTrigger asChild>
<button data-ctxtrigger className="hidden" aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem variant="destructive" onClick={() => void handleRemove(p.id)}>
Remove from sidebar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
type="button"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
aria-expanded={isExpanded}
disabled={isActiveProject}
onClick={(e) => {
e.stopPropagation();
if (isActiveProject) return;
toggle(p.id);
}}
className={cn(
'flex items-center justify-center size-4 shrink-0 opacity-70 hover:opacity-100',
isActiveProject &&
'opacity-50 cursor-not-allowed hover:opacity-50'
)}
>
<ChevronRight
className={`size-3.5 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
/>
</button>
{renamingProject === p.id ? (
<div className="flex items-center gap-2 min-w-0 flex-1">
<Folder className="size-3.5 shrink-0 opacity-70" />
<input
autoFocus
value={renameProjectValue}
onChange={(e) => setRenameProjectValue(e.target.value)}
onBlur={() => void handleRenameProject(p.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') void handleRenameProject(p.id);
if (e.key === 'Escape') setRenamingProject(null);
}}
className="bg-transparent border-b border-border text-sm outline-none flex-1 min-w-0"
/>
</div>
) : (
<NavLink to={`/project/${p.id}`} className="flex items-center gap-2 min-w-0 flex-1">
<Folder className="size-3.5 shrink-0 opacity-70" />
<span className="truncate" title={p.name}>{p.name}</span>
</NavLink>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => {
setRenamingProject(p.id);
setRenameProjectValue(p.name);
}}>
Rename
</ContextMenuItem>
<ContextMenuItem onSelect={() => setArchiveProjectConfirm({ id: p.id, name: p.name })}>
Archive
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => {
const url = giteaUrlFor({ path: p.path, gitea_remote: p.gitea_remote });
window.open(url, '_blank', 'noopener');
}}>
<ExternalLink size={12} /> Open in Gitea
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{isExpanded && (
<div className="ml-5 mt-0.5 space-y-0.5">
{visible.map((s) => (
<NavLink
key={s.id}
to={`/session/${s.id}`}
className={`flex items-center gap-2 rounded-md px-2 py-1 text-sm min-w-0 ${rowCls(activeSession === s.id)}`}
>
<MessageSquare className="size-3.5 shrink-0 opacity-70" />
<span className="truncate flex-1" title={s.name}>{s.name}</span>
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
{relTime(s.updated_at)}
</span>
</NavLink>
<ContextMenu key={s.id}>
<ContextMenuTrigger asChild>
{renamingSession === s.id ? (
<div className={`flex items-center gap-2 rounded-md px-2 py-1 text-sm min-w-0 ${rowCls(activeSession === s.id)}`}>
<MessageSquare className="size-3.5 shrink-0 opacity-70" />
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => void handleRenameSession(s.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') void handleRenameSession(s.id);
if (e.key === 'Escape') setRenamingSession(null);
}}
className="bg-transparent border-b border-border text-sm outline-none flex-1 min-w-0"
/>
</div>
) : (
<NavLink
to={`/session/${s.id}`}
className={`flex items-center gap-2 rounded-md px-2 py-1 text-sm min-w-0 ${rowCls(activeSession === s.id)}`}
>
<MessageSquare className="size-3.5 shrink-0 opacity-70" />
<span className="truncate flex-1" title={s.name}>{s.name}</span>
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
{relTime(s.updated_at)}
</span>
</NavLink>
)}
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => {
setRenamingSession(s.id);
setRenameValue(s.name);
}}>
Rename
</ContextMenuItem>
<ContextMenuItem onSelect={() => void handleArchiveSession(s.id, p.id)}>
Archive
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
onSelect={() => setDeleteConfirm({ id: s.id, name: s.name })}
>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
{p.total_sessions > MAX_VISIBLE_SESSIONS && (
<NavLink
@@ -253,6 +371,60 @@ export function ProjectSidebar() {
</nav>
<AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={() => {}} />
<Dialog open={archiveProjectConfirm !== null} onOpenChange={(open) => { if (!open) setArchiveProjectConfirm(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Archive project?</DialogTitle>
<DialogDescription>
Removes {archiveProjectConfirm ? `"${archiveProjectConfirm.name}"` : 'this project'} from the sidebar. Files on disk are untouched. You can restore it later from the Archived Projects view.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => setArchiveProjectConfirm(null)}>
Cancel
</Button>
<Button
onClick={() => {
if (archiveProjectConfirm) void handleArchiveProject(archiveProjectConfirm.id);
setArchiveProjectConfirm(null);
}}
>
Archive
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) setDeleteConfirm(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete session?</DialogTitle>
<DialogDescription>
This will permanently delete {deleteConfirm ? `"${deleteConfirm.name}"` : 'this session'} and all its chats and messages. This cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
if (deleteConfirm) {
const projectId = projects.find((p) =>
p.recent_sessions.some((s) => s.id === deleteConfirm.id)
)?.id;
if (projectId) void handleDeleteSession(deleteConfirm.id, projectId);
}
setDeleteConfirm(null);
}}
>
Delete
</Button>
</div>
</DialogContent>
</Dialog>
</aside>
);
}

View File

@@ -0,0 +1,264 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ChevronRight, ChevronDown, FileText, Folder, PanelRightClose, PanelRightOpen } from 'lucide-react';
import { api } from '@/api/client';
import type { FileEntry } from '@/api/types';
import { inferLanguage } from '@/lib/attachments';
import { sessionEvents } from '@/hooks/sessionEvents';
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
import { Input } from '@/components/ui/input';
interface Props {
projectId: string;
}
const STORAGE_KEY = 'boocode.rightrail';
function basename(path: string): string {
if (!path) return '';
const parts = path.split('/');
return parts[parts.length - 1] ?? path;
}
function joinPath(parent: string, name: string): string {
if (!parent || parent === '.' || parent === '') return name;
return `${parent}/${name}`;
}
export function RightRail({ projectId }: Props) {
const [open, setOpen] = useState(() => {
try { return localStorage.getItem(`${STORAGE_KEY}.open`) !== 'false'; } catch { return true; }
});
const [filter, setFilter] = useState('');
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [cache, setCache] = useState<Map<string, FileEntry[]>>(new Map());
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
const [viewerFile, setViewerFile] = useState<{ path: string; content: string } | null>(null);
useEffect(() => {
try { localStorage.setItem(`${STORAGE_KEY}.open`, String(open)); } catch {}
}, [open]);
useEffect(() => {
let cancelled = false;
api.projects.files(projectId).then((r) => {
if (!cancelled) setFullFileList(r.files);
}).catch(() => {});
return () => { cancelled = true; };
}, [projectId]);
const loadDir = useCallback(async (dirPath: string) => {
const apiPath = dirPath === '' ? '.' : dirPath;
try {
const result = await api.projects.listDir(projectId, apiPath);
setCache((prev) => { const next = new Map(prev); next.set(dirPath, result.entries); return next; });
} catch { /* ignore */ }
}, [projectId]);
useEffect(() => {
if (!open) return;
if (!cache.has('')) void loadDir('');
}, [open, cache, loadDir]);
function toggleDir(dirPath: string) {
setExpandedDirs((prev) => {
const next = new Set(prev);
if (next.has(dirPath)) {
next.delete(dirPath);
} else {
next.add(dirPath);
if (!cache.has(dirPath)) void loadDir(dirPath);
}
return next;
});
}
async function openFile(path: string) {
try {
const result = await api.projects.viewFile(projectId, path);
setViewerFile({ path, content: result.content });
} catch { /* ignore */ }
}
// Filter results
const trimmed = filter.trim().toLowerCase();
const filterActive = trimmed.length > 0;
interface FilterResult { path: string; name: string; }
const filterResults = useMemo<FilterResult[]>(() => {
if (!filterActive) return [];
if (fullFileList) {
const filenameMatches: string[] = [];
const pathOnly: string[] = [];
for (const p of fullFileList) {
const lp = p.toLowerCase();
if (!lp.includes(trimmed)) continue;
if (basename(p).toLowerCase().includes(trimmed)) filenameMatches.push(p);
else pathOnly.push(p);
}
filenameMatches.sort((a, b) => a.localeCompare(b));
pathOnly.sort((a, b) => a.localeCompare(b));
return [...filenameMatches, ...pathOnly].slice(0, 50).map((p) => ({ path: p, name: basename(p) }));
}
return [];
}, [filterActive, trimmed, fullFileList]);
// Listen for open_file_in_browser events
useEffect(() => {
return sessionEvents.subscribe((event) => {
if (event.type !== 'open_file_in_browser') return;
if (!open) setOpen(true);
void openFile(event.path);
});
}, [open, projectId]);
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="shrink-0 border-l bg-sidebar p-2 hover:bg-muted"
aria-label="Open file browser"
>
<PanelRightOpen size={16} />
</button>
);
}
const rootEntries = cache.get('') ?? [];
return (
<>
<aside className="w-64 shrink-0 border-l bg-sidebar flex flex-col h-full overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b shrink-0">
<span className="text-xs font-medium flex-1">Files</span>
<button
type="button"
onClick={() => setOpen(false)}
className="p-1 rounded hover:bg-muted text-muted-foreground"
aria-label="Close file browser"
>
<PanelRightClose size={14} />
</button>
</div>
<div className="px-2 py-1.5 shrink-0">
<Input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter files..."
className="h-7 text-xs"
/>
</div>
<div className="flex-1 overflow-y-auto px-1 py-1">
{filterActive ? (
filterResults.length > 0 ? (
<ul className="list-none space-y-0.5">
{filterResults.map((r) => (
<li key={r.path}>
<button
type="button"
className="w-full flex items-center gap-1 px-2 py-1 text-xs rounded hover:bg-muted/60 text-left"
onClick={() => void openFile(r.path)}
>
<FileText size={12} className="text-muted-foreground shrink-0" />
<span className="font-bold truncate">{r.name}</span>
<span className="text-muted-foreground ml-1 truncate">{r.path}</span>
</button>
</li>
))}
</ul>
) : (
<div className="text-xs text-muted-foreground px-2 py-4 text-center">No matches</div>
)
) : (
<TreeLevel
parentPath=""
entries={rootEntries}
cache={cache}
expanded={expandedDirs}
depth={0}
onToggleDir={toggleDir}
onSelectFile={(path) => void openFile(path)}
/>
)}
</div>
</aside>
{viewerFile && (
<FileViewerOverlay
path={viewerFile.path}
content={viewerFile.content}
lang={inferLanguage(viewerFile.path)}
projectId={projectId}
onClose={() => setViewerFile(null)}
onNavigate={(path) => void openFile(path)}
/>
)}
</>
);
}
interface TreeLevelProps {
parentPath: string;
entries: FileEntry[];
cache: Map<string, FileEntry[]>;
expanded: Set<string>;
depth: number;
onToggleDir: (dirPath: string) => void;
onSelectFile: (path: string) => void;
}
function TreeLevel({ parentPath, entries, cache, expanded, depth, onToggleDir, onSelectFile }: TreeLevelProps) {
const sorted = useMemo(() => {
const copy = [...entries];
copy.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1;
return a.name.localeCompare(b.name);
});
return copy;
}, [entries]);
return (
<ul className="list-none">
{sorted.map((entry) => {
const fullPath = joinPath(parentPath, entry.name);
const isExpanded = entry.kind === 'dir' && expanded.has(fullPath);
return (
<li key={fullPath}>
<div
className="flex items-center gap-1 px-1 py-0.5 text-xs cursor-default rounded hover:bg-muted/60"
style={{ paddingLeft: 4 + depth * 12 }}
onClick={() => {
if (entry.kind === 'dir') onToggleDir(fullPath);
else onSelectFile(fullPath);
}}
>
{entry.kind === 'dir' ? (
isExpanded ? <ChevronDown size={10} className="shrink-0" /> : <ChevronRight size={10} className="shrink-0" />
) : (
<span className="w-[10px] shrink-0" />
)}
{entry.kind === 'dir' ? (
<Folder size={12} className="text-muted-foreground shrink-0" />
) : (
<FileText size={12} className="text-muted-foreground shrink-0" />
)}
<span className="truncate">{entry.name}</span>
</div>
{entry.kind === 'dir' && isExpanded && cache.has(fullPath) && (
<TreeLevel
parentPath={fullPath}
entries={cache.get(fullPath) ?? []}
cache={cache}
expanded={expanded}
depth={depth + 1}
onToggleDir={onToggleDir}
onSelectFile={onSelectFile}
/>
)}
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,394 @@
import { useState } from 'react';
import { Archive, MessageSquare, Send, ChevronDown, ChevronRight, RotateCcw, Trash2 } from 'lucide-react';
import type { Chat } from '@/api/types';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { formatTokens } from '@/lib/format';
interface Props {
sessionId: string;
projectId: string;
chats: Chat[];
onOpenChat: (chatId: string) => void;
onSend: (content: string) => void;
onReopenChat: (chatId: string) => Promise<void>;
onArchiveChat: (chatId: string) => Promise<void>;
onRenameChat: (chatId: string, name: string) => Promise<void>;
onDeleteChat: (chatId: string) => Promise<void>;
}
function relTime(iso: string): string {
const now = Date.now();
const t = Date.parse(iso);
if (Number.isNaN(t)) return '';
const sec = Math.max(0, Math.floor((now - t) / 1000));
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
return `${day}d ago`;
}
interface ChatRowProps {
chat: Chat;
onClick: () => void;
dimmed?: boolean;
trailing?: React.ReactNode;
actions?: React.ReactNode;
renamingId: string | null;
renameValue: string;
setRenameValue: (s: string) => void;
onFinishRename: () => void;
onCancelRename: () => void;
onContextStartRename: () => void;
onContextArchive: () => void;
onContextDelete: () => void;
showContextMenu: boolean;
}
function ChatRow({
chat,
onClick,
dimmed,
trailing,
actions,
renamingId,
renameValue,
setRenameValue,
onFinishRename,
onCancelRename,
onContextStartRename,
onContextArchive,
onContextDelete,
showContextMenu,
}: ChatRowProps) {
const meta: string[] = [relTime(chat.updated_at)];
if (chat.message_count !== undefined && chat.message_count > 0) {
meta.push(`${chat.message_count} msg`);
}
const tokens = formatTokens(chat.effective_context_tokens);
if (tokens) meta.push(tokens);
const preview = chat.last_message_preview;
const isRenaming = renamingId === chat.id;
const inner = (
<button
type="button"
onClick={onClick}
className="w-full flex flex-col gap-0.5 px-3 py-2 hover:bg-muted/50 text-left"
>
<div className="flex items-center gap-2 min-w-0">
<MessageSquare className={`size-3.5 shrink-0 ${dimmed ? 'opacity-40' : 'opacity-70'}`} />
{isRenaming ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => onFinishRename()}
onKeyDown={(e) => {
if (e.key === 'Enter') onFinishRename();
if (e.key === 'Escape') onCancelRename();
}}
onClick={(e) => e.stopPropagation()}
className="bg-transparent border-b border-border text-sm outline-none flex-1 min-w-0"
/>
) : (
<span className={`truncate text-sm flex-1 ${dimmed ? 'text-muted-foreground' : ''}`}>
{chat.name ?? 'New chat'}
</span>
)}
{trailing && (
<span className="text-xs text-muted-foreground shrink-0">{trailing}</span>
)}
{actions && (
<div className="flex items-center gap-0.5 shrink-0">{actions}</div>
)}
</div>
<div className="ml-5 text-xs text-muted-foreground tabular-nums">
{meta.join(' · ')}
</div>
{preview && (
<div className="ml-5 text-xs italic text-muted-foreground truncate">
{preview}
</div>
)}
</button>
);
if (!showContextMenu) return inner;
return (
<ContextMenu>
<ContextMenuTrigger asChild>{inner}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={onClick}>Open</ContextMenuItem>
<ContextMenuItem onSelect={onContextStartRename}>Rename</ContextMenuItem>
<ContextMenuItem onSelect={onContextArchive}>Archive</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive" onSelect={onContextDelete}>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
export function SessionLandingPage({
chats,
onOpenChat,
onSend,
onReopenChat,
onArchiveChat,
onRenameChat,
onDeleteChat,
}: Props) {
const [composerValue, setComposerValue] = useState('');
const [showArchived, setShowArchived] = useState(false);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [archiveConfirm, setArchiveConfirm] = useState<Chat | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<Chat | null>(null);
const [deleteInput, setDeleteInput] = useState('');
const openChats = chats
.filter((c) => c.status === 'open')
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
const archivedChats = chats
.filter((c) => c.status === 'archived')
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
function handleSend() {
const text = composerValue.trim();
if (!text) return;
onSend(text);
setComposerValue('');
}
function startRename(chat: Chat) {
setRenamingId(chat.id);
setRenameValue(chat.name ?? '');
}
async function finishRename() {
if (renamingId && renameValue.trim()) {
await onRenameChat(renamingId, renameValue.trim());
}
setRenamingId(null);
}
const deleteExpected = deleteConfirm?.name ?? '';
const deleteEnabled = deleteConfirm !== null && deleteInput === deleteExpected && deleteExpected.length > 0;
// TODO: Landing page chat counts are a snapshot at mount. New messages in
// visible chats won't update the per-row stats until next mount/navigation.
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
{openChats.length > 0 && (
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-2">Open chats</h3>
<ul className="divide-y rounded-md border">
{openChats.map((chat) => (
<li key={chat.id}>
<ChatRow
chat={chat}
onClick={() => onOpenChat(chat.id)}
renamingId={renamingId}
renameValue={renameValue}
setRenameValue={setRenameValue}
onFinishRename={() => void finishRename()}
onCancelRename={() => setRenamingId(null)}
onContextStartRename={() => startRename(chat)}
onContextArchive={() => setArchiveConfirm(chat)}
onContextDelete={() => { setDeleteConfirm(chat); setDeleteInput(''); }}
showContextMenu
actions={
<>
<Button
variant="ghost"
size="icon-sm"
aria-label="Archive chat"
title="Archive chat"
onClick={(e) => {
e.stopPropagation();
setArchiveConfirm(chat);
}}
>
<Archive size={14} />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Delete chat"
title="Delete chat"
className="text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
setDeleteConfirm(chat);
setDeleteInput('');
}}
>
<Trash2 size={14} />
</Button>
</>
}
/>
</li>
))}
</ul>
</div>
)}
{archivedChats.length > 0 && (
<div>
<button
type="button"
onClick={() => setShowArchived(!showArchived)}
className="flex items-center gap-1 text-xs font-medium text-muted-foreground mb-2 hover:text-foreground"
>
{showArchived ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
Archived chats ({archivedChats.length})
</button>
{showArchived && (
<ul className="divide-y rounded-md border">
{archivedChats.map((chat) => (
<li key={chat.id}>
<ChatRow
chat={chat}
onClick={() => void onReopenChat(chat.id)}
dimmed
trailing={<><RotateCcw size={10} className="inline mr-1" />Restore</>}
renamingId={null}
renameValue=""
setRenameValue={() => {}}
onFinishRename={() => {}}
onCancelRename={() => {}}
onContextStartRename={() => {}}
onContextArchive={() => {}}
onContextDelete={() => {}}
showContextMenu={false}
/>
</li>
))}
</ul>
)}
</div>
)}
{openChats.length === 0 && archivedChats.length === 0 && (
<div className="text-sm text-muted-foreground py-8 text-center">
No chats yet. Type below to start a conversation.
</div>
)}
</div>
<div className="border-t px-4 py-3 flex items-end gap-2 shrink-0">
<Textarea
value={composerValue}
onChange={(e) => setComposerValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSend();
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder="Start a new chat..."
rows={2}
className="resize-none min-h-[52px] max-h-[160px]"
/>
<Button
onClick={handleSend}
disabled={!composerValue.trim()}
size="icon-lg"
aria-label="Send"
>
<Send />
</Button>
</div>
<Dialog open={archiveConfirm !== null} onOpenChange={(open) => { if (!open) setArchiveConfirm(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Archive chat?</DialogTitle>
<DialogDescription>
Moves {archiveConfirm ? `"${archiveConfirm.name ?? 'New chat'}"` : 'this chat'} to the Archived chats section. You can restore it any time.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => setArchiveConfirm(null)}>
Cancel
</Button>
<Button
onClick={() => {
if (archiveConfirm) void onArchiveChat(archiveConfirm.id);
setArchiveConfirm(null);
}}
>
Archive
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) { setDeleteConfirm(null); setDeleteInput(''); } }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete chat?</DialogTitle>
<DialogDescription>
Type the chat name to confirm:
{' '}
<span className="font-mono font-medium text-foreground">{deleteExpected || '(unnamed — cannot type-confirm)'}</span>
</DialogDescription>
</DialogHeader>
<Input
value={deleteInput}
onChange={(e) => setDeleteInput(e.target.value)}
placeholder={deleteExpected}
disabled={!deleteExpected}
/>
<div className="text-xs text-muted-foreground">
This will permanently delete this chat and all its messages. This cannot be undone.
</div>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => { setDeleteConfirm(null); setDeleteInput(''); }}>
Cancel
</Button>
<Button
variant="destructive"
disabled={!deleteEnabled}
onClick={() => {
if (deleteConfirm && deleteEnabled) void onDeleteChat(deleteConfirm.id);
setDeleteConfirm(null);
setDeleteInput('');
}}
>
Delete
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,13 +1,19 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { DragEvent } from 'react';
import { Plus } from 'lucide-react';
import { usePanes } from '@/hooks/usePanes';
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import type { FileBrowserPaneState, Pane, PaneKind } from '@/api/types';
import { PaneTab } from '@/components/PaneTab';
import { PaneShell } from '@/components/panes/PaneShell';
import type { Chat, WorkspacePane } from '@/api/types';
import { ChatPane } from '@/components/panes/ChatPane';
import { FileBrowserPane } from '@/components/panes/FileBrowserPane';
import { ChatTabBar } from '@/components/ChatTabBar';
import { SessionLandingPage } from '@/components/SessionLandingPage';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
interface Props {
@@ -16,324 +22,476 @@ interface Props {
}
const MAX_PANES = 5;
const STORAGE_KEY = 'boocode.workspace.panes';
function PaneSkeleton() {
return (
<div className="flex flex-col h-full">
<div className="flex items-center border-b border-border bg-muted/20 h-8" />
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">
Loading panes...
</div>
</div>
);
function generateId(): string {
return crypto.randomUUID();
}
function PaneError({
message,
onRetry,
}: {
message: string;
onRetry: () => void | Promise<void>;
}) {
return (
<div className="flex flex-col h-full items-center justify-center gap-2 text-sm">
<span className="text-destructive">{message}</span>
<button
type="button"
onClick={() => void onRetry()}
className="text-xs underline text-muted-foreground hover:text-foreground"
>
Retry
</button>
</div>
);
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, loading, error, create, update, remove, refresh } =
usePanes(sessionId);
const [activeId, setActiveId] = useState<string | null>(null);
const draggingIdRef = useRef<string | null>(null);
const [panes, setPanes] = useState<WorkspacePane[]>(() => {
return loadPanes(sessionId) ?? [emptyPane()];
});
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);
// Keep latest panes in a ref so the event-bus subscription doesn't need
// to re-subscribe whenever the list changes (which would race with rapid
// updates).
const panesRef = useRef<Pane[] | null>(null);
panesRef.current = panes;
// Default active: first pane (and reset if the active one disappears)
useEffect(() => {
if (!panes || panes.length === 0) {
if (activeId !== null) setActiveId(null);
return;
}
if (!panes.some((p) => p.id === activeId)) {
setActiveId(panes[0]!.id);
}
}, [panes, activeId]);
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]);
// Tracks an in-flight create() call so rapid open_file_in_browser events
// don't race to each spawn a new file_browser pane. While a create is in
// progress the subsequent events wait for it and update the same pane.
const creatingRef = useRef<{ id: string; promise: Promise<string> } | null>(
null
);
useEffect(() => {
savePanes(sessionId, panes);
}, [sessionId, panes]);
// Subscribe to open_file_in_browser events: focus an existing file_browser
// pane (updating its open_file) or spawn one if room is available.
useEffect(() => {
return sessionEvents.subscribe((event) => {
if (event.type !== 'open_file_in_browser') return;
void (async () => {
// If a create is already in flight, wait for it to finish then update
// the newly-created pane rather than spawning a second one.
if (creatingRef.current) {
const { id: pendingId, promise } = creatingRef.current;
const resolvedId = await promise;
const targetId = resolvedId || pendingId;
const current = panesRef.current;
const fb = current?.find((p) => p.id === targetId);
const nextState: FileBrowserPaneState = {
...(fb?.kind === 'file_browser' ? fb.state : {}),
open_file: event.path,
};
await update(targetId, { state: nextState });
setActiveId(targetId);
return;
}
const current = panesRef.current;
if (!current) return;
const fb = current.find(
(p): p is Pane & { kind: 'file_browser' } =>
p.kind === 'file_browser'
);
if (fb) {
const nextState: FileBrowserPaneState = {
...fb.state,
open_file: event.path,
};
await update(fb.id, { state: nextState });
setActiveId(fb.id);
} else if (current.length < MAX_PANES) {
// Reserve the slot immediately so concurrent events see the flag.
const createPromise = (async (): Promise<string> => {
const newPane = await create({ kind: 'file_browser' });
return newPane.id;
})();
// Use a stable object; id is filled in once resolved.
const entry: { id: string; promise: Promise<string> } = {
id: '',
promise: createPromise,
};
creatingRef.current = entry;
try {
const newId = await createPromise;
entry.id = newId;
const nextState: FileBrowserPaneState = {
open_file: event.path,
filter: '',
expanded_dirs: [],
};
await update(newId, { state: nextState });
setActiveId(newId);
} finally {
if (creatingRef.current === entry) {
creatingRef.current = null;
}
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);
}
});
}, [create, update]);
}, [sessionId]);
const handleClose = useCallback(
async (id: string) => {
try {
await remove(id);
} catch {
/* error surfaced via hook state */
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 };
}
},
[remove]
);
const nextActiveIdx = Math.min(p.activeChatIdx, nextIds.length - 1);
return {
...p,
chatIds: nextIds,
activeChatIdx: nextActiveIdx,
chatId: nextIds[nextActiveIdx],
};
}));
}
const handleSplit = useCallback(
async (afterIdx: number, kind: PaneKind) => {
const current = panesRef.current;
if (!current || current.length >= MAX_PANES) return;
try {
const created = await create({ kind, position: afterIdx + 1 });
setActiveId(created.id);
} catch {
/* error surfaced via hook state */
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,
};
}
},
[create]
);
return next;
});
setActivePaneIdx(paneIdx);
}, []);
const handleCloseOthers = useCallback(
async (id: string) => {
const current = panesRef.current;
if (!current) return;
const targets = current.filter((p) => p.id !== id).map((p) => p.id);
for (const targetId of targets) {
try {
await remove(targetId);
} catch {
// Stop on first failure to avoid cascading errors.
return;
}
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],
};
}
},
[remove]
);
return next;
});
}, []);
const handleCloseToRight = useCallback(
async (idx: number) => {
const current = panesRef.current;
if (!current) return;
const targets = current.slice(idx + 1).map((p) => p.id);
for (const targetId of targets) {
try {
await remove(targetId);
} catch {
return;
}
}
},
[remove]
);
// 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;
});
}, []);
const handleCloseAll = useCallback(async () => {
const current = panesRef.current;
if (!current) return;
const targets = current.map((p) => p.id);
for (const targetId of targets) {
try {
await remove(targetId);
} catch {
return;
}
}
}, [remove]);
// 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;
});
}, []);
const handleAdd = useCallback(async () => {
const current = panesRef.current;
if (current && current.length >= MAX_PANES) return;
// 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 created = await create({ kind: 'chat' });
setActiveId(created.id);
} catch {
/* error surfaced via hook state */
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');
}
}, [create]);
}, [sessionId, openChatInPane]);
const handleDragStart = useCallback(
(id: string) => (e: DragEvent<HTMLDivElement>) => {
draggingIdRef.current = id;
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', id);
e.dataTransfer.setData('text/plain', String(idx));
},
[]
);
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}, []);
const handleDrop = useCallback(
(targetIdx: number) => async (e: DragEvent<HTMLDivElement>) => {
const handlePaneDragOver = useCallback(
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
if (draggingIdxRef.current === null) return;
e.preventDefault();
const draggedId =
draggingIdRef.current || e.dataTransfer.getData('text/plain');
draggingIdRef.current = null;
if (!draggedId) return;
const current = panesRef.current;
if (!current) return;
const draggedIdx = current.findIndex((p) => p.id === draggedId);
if (draggedIdx < 0 || draggedIdx === targetIdx) return;
try {
await update(draggedId, { position: targetIdx });
} catch {
/* error surfaced via hook state */
}
e.dataTransfer.dropEffect = 'move';
if (dragOverIdx !== idx) setDragOverIdx(idx);
},
[update]
[dragOverIdx]
);
if (loading && !panes) return <PaneSkeleton />;
if (error && !panes) return <PaneError message={error} onRetry={refresh} />;
if (!panes) return <PaneSkeleton />;
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
.map((id) => chats.find((c) => c.id === id))
.filter((c): c is Chat => c !== undefined);
}
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={panes.length >= MAX_PANES}
className={cn(
'flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted',
panes.length >= MAX_PANES && 'opacity-40 cursor-not-allowed hover:bg-transparent'
)}
>
<PanelRight size={14} />
Split
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={() => addSplitPane('chat')}>
<MessageSquare size={14} /> Chat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}>
<Terminal size={14} /> Terminal
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => addSplitPane('agent')}>
<Bot size={14} /> Agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div
className="flex items-center border-b border-border bg-muted/20"
role="tablist"
className="flex-1 grid min-h-0"
style={{
gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))`,
}}
>
{panes.map((pane, idx) => (
<PaneTab
<div
key={pane.id}
pane={pane}
isActive={pane.id === activeId}
onClick={() => setActiveId(pane.id)}
onClose={() => void handleClose(pane.id)}
onSplit={(kind) => void handleSplit(idx, kind)}
onCloseOthers={() => void handleCloseOthers(pane.id)}
onCloseToRight={() => void handleCloseToRight(idx)}
onCloseAll={() => void handleCloseAll()}
onDragStart={handleDragStart(pane.id)}
onDragOver={handleDragOver}
onDrop={handleDrop(idx)}
/>
))}
<button
type="button"
onClick={() => void handleAdd()}
disabled={panes.length >= MAX_PANES}
className={cn(
'p-1.5 ml-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground',
panes.length >= MAX_PANES && 'opacity-40 cursor-not-allowed hover:bg-transparent'
)}
aria-label="Add pane"
>
<Plus size={14} />
</button>
</div>
{panes.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">
No panes. Click + to add one.
</div>
) : (
<div
className="flex-1 grid min-h-0"
style={{
gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))`,
}}
>
{panes.map((pane) => (
<PaneShell
key={pane.id}
pane={pane}
onClose={() => void handleClose(pane.id)}
className={cn(
'flex flex-col h-full min-h-0 border-r border-border last:border-r-0 relative',
idx === activePaneIdx && 'ring-1 ring-inset ring-ring/20',
dragOverIdx === idx && draggingIdxRef.current !== idx &&
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
)}
onClick={() => setActivePaneIdx(idx)}
onDragOver={panes.length > 1 ? handlePaneDragOver(idx) : undefined}
onDragLeave={panes.length > 1 ? handlePaneDragLeave : undefined}
onDrop={panes.length > 1 ? handlePaneDrop(idx) : undefined}
>
<div
draggable={panes.length > 1}
onDragStart={panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={panes.length > 1 ? handlePaneDragEnd : undefined}
>
{pane.kind === 'chat' ? (
<ChatPane sessionId={sessionId} />
<ChatTabBar
pane={pane}
tabs={chatsForPane(pane)}
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
onRemoveTab={(chatId) => removeTab(idx, chatId)}
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
onCloseAll={() => closeAllTabs(idx)}
onNewChat={() => void createChat(idx)}
onShowHistory={() => showLandingPage(idx)}
onRename={renameChat}
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
/>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{pane.kind === 'chat' && pane.chatId ? (
<ChatPane sessionId={sessionId} chatId={pane.chatId} projectId={projectId} sessionChats={chats} />
) : (
<FileBrowserPane
pane={pane}
<SessionLandingPage
sessionId={sessionId}
projectId={projectId}
onStateChange={(state) =>
void update(pane.id, { state })
}
chats={chats}
onOpenChat={(chatId) => openChatInPane(idx, chatId)}
onSend={(content) => void handleLandingSend(idx, content)}
onReopenChat={async (chatId) => {
await unarchiveChat(chatId);
openChatInPane(idx, chatId);
}}
onArchiveChat={archiveChat}
onRenameChat={renameChat}
onDeleteChat={deleteChat}
/>
)}
</PaneShell>
))}
</div>
)}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,19 +1,30 @@
import { useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronDown, Square, X } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import { useSessionStream } from '@/hooks/useSessionStream';
import { MessageList } from '@/components/MessageList';
import { ChatInput } from '@/components/ChatInput';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface Props {
sessionId: string;
chatId: string;
projectId: string;
sessionChats?: import('@/api/types').Chat[];
}
export function ChatPane({ sessionId }: Props) {
export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props) {
const stream = useSessionStream(sessionId);
const lastErrorRef = useRef<string | null>(null);
const [queue, setQueue] = useState<string[]>([]);
const processingRef = useRef(false);
// Surface stream errors via toast — matches Session.tsx behavior.
useEffect(() => {
if (stream.error && stream.error !== lastErrorRef.current) {
lastErrorRef.current = stream.error;
@@ -24,16 +35,134 @@ export function ChatPane({ sessionId }: Props) {
}
}, [stream.error]);
async function handleSend(content: string) {
await api.messages.send(sessionId, content);
const chatMessages = stream.messages.filter((m) => m.chat_id === chatId);
const streaming = chatMessages.some((m) => m.status === 'streaming');
// Auto-send next queued message when streaming completes
useEffect(() => {
if (streaming || queue.length === 0 || processingRef.current) return;
processingRef.current = true;
const next = queue[0]!;
setQueue((prev) => prev.slice(1));
api.messages.send(chatId, next)
.catch((err) => toast.error(err instanceof Error ? err.message : 'queue send failed'))
.finally(() => { processingRef.current = false; });
}, [streaming, queue, chatId]);
const handleSend = useCallback(async (content: string) => {
const trimmed = content.trim();
if (!trimmed) return;
if (trimmed === '/compact') {
try {
await api.chats.compact(chatId);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'compact failed');
}
return;
}
if (streaming) {
setQueue((prev) => [...prev, trimmed]);
return;
}
await api.messages.send(chatId, trimmed);
}, [chatId, streaming]);
async function handleStop() {
try {
await api.chats.stop(chatId);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'stop failed');
}
}
const streaming = stream.messages.some((m) => m.status === 'streaming');
const handleForceSend = useCallback(async (content: string) => {
const trimmed = content.trim();
if (!trimmed) return;
try {
await api.chats.forceSend(chatId, trimmed);
setQueue([]);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'force send failed');
}
}, [chatId]);
function removeQueued(idx: number) {
setQueue((prev) => prev.filter((_, i) => i !== idx));
}
async function forceSendQueued(idx: number) {
const msg = queue[idx];
if (!msg) return;
setQueue((prev) => prev.filter((_, i) => i !== idx));
try {
await api.chats.forceSend(chatId, msg);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'force send failed');
}
}
return (
<div className="flex flex-col h-full min-h-0">
<MessageList messages={stream.messages} sessionId={sessionId} />
<ChatInput disabled={streaming} onSend={handleSend} />
<MessageList messages={chatMessages} sessionChats={sessionChats} />
{/* Queued messages */}
{queue.length > 0 && (
<div className="border-t">
<div className="max-w-[1000px] mx-auto w-full px-4 py-1 space-y-1">
{queue.map((msg, i) => (
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/30 rounded px-2 py-1">
<span className="font-medium shrink-0">Queued:</span>
<span className="truncate flex-1">{msg}</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-0.5 hover:bg-muted rounded shrink-0"
aria-label="Queued message options"
>
<ChevronDown size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => { /* default: queued, nothing to do */ }}>
Send when done
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void forceSendQueued(i)}>
Force send now
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
type="button"
onClick={() => removeQueued(i)}
className="p-0.5 hover:bg-muted rounded shrink-0"
aria-label="Cancel queued message"
>
<X size={12} />
</button>
</div>
))}
</div>
</div>
)}
{/* Stop button when streaming */}
{streaming && (
<div className="border-t py-1">
<div className="max-w-[1000px] mx-auto w-full flex justify-center">
<button
type="button"
onClick={() => void handleStop()}
className="flex items-center gap-1.5 text-xs px-3 py-1 rounded-full border hover:bg-muted text-muted-foreground hover:text-foreground"
>
<Square size={10} className="fill-current" />
Stop generating
</button>
</div>
</div>
)}
<ChatInput disabled={false} projectId={projectId} onSend={handleSend} onForceSend={streaming ? handleForceSend : undefined} />
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { KeyboardEvent } from 'react';
import { ChevronRight, ChevronDown, FileText, Folder, X } from 'lucide-react';
import { Check, ChevronRight, ChevronDown, Copy, FileText, Folder, X } from 'lucide-react';
import { codeToHtml } from 'shiki';
import { api, ApiError } from '@/api/client';
import type {
FileBrowserPaneState,
@@ -8,7 +9,8 @@ import type {
Pane,
ViewFileResult,
} from '@/api/types';
import { CodeBlock } from '@/components/CodeBlock';
import { inferLanguage } from '@/lib/attachments';
import { sessionEvents } from '@/hooks/sessionEvents';
import { cn } from '@/lib/utils';
interface Props {
@@ -17,49 +19,113 @@ interface Props {
onStateChange: (state: FileBrowserPaneState) => void;
}
const LANG_BY_EXT: Record<string, string> = {
ts: 'typescript',
tsx: 'tsx',
js: 'javascript',
jsx: 'jsx',
mjs: 'javascript',
cjs: 'javascript',
py: 'python',
go: 'go',
rs: 'rust',
rb: 'ruby',
java: 'java',
c: 'c',
h: 'c',
cpp: 'cpp',
hpp: 'cpp',
cs: 'csharp',
php: 'php',
sh: 'bash',
bash: 'bash',
zsh: 'bash',
yaml: 'yaml',
yml: 'yaml',
json: 'json',
toml: 'toml',
md: 'markdown',
markdown: 'markdown',
sql: 'sql',
dockerfile: 'dockerfile',
html: 'html',
htm: 'html',
css: 'css',
scss: 'scss',
};
const SHIKI_THEME = 'github-dark';
function deriveLang(filePath: string): string | undefined {
// basename
const base = filePath.split('/').pop() ?? filePath;
if (base.toLowerCase() === 'dockerfile') return 'dockerfile';
const dot = base.lastIndexOf('.');
if (dot < 0 || dot === base.length - 1) return undefined;
const ext = base.slice(dot + 1).toLowerCase();
return LANG_BY_EXT[ext];
function 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 {
@@ -230,6 +296,26 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
};
}, []);
// Full file list fetched once on mount for filter mode (covers unexpanded dirs)
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const result = await api.projects.files(projectId);
if (!cancelled) setFullFileList(result.files);
} catch {
// Silently ignore; filter will fall back to cache-based list
}
})();
return () => {
cancelled = true;
};
// Intentionally run once per mount (projectId is stable per pane)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
// Directory cache: dirPath -> entries
const [cache, setCache] = useState<Map<string, FileEntry[]>>(new Map());
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set());
@@ -380,11 +466,43 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
const trimmedFilter = filterDraft.trim();
const filterActive = trimmedFilter.length > 0;
const filterResults = useMemo<FlatEntry[]>(() => {
interface FilterResult {
path: string;
name: string;
}
const filterResults = useMemo<FilterResult[]>(() => {
if (!filterActive) return [];
const needle = trimmedFilter.toLowerCase();
return flattenedAll.filter((e) => e.path.toLowerCase().includes(needle));
}, [filterActive, trimmedFilter, flattenedAll]);
if (fullFileList !== null) {
// Use complete file list from API; rank filename matches above path-only matches
const filenameMatches: string[] = [];
const pathOnlyMatches: string[] = [];
for (const p of fullFileList) {
const lp = p.toLowerCase();
if (!lp.includes(needle)) continue;
const bn = basename(p).toLowerCase();
if (bn.includes(needle)) {
filenameMatches.push(p);
} else {
pathOnlyMatches.push(p);
}
}
filenameMatches.sort((a, b) => a.localeCompare(b));
pathOnlyMatches.sort((a, b) => a.localeCompare(b));
return [...filenameMatches, ...pathOnlyMatches]
.slice(0, 50)
.map((p) => ({ path: p, name: basename(p) }));
}
// Fallback: use cache-based flat list (only loaded directories, files only)
return flattenedAll
.filter((e) => e.kind === 'file' && e.path.toLowerCase().includes(needle))
.slice(0, 50)
.map((e) => ({ path: e.path, name: e.name }));
}, [filterActive, trimmedFilter, fullFileList, flattenedAll]);
// Keyboard navigation
const [highlightedPath, setHighlightedPath] = useState<string | null>(null);
@@ -401,7 +519,38 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
}, [highlightedPath, filterActive, filterResults, flattenedVisible]);
function onTreeKeyDown(e: KeyboardEvent<HTMLDivElement>) {
const list = filterActive ? filterResults : flattenedVisible;
if (filterActive) {
if (filterResults.length === 0) return;
const idx = highlightedPath
? filterResults.findIndex((entry) => entry.path === highlightedPath)
: -1;
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = idx < 0 ? 0 : Math.min(filterResults.length - 1, idx + 1);
const target = filterResults[next];
if (target) setHighlightedPath(target.path);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
const next = idx <= 0 ? 0 : idx - 1;
const target = filterResults[next];
if (target) setHighlightedPath(target.path);
return;
}
if (e.key === 'Enter') {
if (idx < 0) return;
const target = filterResults[idx];
if (!target) return;
e.preventDefault();
// Filter results are always files (API returns only files)
selectFile(target.path);
}
return;
}
// Tree mode: use flattenedVisible which has kind info
const list = flattenedVisible;
if (list.length === 0) return;
const idx = highlightedPath
? list.findIndex((entry) => entry.path === highlightedPath)
@@ -434,6 +583,31 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
}
}
// Line selection state
const [selectedLines, setSelectedLines] = useState<Set<number>>(new Set());
const [selectionAnchor, setSelectionAnchor] = useState<number | null>(null);
function handleLineClick(lineNo: number, shiftKey: boolean) {
if (shiftKey && selectionAnchor !== null) {
const start = Math.min(selectionAnchor, lineNo);
const end = Math.max(selectionAnchor, lineNo);
const range = new Set<number>();
for (let i = start; i <= end; i++) range.add(i);
setSelectedLines(range);
} else {
setSelectedLines(prev => {
const next = new Set(prev);
if (next.has(lineNo)) {
next.delete(lineNo);
} else {
next.add(lineNo);
}
return next;
});
setSelectionAnchor(lineNo);
}
}
// Viewer state
const [viewer, setViewer] = useState<{
path: string;
@@ -490,6 +664,45 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
};
}, [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;
@@ -534,8 +747,7 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
</li>
) : (
filterResults.map((entry) => {
const isActive =
entry.kind === 'file' && openFile === entry.path;
const isActive = openFile === entry.path;
const isHighlight = highlightedPath === entry.path;
return (
<li key={entry.path}>
@@ -547,19 +759,14 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
)}
onClick={() => {
setHighlightedPath(entry.path);
if (entry.kind === 'dir') {
toggleDir(entry.path);
} else {
selectFile(entry.path);
}
selectFile(entry.path);
}}
>
{entry.kind === 'dir' ? (
<Folder size={12} className="text-muted-foreground shrink-0" />
) : (
<FileText size={12} className="text-muted-foreground shrink-0" />
)}
<span className="truncate">{entry.path}</span>
<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>
);
@@ -606,7 +813,7 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
<X size={12} />
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="flex-1 min-h-0 overflow-y-auto relative">
{viewer?.state === 'loading' && (
<div className="text-xs text-muted-foreground px-2 py-1.5">
Loading...
@@ -619,12 +826,33 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
)}
{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>
)}
<CodeBlock code={viewer.result.content} lang={deriveLang(openFile)} />
<FileViewer
code={viewer.result.content}
lang={inferLanguage(openFile)}
selectedLines={selectedLines}
onLineClick={handleLineClick}
/>
</div>
)}
</div>

View File

@@ -2,7 +2,8 @@
// across hooks (e.g. AI rename arriving via WS in the session view needs to
// also refresh the sidebar's session list).
import type { Project, Session } from '@/api/types';
import type { Chat, Project, Session } from '@/api/types';
import type { Attachment } from '@/lib/attachments';
export interface SessionRenamedEvent {
type: 'session_renamed';
@@ -51,6 +52,64 @@ export interface OpenFileInBrowserEvent {
path: string; // project-relative
}
export interface AttachChatFileEvent {
type: 'attach_chat_file';
attachment: Omit<Attachment, 'id'>;
}
export interface SessionArchivedEvent {
type: 'session_archived';
session_id: string;
project_id: string;
}
export interface ChatCreatedEvent {
type: 'chat_created';
chat: Chat;
session_id: string;
}
export interface ChatUpdatedEvent {
type: 'chat_updated';
chat_id: string;
session_id: string;
name: string | null;
updated_at: string;
}
export interface ChatArchivedEvent {
type: 'chat_archived';
chat_id: string;
session_id: string;
}
export interface ChatUnarchivedEvent {
type: 'chat_unarchived';
chat: Chat;
}
export interface ChatDeletedEvent {
type: 'chat_deleted';
chat_id: string;
session_id: string;
}
export interface ProjectArchivedEvent {
type: 'project_archived';
project_id: string;
}
export interface ProjectUnarchivedEvent {
type: 'project_unarchived';
project: Project;
}
export interface ProjectUpdatedEvent {
type: 'project_updated';
project_id: string;
name: string;
}
export type SessionEvent =
| SessionRenamedEvent
| ProjectCreatedEvent
@@ -59,7 +118,17 @@ export type SessionEvent =
| SessionDeletedEvent
| SessionUpdatedEvent
| SessionLoadedEvent
| OpenFileInBrowserEvent;
| OpenFileInBrowserEvent
| AttachChatFileEvent
| SessionArchivedEvent
| ChatCreatedEvent
| ChatUpdatedEvent
| ChatArchivedEvent
| ChatUnarchivedEvent
| ChatDeletedEvent
| ProjectArchivedEvent
| ProjectUnarchivedEvent
| ProjectUpdatedEvent;
type Listener = (event: SessionEvent) => void;
const listeners = new Set<Listener>();

View File

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

View File

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

View File

@@ -19,8 +19,10 @@ function applyFrame(state: State, frame: WsFrame): State {
const newMsg: Message = {
id: frame.message_id,
session_id: '',
chat_id: frame.chat_id ?? '',
role: frame.role,
content: '',
kind: 'message',
tool_calls: null,
tool_results: null,
status: 'streaming',
@@ -71,8 +73,10 @@ function applyFrame(state: State, frame: WsFrame): State {
const newMsg: Message = {
id: frame.tool_message_id,
session_id: '',
chat_id: frame.chat_id ?? '',
role: 'tool',
content: '',
kind: 'message',
tool_calls: null,
tool_results: {
tool_call_id: frame.tool_call_id,
@@ -115,7 +119,6 @@ function applyFrame(state: State, frame: WsFrame): State {
};
}
case 'session_renamed': {
// Side-effect, not state — dispatch via event bus to other hooks.
sessionEvents.emit({
type: 'session_renamed',
session_id: frame.session_id,
@@ -123,6 +126,16 @@ function applyFrame(state: State, frame: WsFrame): State {
});
return state;
}
case 'chat_renamed': {
sessionEvents.emit({
type: 'chat_updated',
chat_id: frame.chat_id,
session_id: '',
name: frame.name,
updated_at: new Date().toISOString(),
});
return state;
}
case 'error': {
const next = frame.message_id
? state.messages.map((m) =>

View File

@@ -52,9 +52,12 @@ function load(): Promise<void> {
function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').SessionEvent): SidebarResponse {
switch (event.type) {
case 'project_created': {
if (prev.projects.some((p) => p.id === event.project.id)) return prev;
const fresh: SidebarProject = {
id: event.project.id,
name: event.project.name,
path: event.project.path,
gitea_remote: event.project.gitea_remote ?? null,
recent_sessions: [],
total_sessions: 0,
};
@@ -69,6 +72,7 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
let changed = false;
const projects = prev.projects.map((p) => {
if (p.id !== event.project_id) return p;
if (p.recent_sessions.some((s) => s.id === event.session.id)) return p;
changed = true;
const fresh: SidebarSession = {
id: event.session.id,
@@ -89,8 +93,10 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
let changed = false;
const projects = prev.projects.map((p) => {
if (p.id !== event.project_id) return p;
changed = true;
const recent = p.recent_sessions.filter((s) => s.id !== event.session_id);
const wasPresent = recent.length !== p.recent_sessions.length;
if (!wasPresent) return p;
changed = true;
return {
...p,
recent_sessions: recent,
@@ -140,26 +146,79 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
case 'open_file_in_browser':
// Consumed by Workspace (T7); no sidebar state change needed.
return prev;
case 'attach_chat_file':
return prev;
case 'session_archived': {
let changed = false;
const projects = prev.projects.map((p) => {
if (p.id !== event.project_id) return p;
const recent = p.recent_sessions.filter((s) => s.id !== event.session_id);
if (recent.length === p.recent_sessions.length) return p;
changed = true;
return {
...p,
recent_sessions: recent,
total_sessions: Math.max(0, p.total_sessions - 1),
};
});
return changed ? { ...prev, projects } : prev;
}
case 'chat_created':
case 'chat_updated':
case 'chat_archived':
case 'chat_unarchived':
case 'chat_deleted':
return prev;
case 'project_archived': {
const next = prev.projects.filter((p) => p.id !== event.project_id);
if (next.length === prev.projects.length) return prev;
return { ...prev, projects: next };
}
case 'project_unarchived': {
if (prev.projects.some((p) => p.id === event.project.id)) return prev;
const fresh: SidebarProject = {
id: event.project.id,
name: event.project.name,
path: event.project.path,
gitea_remote: event.project.gitea_remote ?? null,
recent_sessions: [],
total_sessions: 0,
};
return { ...prev, projects: [fresh, ...prev.projects] };
}
case 'project_updated': {
let changed = false;
const projects = prev.projects.map((p) => {
if (p.id !== event.project_id) return p;
if (p.name === event.name) return p;
changed = true;
return { ...p, name: event.name };
});
return changed ? { ...prev, projects } : prev;
}
}
}
// One bus subscription for the lifetime of the module. Events arriving
// before the initial fetch resolves are dropped; the eventual fetch
// result is the source of truth.
sessionEvents.subscribe((event) => {
// session_loaded updates activeSessionProjectId regardless of whether
// sharedData is populated yet — notify so subscribers can re-read.
if (event.type === 'session_loaded') {
activeSession = { session_id: event.session_id, project_id: event.project_id };
// Guard prevents duplicate listeners during Vite HMR reloads.
const G = globalThis as Record<string, unknown>;
if (!G.__boocode_sidebar_subscribed) {
G.__boocode_sidebar_subscribed = true;
sessionEvents.subscribe((event) => {
if (event.type === 'session_loaded') {
activeSession = { session_id: event.session_id, project_id: event.project_id };
notify();
return;
}
if (!sharedData) return;
const next = applyEvent(sharedData, event);
if (next === sharedData) return;
sharedData = next;
notify();
return;
}
if (!sharedData) return;
const next = applyEvent(sharedData, event);
if (next === sharedData) return;
sharedData = next;
notify();
});
});
}
interface Snapshot {
data: SidebarResponse | null;

View File

@@ -0,0 +1,44 @@
export type Attachment = {
id: string;
kind: 'file' | 'lines' | 'paste';
filename: string;
language: string | null;
content: string;
range?: [number, number];
source: '@' | 'line-select' | 'drop' | 'paste';
};
export const LANG_MAP: Record<string, string> = {
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
mjs: 'javascript', cjs: 'javascript',
py: 'python', go: 'go', rs: 'rust', rb: 'ruby', java: 'java',
c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', hpp: 'cpp', cs: 'csharp',
php: 'php', sh: 'bash', bash: 'bash', zsh: 'bash',
yml: 'yaml', yaml: 'yaml', json: 'json', toml: 'toml',
md: 'markdown', markdown: 'markdown', sql: 'sql', dockerfile: 'dockerfile',
html: 'html', htm: 'html', css: 'css', scss: 'scss',
};
export function inferLanguage(filename: string): string | null {
const base = filename.split('/').pop() ?? filename;
if (base.toLowerCase() === 'dockerfile') return 'dockerfile';
const m = base.match(/\.([^.]+)$/);
return m ? (LANG_MAP[m[1]!.toLowerCase()] ?? null) : null;
}
export function flattenToMessage(attachments: Attachment[], text: string): string {
if (attachments.length === 0) return text;
const blocks = attachments.map(a => {
const fence = '```' + (a.language ?? '');
let header: string;
if (a.kind === 'lines') {
header = `// from: ${a.filename}:${a.range?.[0] ?? '?'}-${a.range?.[1] ?? '?'}`;
} else if (a.kind === 'paste') {
header = `// from: pasted text (${a.content.split('\n').length} lines)`;
} else {
header = `// from: ${a.filename}`;
}
return `${fence}\n${header}\n${a.content}\n\`\`\``;
});
return [...blocks, text].filter(Boolean).join('\n\n');
}

View File

@@ -0,0 +1,5 @@
export function formatTokens(n: number | null | undefined): string | null {
if (n === null || n === undefined) return null;
if (n < 1000) return `${n} tok`;
return `${(n / 1000).toFixed(1)}k tok`;
}

View File

@@ -0,0 +1,5 @@
export function giteaUrlFor(project: { path: string; gitea_remote?: string | null }): string {
if (project.gitea_remote) return project.gitea_remote;
const folderName = project.path.split('/').filter(Boolean).pop() ?? '';
return `https://git.indifferentketchup.com/indifferentketchup/${folderName}`;
}

View File

@@ -1,35 +1,135 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { ChevronDown, ChevronRight, Folder, RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { AddProjectModal } from '@/components/AddProjectModal';
import { CreateProjectModal } from '@/components/CreateProjectModal';
import { api } from '@/api/client';
import type { Project } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
import { useSidebar } from '@/hooks/useSidebar';
export function Home() {
const { data } = useSidebar();
const [open, setOpen] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [archived, setArchived] = useState<Project[] | null>(null);
const [showArchived, setShowArchived] = useState(false);
const empty = data ? data.projects.length === 0 : false;
useEffect(() => {
api.projects.list({ status: 'archived' })
.then(setArchived)
.catch(() => {});
}, []);
useEffect(() => {
return sessionEvents.subscribe((event) => {
if (event.type === 'project_archived') {
setArchived((prev) => {
if (!prev) return prev;
if (prev.some((p) => p.id === event.project_id)) return prev;
const fromSidebar = data?.projects.find((p) => p.id === event.project_id);
if (!fromSidebar) return prev;
return [
{
id: fromSidebar.id,
name: fromSidebar.name,
path: fromSidebar.path,
added_at: new Date().toISOString(),
last_session_id: null,
status: 'archived' as const,
gitea_remote: fromSidebar.gitea_remote,
},
...prev,
];
});
}
if (event.type === 'project_unarchived') {
setArchived((prev) => prev ? prev.filter((p) => p.id !== event.project.id) : prev);
}
if (event.type === 'project_deleted') {
setArchived((prev) => prev ? prev.filter((p) => p.id !== event.project_id) : prev);
}
if (event.type === 'project_updated') {
setArchived((prev) =>
prev ? prev.map((p) => p.id === event.project_id ? { ...p, name: event.name } : p) : prev
);
}
});
}, [data]);
async function handleUnarchive(id: string) {
try {
await api.projects.unarchive(id);
// Server publishes project_unarchived; useUserEvents delivers it.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to restore project');
}
}
return (
<div className="flex-1 flex items-center justify-center px-6">
<div className="max-w-md text-center space-y-4">
{empty ? (
<>
<h1 className="text-2xl font-semibold tracking-tight">No projects yet</h1>
<p className="text-sm text-muted-foreground">
Add a project from /opt to start chatting about its code.
</p>
<Button onClick={() => setOpen(true)}>Add project</Button>
</>
) : (
<>
<h1 className="text-2xl font-semibold tracking-tight">BooCode</h1>
<p className="text-sm text-muted-foreground">
Pick a project from the sidebar.
</p>
</>
<div className="flex-1 flex flex-col items-center px-6 py-12 overflow-y-auto">
<div className="w-full max-w-md space-y-6">
<div className="text-center space-y-3">
{empty ? (
<>
<h1 className="text-2xl font-semibold tracking-tight">No projects yet</h1>
<p className="text-sm text-muted-foreground">
Add a project from /opt or create a new one.
</p>
</>
) : (
<>
<h1 className="text-2xl font-semibold tracking-tight">BooCode</h1>
<p className="text-sm text-muted-foreground">
Pick a project from the sidebar, or add another.
</p>
</>
)}
<div className="flex gap-2 justify-center pt-2">
<Button variant="outline" onClick={() => setAddOpen(true)}>Add existing project</Button>
<Button onClick={() => setCreateOpen(true)}>Create new project</Button>
</div>
</div>
{archived && archived.length > 0 && (
<div className="border-t pt-6">
<button
type="button"
onClick={() => setShowArchived(!showArchived)}
className="flex items-center gap-1 text-xs font-medium text-muted-foreground mb-2 hover:text-foreground"
>
{showArchived ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
Archived Projects ({archived.length})
</button>
{showArchived && (
<ul className="divide-y rounded-md border">
{archived.map((p) => (
<li key={p.id} className="flex items-center justify-between px-3 py-2 hover:bg-muted/50">
<div className="flex-1 flex items-center gap-2 min-w-0">
<Folder className="size-3.5 opacity-40 shrink-0" />
<span className="truncate text-sm text-muted-foreground" title={p.name}>{p.name}</span>
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label="Restore project"
title="Restore project"
onClick={() => void handleUnarchive(p.id)}
>
<RotateCcw size={14} />
</Button>
</li>
))}
</ul>
)}
</div>
)}
</div>
<AddProjectModal open={open} onOpenChange={setOpen} onAdded={() => {}} />
<AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={() => {}} />
<CreateProjectModal open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}

View File

@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { Plus, MessageSquare, Trash2 } from 'lucide-react';
import { Plus, MessageSquare, Trash2, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { Project as ProjectType } from '@/api/types';
import type { Project as ProjectType, Session } from '@/api/types';
import { Button } from '@/components/ui/button';
import { sessionEvents } from '@/hooks/sessionEvents';
import { useSessions } from '@/hooks/useSessions';
@@ -14,6 +14,8 @@ export function Project() {
const { sessions, create, remove } = useSessions(id);
const [project, setProject] = useState<ProjectType | null>(null);
const [creating, setCreating] = useState(false);
const [archivedSessions, setArchivedSessions] = useState<Session[] | null>(null);
const [showArchived, setShowArchived] = useState(false);
useEffect(() => {
if (!id) return;
@@ -23,18 +25,55 @@ export function Project() {
.catch(() => {});
}, [id]);
useEffect(() => {
if (!id) return;
api.sessions.listForProject(id, 'archived')
.then(setArchivedSessions)
.catch(() => {});
}, [id]);
useEffect(() => {
return sessionEvents.subscribe((event) => {
if (event.type === 'session_archived' && event.project_id === id) {
setArchivedSessions((prev) => {
if (!prev) return prev;
if (prev.some((s) => s.id === event.session_id)) return prev;
const session = sessions?.find((s) => s.id === event.session_id);
if (!session) return prev;
return [{ ...session, status: 'archived' as const }, ...prev];
});
}
if (event.type === 'session_deleted' && event.project_id === id) {
setArchivedSessions((prev) =>
prev ? prev.filter((s) => s.id !== event.session_id) : prev
);
}
});
}, [id, sessions]);
async function handleNew() {
if (!id || creating) return;
setCreating(true);
try {
const s = await create({});
sessionEvents.emit({ type: 'session_created', session: s, project_id: id });
// Server publishes session_created via WS; let useUserEvents deliver it.
navigate(`/session/${s.id}`);
} finally {
setCreating(false);
}
}
async function handleUnarchive(sessionId: string) {
try {
await api.sessions.unarchive(sessionId);
setArchivedSessions((prev) =>
prev ? prev.filter((s) => s.id !== sessionId) : prev
);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to unarchive');
}
}
return (
<div className="flex-1 flex flex-col">
<header className="border-b px-6 py-3 flex items-center justify-between">
@@ -52,7 +91,7 @@ export function Project() {
</Button>
</header>
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
{sessions === null && (
<div className="text-sm text-muted-foreground">Loading</div>
)}
@@ -79,11 +118,7 @@ export function Project() {
onClick={async () => {
try {
await remove(s.id);
sessionEvents.emit({
type: 'session_deleted',
session_id: s.id,
project_id: id!,
});
// Server publishes session_deleted via WS.
} catch (err) {
toast.error(
err instanceof Error ? err.message : 'failed to delete session'
@@ -97,6 +132,61 @@ export function Project() {
))}
</ul>
)}
{/* Archived sessions */}
{archivedSessions && archivedSessions.length > 0 && (
<div>
<button
type="button"
onClick={() => setShowArchived(!showArchived)}
className="flex items-center gap-1 text-xs font-medium text-muted-foreground mb-2 hover:text-foreground"
>
{showArchived ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
Closed sessions ({archivedSessions.length})
</button>
{showArchived && (
<ul className="divide-y rounded-md border">
{archivedSessions.map((s) => (
<li key={s.id} className="flex items-center justify-between px-3 py-2 hover:bg-muted/50">
<div className="flex-1 flex items-center gap-2 min-w-0">
<MessageSquare className="size-3.5 opacity-40 shrink-0" />
<span className="truncate text-sm text-muted-foreground">{s.name}</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label="Reopen session"
onClick={() => void handleUnarchive(s.id)}
>
<RotateCcw size={14} />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Delete session permanently"
onClick={async () => {
try {
await api.sessions.remove(s.id);
setArchivedSessions((prev) =>
prev ? prev.filter((a) => a.id !== s.id) : prev
);
} catch (err) {
toast.error(
err instanceof Error ? err.message : 'failed to delete'
);
}
}}
>
<Trash2 />
</Button>
</div>
</li>
))}
</ul>
)}
</div>
)}
</div>
</div>
);

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { ChevronLeft } from 'lucide-react';
import { api } from '@/api/client';
import type { Session as SessionType } from '@/api/types';
@@ -9,6 +9,7 @@ import { ModelPicker } from '@/components/ModelPicker';
export function Session() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [session, setSession] = useState<SessionType | null>(null);
const [name, setName] = useState('');
const [editingName, setEditingName] = useState(false);
@@ -43,12 +44,19 @@ export function Session() {
useEffect(() => {
if (!id) return;
return sessionEvents.subscribe((event) => {
if (event.type !== 'session_renamed') return;
if (event.session_id !== id) return;
setSession((prev) => (prev ? { ...prev, name: event.name } : prev));
setName((prev) => (editingName ? prev : event.name));
if (event.type === 'session_renamed' && event.session_id === id) {
setSession((prev) => (prev ? { ...prev, name: event.name } : prev));
setName((prev) => (editingName ? prev : event.name));
return;
}
if (
(event.type === 'session_deleted' || event.type === 'session_archived') &&
event.session_id === id
) {
navigate(`/project/${event.project_id}`);
}
});
}, [id, editingName]);
}, [id, editingName, navigate]);
async function saveName() {
if (!id || !session) return;

View File

@@ -9,7 +9,7 @@ services:
environment:
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
volumes:
- /opt:/opt:ro
- /opt:/opt:rw
depends_on:
- boocode_db
networks: