Compare commits

..

6 Commits

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

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

109
CLAUDE.md Normal file
View File

@@ -0,0 +1,109 @@
# 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/useSidebar.ts`** — Module-singleton with Set<setState> subscriber pattern; one bus subscription guarded by `globalThis.__boocode_sidebar_subscribed` for HMR safety. Every new `SessionEvent` type needs a `case` in the `applyEvent` switch (no-op `return prev` is fine).
- **`api/client.ts`** — Centralized typed fetch wrapper. All endpoints under `api.*` namespace.
### Data flow for chat
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 / empty / placeholder terminal+agent). Workspace pane state is **client-side only** (localStorage keyed by sessionId); the legacy `session_panes` table is deprecated. Each chat lives in at most one pane; tab strip is per-pane and tracks `chatIds[]` + `activeChatIdx`. Sessions 1:N chats; chats own messages. Tab reorder via native HTML5 drag events.
## Database
PostgreSQL 16. Tables: `projects`, `sessions`, `chats`, `messages`, `settings`, `session_panes` (deprecated). Schema applied idempotently on startup via `applySchema()`. Use `clock_timestamp()` (not `NOW()`) inside transactions. CHECK constraints in place: `projects_status_chk` ('open'|'archived'), `sessions_status_chk` (same), `chats_status_chk` (same), `messages_role_chk`, `messages_status_chk` — keep in sync with the `*_STATUSES` const arrays in `apps/server/src/types/api.ts`.
Schema CHECK migration order when renaming allowed values: (1) `ALTER TABLE ... DROP CONSTRAINT IF EXISTS <system_name>` (inline `CREATE TABLE` checks get `<table>_<column>_check`), (2) `UPDATE` rows to new values, (3) wrap new constraint ADD in `DO $$ ... pg_constraint` guard — that block is the only way to get `ADD CONSTRAINT IF NOT EXISTS`.
Position-shift pattern for panes (legacy `session_panes` table): negate-and-restore to avoid UNIQUE(session_id, position) collisions during reorder/insert/delete. Sentinel value -100 for the moving pane.
## Environment
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt), `DEFAULT_MODEL`, `LOG_LEVEL`.
## Workflow
- Sam reviews all diffs and commits manually. Do not commit unless explicitly asked.
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
- Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client.
- Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present).
## Conventions
- `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'), PROJECT_ROOT_WHITELIST: z.string().default('/opt'),
DEFAULT_MODEL: z.string().default('qwen3.6-35b-a3b-mxfp4'), DEFAULT_MODEL: z.string().default('qwen3.6-35b-a3b-mxfp4'),
LOG_LEVEL: z.string().default('info'), 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>; export type Config = z.infer<typeof ConfigSchema>;

View File

@@ -5,15 +5,15 @@ import { existsSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { loadConfig } from './config.js'; import { loadConfig } from './config.js';
import { getSql, applySchema, pingDb, closeDb } from './db.js'; import { getSql, applySchema, pingDb, closeDb } from './db.js';
import { registerAuth } from './auth.js';
import { registerProjectRoutes } from './routes/projects.js'; import { registerProjectRoutes } from './routes/projects.js';
import { registerSessionRoutes } from './routes/sessions.js'; import { registerSessionRoutes } from './routes/sessions.js';
import { registerSettingsRoutes } from './routes/settings.js'; import { registerSettingsRoutes } from './routes/settings.js';
import { registerMessageRoutes } from './routes/messages.js'; import { registerMessageRoutes } from './routes/messages.js';
import { registerChatRoutes } from './routes/chats.js';
import { registerSidebarRoutes } from './routes/sidebar.js'; import { registerSidebarRoutes } from './routes/sidebar.js';
import { registerWebSocket } from './routes/ws.js'; import { registerWebSocket } from './routes/ws.js';
import { registerModelRoutes } from './routes/models.js'; import { registerModelRoutes } from './routes/models.js';
import { registerPaneRoutes } from './routes/panes.js';
import { createInferenceRunner } from './services/inference.js'; import { createInferenceRunner } from './services/inference.js';
import { createBroker } from './services/broker.js'; import { createBroker } from './services/broker.js';
@@ -24,14 +24,28 @@ async function main() {
logger: { level: config.LOG_LEVEL }, 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); const sql = getSql(config);
await applySchema(sql); await applySchema(sql);
app.log.info('database schema applied'); app.log.info('database schema applied');
await app.register(fastifyWebsocket); await app.register(fastifyWebsocket);
registerAuth(app);
app.get('/api/health', async () => { app.get('/api/health', async () => {
const dbOk = await pingDb(sql); const dbOk = await pingDb(sql);
return { status: dbOk ? 'ok' : 'degraded', db: dbOk }; return { status: dbOk ? 'ok' : 'degraded', db: dbOk };
@@ -44,7 +58,7 @@ async function main() {
registerSettingsRoutes(app, sql); registerSettingsRoutes(app, sql);
registerModelRoutes(app, config); registerModelRoutes(app, config);
registerSidebarRoutes(app, sql); registerSidebarRoutes(app, sql);
registerPaneRoutes(app, sql); registerChatRoutes(app, sql, broker);
const inference = createInferenceRunner( const inference = createInferenceRunner(
{ {
@@ -60,29 +74,40 @@ async function main() {
} }
); );
registerMessageRoutes(app, sql, { registerMessageRoutes(app, sql, {
enqueueInference: (sessionId, assistantId, user) => { enqueueInference: (sessionId, chatId, assistantId, user) => {
inference.enqueue(sessionId, 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);
},
hasActiveInference: (chatId) => inference.hasActive(chatId),
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
broker.publish(sessionId, { broker.publish(sessionId, {
type: 'message_started', type: 'message_started',
message_id: userMessageId, message_id: userMessageId,
chat_id: chatId,
role: 'user', role: 'user',
}); });
broker.publish(sessionId, { broker.publish(sessionId, {
type: 'delta', type: 'delta',
message_id: userMessageId, message_id: userMessageId,
chat_id: chatId,
content, content,
}); });
broker.publish(sessionId, { broker.publish(sessionId, {
type: 'message_complete', type: 'message_complete',
message_id: userMessageId, message_id: userMessageId,
chat_id: chatId,
}); });
}, },
publishMessagesDeleted: (sessionId, messageIds) => { publishMessagesDeleted: (sessionId, chatId, messageIds) => {
broker.publish(sessionId, { broker.publish(sessionId, {
type: 'messages_deleted', type: 'messages_deleted',
message_ids: messageIds, message_ids: messageIds,
chat_id: chatId,
}); });
}, },
}); });
@@ -120,6 +145,9 @@ async function main() {
process.on('SIGINT', () => void shutdown('SIGINT')); process.on('SIGINT', () => void shutdown('SIGINT'));
process.on('SIGTERM', () => void shutdown('SIGTERM')); process.on('SIGTERM', () => void shutdown('SIGTERM'));
// Bound to 0.0.0.0 intentionally. Public access goes through Caddy → Authelia.
// Direct Tailscale access (100.114.205.53:9500) is unauthenticated by design;
// the threat model treats Tailnet membership as the trust boundary.
await app.listen({ port: config.PORT, host: config.HOST }); await app.listen({ port: config.PORT, host: config.HOST });
app.log.info(`boocode server listening on http://${config.HOST}:${config.PORT}`); app.log.info(`boocode server listening on http://${config.HOST}:${config.PORT}`);
} }

View File

@@ -0,0 +1,279 @@
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),
});
const ForkBody = z.object({
message_id: z.string().uuid(),
name: z.string().min(1).max(200).optional(),
});
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.post<{ Params: { id: string } }>(
'/api/chats/:id/fork',
async (req, reply) => {
const parsed = ForkBody.safeParse(req.body ?? {});
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const sourceRows = await sql<Chat[]>`
SELECT id, session_id, name, status, created_at, updated_at
FROM chats WHERE id = ${req.params.id}
`;
if (sourceRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const source = sourceRows[0]!;
const targetRows = await sql<{ created_at: string; status: string }[]>`
SELECT created_at, status FROM messages
WHERE chat_id = ${source.id} AND id = ${parsed.data.message_id}
`;
if (targetRows.length === 0) {
reply.code(404);
return { error: 'message not found in chat' };
}
const target = targetRows[0]!;
if (target.status !== 'complete') {
reply.code(400);
return { error: 'can only fork from completed messages' };
}
const newName = parsed.data.name ?? `${source.name ?? 'Chat'} (fork)`;
const newChat = await sql.begin(async (tx) => {
const [chat] = await tx<Chat[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${source.session_id}, ${newName}, 'open')
RETURNING id, session_id, name, status, created_at, updated_at
`;
await tx`
INSERT INTO messages (
session_id, chat_id, role, content, kind, tool_calls, tool_results,
status, tokens_used, ctx_used, ctx_max, started_at, finished_at,
created_at
)
SELECT
${source.session_id}, ${chat!.id}, role, content, kind,
tool_calls, tool_results, status,
tokens_used, ctx_used, ctx_max, started_at, finished_at,
clock_timestamp() + (
ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) * INTERVAL '1 microsecond'
)
FROM messages
WHERE chat_id = ${source.id}
AND created_at <= ${target.created_at}::timestamptz
AND status = 'complete'
`;
return chat!;
});
broker.publishUser('default', {
type: 'chat_created',
chat: newChat,
session_id: source.session_id,
});
reply.code(201);
return newChat;
}
);
app.get<{ Params: { id: string } }>(
'/api/chats/:id/messages',
async (req, reply) => {
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,24 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import type { Message, Session } from '../types/api.js'; import type { Chat, Message, Session } from '../types/api.js';
import { requireUser } from '../auth.js';
const SendBody = z.object({ const SendBody = z.object({
content: z.string().min(1).max(64_000), content: z.string().min(1).max(64_000),
}); });
interface MessageHandlers { 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: ( publishUserMessage: (
sessionId: string, sessionId: string,
chatId: string,
userMessageId: string, userMessageId: string,
content: string content: string
) => void; ) => void;
publishMessagesDeleted: (sessionId: string, messageIds: string[]) => void; publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void;
cancelInference: (sessionId: string, chatId: string) => Promise<boolean>;
hasActiveInference: (chatId: string) => boolean;
} }
export function registerMessageRoutes( export function registerMessageRoutes(
@@ -32,7 +35,7 @@ export function registerMessageRoutes(
return { error: 'session not found' }; return { error: 'session not found' };
} }
const rows = await sql<Message[]>` 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 tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at
FROM messages FROM messages
WHERE session_id = ${req.params.id} WHERE session_id = ${req.params.id}
@@ -43,7 +46,7 @@ export function registerMessageRoutes(
); );
app.post<{ Params: { id: string } }>( app.post<{ Params: { id: string } }>(
'/api/sessions/:id/messages', '/api/chats/:id/messages',
async (req, reply) => { async (req, reply) => {
const parsed = SendBody.safeParse(req.body); const parsed = SendBody.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
@@ -51,33 +54,39 @@ export function registerMessageRoutes(
return { error: 'invalid body', details: parsed.error.flatten() }; return { error: 'invalid body', details: parsed.error.flatten() };
} }
const session = await sql<Session[]>`SELECT id FROM sessions WHERE id = ${req.params.id}`; const chatRows = await sql<Chat[]>`
if (session.length === 0) { SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404); 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 result = await sql.begin(async (tx) => {
const [userMsg] = await tx<{ id: string }[]>` const [userMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, role, content, status, created_at) INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${req.params.id}, 'user', ${parsed.data.content}, 'complete', clock_timestamp()) VALUES (${sessionId}, ${chat.id}, 'user', ${parsed.data.content}, 'complete', clock_timestamp())
RETURNING id RETURNING id
`; `;
const [assistantMsg] = await tx<{ id: string }[]>` const [assistantMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, role, content, status, created_at) INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${req.params.id}, 'assistant', '', 'streaming', clock_timestamp()) VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id 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 }; return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
}); });
handlers.publishUserMessage( handlers.publishUserMessage(
req.params.id, sessionId,
chat.id,
result.user_message_id, result.user_message_id,
parsed.data.content 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); reply.code(202);
return result; return result;
@@ -85,14 +94,24 @@ export function registerMessageRoutes(
); );
app.post<{ Params: { id: string; message_id: string } }>( 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) => { 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 }[]>` const target = await sql<{ id: string; role: string; status: string }[]>`
SELECT id, role, status SELECT id, role, status
FROM messages FROM messages
WHERE session_id = ${sessionId} AND id = ${targetId} WHERE chat_id = ${chatId} AND id = ${targetId}
`; `;
if (target.length === 0) { if (target.length === 0) {
reply.code(404); reply.code(404);
@@ -109,34 +128,188 @@ export function registerMessageRoutes(
} }
const { newAssistantId, deletedIds } = await sql.begin(async (tx) => { 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 }[]>` const deletedRows = await tx<{ id: string }[]>`
DELETE FROM messages DELETE FROM messages
WHERE session_id = ${sessionId} WHERE chat_id = ${chatId}
AND created_at >= ( AND created_at >= (
SELECT created_at FROM messages WHERE id = ${targetId} SELECT created_at FROM messages WHERE id = ${targetId}
) )
RETURNING id RETURNING id
`; `;
const [row] = await tx<{ id: string }[]>` const [row] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, role, content, status, created_at) INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, 'assistant', '', 'streaming', clock_timestamp()) VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id 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 { return {
newAssistantId: row!.id, newAssistantId: row!.id,
deletedIds: deletedRows.map((r) => r.id), deletedIds: deletedRows.map((r) => r.id),
}; };
}); });
handlers.publishMessagesDeleted(sessionId, deletedIds); handlers.publishMessagesDeleted(sessionId, chatId, deletedIds);
handlers.enqueueInference(sessionId, newAssistantId, requireUser(req)); handlers.enqueueInference(sessionId, chatId, newAssistantId, 'default');
reply.code(202); reply.code(202);
return { assistant_message_id: newAssistantId }; return { assistant_message_id: newAssistantId };
} }
); );
app.delete<{ Params: { id: string; message_id: string } }>(
'/api/chats/:id/messages/:message_id',
async (req, reply) => {
const { id: chatId, message_id: messageId } = req.params;
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${chatId}
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const chat = chatRows[0]!;
if (handlers.hasActiveInference(chatId)) {
reply.code(409);
return { error: 'chat is currently streaming; stop it first' };
}
const deletedIds = await sql.begin(async (tx) => {
const deletedRows = await tx<{ id: string }[]>`
DELETE FROM messages
WHERE chat_id = ${chatId}
AND created_at >= (
SELECT created_at FROM messages
WHERE id = ${messageId} AND chat_id = ${chatId}
)
RETURNING id
`;
if (deletedRows.length > 0) {
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
}
return deletedRows.map((r) => r.id);
});
if (deletedIds.length === 0) {
reply.code(404);
return { error: 'message not found' };
}
handlers.publishMessagesDeleted(chat.session_id, chatId, deletedIds);
reply.code(204);
return null;
}
);
app.post<{ Params: { id: string } }>(
'/api/chats/:id/compact',
async (req, reply) => {
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 { Config } from '../config.js';
import type { Broker } from '../services/broker.js'; import type { Broker } from '../services/broker.js';
import type { Project, AvailableProject } from '../types/api.js'; import type { Project, AvailableProject } from '../types/api.js';
import { requireUser } from '../auth.js';
import { resolveProjectRoot, PathScopeError } from '../services/path_guard.js'; import { resolveProjectRoot, PathScopeError } from '../services/path_guard.js';
import { listDir, viewFile } from '../services/file_ops.js'; import { listDir, viewFile } from '../services/file_ops.js';
import { getProjectFiles } from '../services/file_index.js'; import { getProjectFiles } from '../services/file_index.js';
import {
bootstrapProject,
BootstrapNameError,
BootstrapCollisionError,
BootstrapPathError,
} from '../services/project_bootstrap.js';
const AddProjectBody = z.object({ const AddProjectBody = z.object({
path: z.string().min(1), path: z.string().min(1),
name: z.string().min(1).optional(), 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> { async function isDir(path: string): Promise<boolean> {
try { try {
const s = await stat(path); const s = await stat(path);
@@ -50,15 +66,83 @@ export function registerProjectRoutes(
config: Config, config: Config,
broker: Broker broker: Broker
): void { ): 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[]>` 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 FROM projects
WHERE status = ${status}
ORDER BY added_at DESC ORDER BY added_at DESC
`; `;
return rows; 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) => { app.post('/api/projects', async (req, reply) => {
const parsed = AddProjectBody.safeParse(req.body); const parsed = AddProjectBody.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
@@ -71,22 +155,88 @@ export function registerProjectRoutes(
return { error: resolved.error }; return { error: resolved.error };
} }
const name = parsed.data.name?.trim() || resolved.name; const name = parsed.data.name?.trim() || resolved.name;
try {
const [row] = await sql<Project[]>` // Pre-check the current row (if any) so we can distinguish three cases:
INSERT INTO projects (name, path) // - no row INSERT fresh, 201, project_created
VALUES (${name}, ${resolved.real}) // - row archived → ON CONFLICT UPDATE flips to 'open', 200, project_unarchived
RETURNING id, name, path, added_at, last_session_id // - row already open → 409 (true duplicate)
const existing = await sql<{ status: string }[]>`
SELECT status FROM projects WHERE path = ${resolved.real}
`; `;
broker.publishUser(requireUser(req), { type: 'project_created', project: row as unknown as Project }); if (existing.length > 0 && existing[0]!.status === 'open') {
reply.code(201);
return row;
} catch (err) {
if (err instanceof Error && err.message.includes('duplicate key')) {
reply.code(409); reply.code(409);
return { error: 'project already exists' }; return { error: 'project already exists' };
} }
throw err;
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) => { app.delete<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => {
@@ -96,7 +246,7 @@ export function registerProjectRoutes(
reply.code(404); reply.code(404);
return { error: 'not found' }; 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); reply.code(204);
return null; return null;
}); });
@@ -110,7 +260,12 @@ export function registerProjectRoutes(
return [] as AvailableProject[]; 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 existingSet = new Set(existing.map((r) => r.path));
const out: AvailableProject[] = []; const out: AvailableProject[] = [];
@@ -144,7 +299,7 @@ export function registerProjectRoutes(
const relPath = req.query.path ?? '.'; const relPath = req.query.path ?? '.';
const rows = await sql<Project[]>` 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} FROM projects WHERE id = ${id}
`; `;
if (rows.length === 0) { if (rows.length === 0) {
@@ -189,7 +344,7 @@ export function registerProjectRoutes(
} }
const rows = await sql<Project[]>` 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} FROM projects WHERE id = ${id}
`; `;
if (rows.length === 0) { if (rows.length === 0) {
@@ -233,7 +388,7 @@ export function registerProjectRoutes(
const { id } = req.params; const { id } = req.params;
const rows = await sql<Project[]>` 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} FROM projects WHERE id = ${id}
`; `;
if (rows.length === 0) { 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 { Broker } from '../services/broker.js';
import type { Session } from '../types/api.js'; import type { Session } from '../types/api.js';
import { getSetting } from './settings.js'; import { getSetting } from './settings.js';
import { requireUser } from '../auth.js';
const CreateBody = z.object({ const CreateBody = z.object({
name: z.string().min(1).max(200).optional(), name: z.string().min(1).max(200).optional(),
@@ -31,7 +30,7 @@ export function registerSessionRoutes(
config: Config, config: Config,
broker: Broker broker: Broker
): void { ): void {
app.get<{ Params: { id: string } }>( app.get<{ Params: { id: string }; Querystring: { status?: string } }>(
'/api/projects/:id/sessions', '/api/projects/:id/sessions',
async (req, reply) => { async (req, reply) => {
const project = await sql`SELECT id FROM projects WHERE id = ${req.params.id}`; const project = await sql`SELECT id FROM projects WHERE id = ${req.params.id}`;
@@ -39,10 +38,11 @@ export function registerSessionRoutes(
reply.code(404); reply.code(404);
return { error: 'project not found' }; return { error: 'project not found' };
} }
const status = req.query.status === 'archived' ? 'archived' : 'open';
const rows = await sql<Session[]>` 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 FROM sessions
WHERE project_id = ${req.params.id} WHERE project_id = ${req.params.id} AND status = ${status}
ORDER BY updated_at DESC ORDER BY updated_at DESC
`; `;
return rows; return rows;
@@ -81,15 +81,15 @@ export function registerSessionRoutes(
const [session] = await tx<Session[]>` const [session] = await tx<Session[]>`
INSERT INTO sessions (project_id, name, model, system_prompt) INSERT INTO sessions (project_id, name, model, system_prompt)
VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt}) 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` await tx`
INSERT INTO session_panes (session_id, position, kind, state) INSERT INTO chats (session_id, name, status)
VALUES (${session!.id}, 0, 'chat', '{}'::jsonb) VALUES (${session!.id}, NULL, 'open')
`; `;
return session!; return session!;
}); });
broker.publishUser(requireUser(req), { broker.publishUser('default', {
type: 'session_created', type: 'session_created',
session: row, session: row,
project_id: row.project_id, project_id: row.project_id,
@@ -101,7 +101,7 @@ export function registerSessionRoutes(
app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => { app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
const rows = await sql<Session[]>` 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} FROM sessions WHERE id = ${req.params.id}
`; `;
if (rows.length === 0) { if (rows.length === 0) {
@@ -128,13 +128,66 @@ export function registerSessionRoutes(
system_prompt = COALESCE(${system_prompt ?? null}, system_prompt), system_prompt = COALESCE(${system_prompt ?? null}, system_prompt),
updated_at = clock_timestamp() updated_at = clock_timestamp()
WHERE id = ${req.params.id} 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) { if (rows.length === 0) {
reply.code(404); reply.code(404);
return { error: 'session not found' }; return { error: 'session not found' };
} }
return rows[0]; const session = rows[0]!;
if (name !== undefined) {
broker.publishUser('default', {
type: 'session_renamed',
session_id: session.id,
name: session.name,
});
}
return session;
}
);
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;
} }
); );
@@ -150,7 +203,7 @@ export function registerSessionRoutes(
return { error: 'not found' }; return { error: 'not found' };
} }
const project_id = deleted[0]!.project_id; 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); reply.code(204);
return null; return null;
} }

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL, name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE, path TEXT NOT NULL UNIQUE,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), added_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
last_session_id UUID last_session_id UUID
); );
@@ -12,8 +12,8 @@ CREATE TABLE IF NOT EXISTS sessions (
name TEXT NOT NULL, name TEXT NOT NULL,
model TEXT NOT NULL, model TEXT NOT NULL,
system_prompt TEXT NOT NULL DEFAULT '', system_prompt TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
); );
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id, updated_at DESC);
@@ -21,13 +21,13 @@ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id, updated_
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, 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 '', content TEXT NOT NULL DEFAULT '',
tool_calls JSONB, tool_calls JSONB,
tool_results 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, last_seq INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
); );
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
@@ -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; 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 ( CREATE TABLE IF NOT EXISTS session_panes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
@@ -58,11 +60,96 @@ CREATE TABLE IF NOT EXISTS session_panes (
); );
CREATE INDEX IF NOT EXISTS idx_session_panes_session ON session_panes (session_id); CREATE INDEX IF NOT EXISTS idx_session_panes_session ON session_panes (session_id);
-- Backfill: ensure every session has at least one pane (default Chat). -- v1.4: backfill removed. Pane layout is client-side (localStorage) since v1.2-batch4.
-- Idempotent: skipped on subsequent runs because session_panes rows already exist. -- The CREATE TABLE above is retained for additive-schema discipline; drop is a
INSERT INTO session_panes (session_id, position, kind, state) -- future destructive migration.
SELECT s.id, 0, 'chat', '{}'::jsonb
-- 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 FROM sessions s
WHERE NOT EXISTS ( WHERE NOT EXISTS (
SELECT 1 FROM session_panes p WHERE p.session_id = s.id 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; 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 { function cleanTitle(raw: string): string {
let name = raw.trim(); let name = raw.trim();
// Strip surrounding straight or smart quotes (one layer).
const quotes = ['"', "'", '`', '', '', '“', '”']; const quotes = ['"', "'", '`', '', '', '“', '”'];
while (name.length >= 2 && quotes.includes(name[0]!) && quotes.includes(name[name.length - 1]!)) { while (name.length >= 2 && quotes.includes(name[0]!) && quotes.includes(name[name.length - 1]!)) {
name = name.slice(1, -1).trim(); 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(); name = name.replace(/^title\s*:\s*/i, '').trim();
if (name.length > MAX_TITLE_CHARS) { if (name.length > MAX_TITLE_CHARS) {
name = name.slice(0, MAX_TITLE_CHARS).trim(); 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 { function pickTitleSource(data: NamingResponse): string {
const choice = data.choices?.[0]?.message; const choice = data.choices?.[0]?.message;
if (!choice) return ''; if (!choice) return '';
if (choice.content && choice.content.trim().length > 0) return choice.content; 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 ?? ''; const reasoning = choice.reasoning_content ?? '';
if (reasoning.length === 0) return ''; if (reasoning.length === 0) return '';
const lines = reasoning const lines = reasoning
@@ -62,40 +40,48 @@ function pickTitleSource(data: NamingResponse): string {
return lines[lines.length - 1] ?? ''; return lines[lines.length - 1] ?? '';
} }
export async function maybeAutoNameSession( export async function maybeAutoNameChat(
ctx: InferenceContext, ctx: InferenceContext,
chatId: string,
sessionId: string sessionId: string
): Promise<void> { ): Promise<void> {
const counts = await ctx.sql<{ n: number }[]>` const counts = await ctx.sql<{ n: number }[]>`
SELECT COUNT(*)::int AS n SELECT COUNT(*)::int AS n
FROM messages FROM messages
WHERE session_id = ${sessionId} WHERE chat_id = ${chatId}
AND role = 'assistant' AND role = 'assistant'
AND status = 'complete' AND status = 'complete'
AND content <> ''
`; `;
if (counts[0]?.n !== 1) return; if ((counts[0]?.n ?? 0) < 1) return;
const sessionRows = await ctx.sql< const chatRows = await ctx.sql<
{ id: string; name: string; model: string }[] { 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]; const chat = chatRows[0];
if (!session) return; if (!chat) return;
const existingName = session.name ?? ''; if (chat.name !== null && chat.name !== '') return;
if (existingName !== '' && existingName !== 'New session') 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 }[]>` const userMsg = await ctx.sql<{ content: string }[]>`
SELECT content FROM messages SELECT content FROM messages
WHERE session_id = ${sessionId} AND role = 'user' WHERE chat_id = ${chatId} AND role = 'user'
ORDER BY created_at ASC ORDER BY created_at ASC
LIMIT 1 LIMIT 1
`; `;
const assistantMsg = await ctx.sql<{ content: string }[]>` const assistantMsg = await ctx.sql<{ content: string }[]>`
SELECT content FROM messages SELECT content FROM messages
WHERE session_id = ${sessionId} WHERE chat_id = ${chatId}
AND role = 'assistant' AND role = 'assistant'
AND status = 'complete' AND status = 'complete'
AND content <> ''
ORDER BY created_at ASC ORDER BY created_at ASC
LIMIT 1 LIMIT 1
`; `;
@@ -105,7 +91,7 @@ export async function maybeAutoNameSession(
const assistantText = assistantMsg[0].content.slice(0, 2000); const assistantText = assistantMsg[0].content.slice(0, 2000);
const body = { const body = {
model: session.model, model,
messages: [ messages: [
{ role: 'system', content: NAMING_SYSTEM_PROMPT }, { role: 'system', content: NAMING_SYSTEM_PROMPT },
{ {
@@ -116,9 +102,6 @@ export async function maybeAutoNameSession(
max_tokens: 30, max_tokens: 30,
temperature: 0.3, temperature: 0.3,
stream: false, 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 }, chat_template_kwargs: { enable_thinking: false },
}; };
@@ -135,23 +118,30 @@ export async function maybeAutoNameSession(
const raw = pickTitleSource(data); const raw = pickTitleSource(data);
const name = cleanTitle(raw); const name = cleanTitle(raw);
if (!name) { 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; return;
} }
const updated = await ctx.sql<{ id: string; name: string }[]>` const updated = await ctx.sql<{ id: string; name: string; session_id: string; updated_at: string }[]>`
UPDATE sessions UPDATE chats
SET name = ${name}, updated_at = NOW() SET name = ${name}, updated_at = clock_timestamp()
WHERE id = ${sessionId} WHERE id = ${chatId}
AND (name IS NULL OR name = '' OR name = 'New session') AND (name IS NULL OR name = '')
RETURNING id, name RETURNING id, name, session_id, updated_at
`; `;
if (updated.length === 0) return; if (updated.length === 0) return;
ctx.publish(sessionId, { ctx.publish(sessionId, {
type: 'session_renamed', type: 'chat_renamed',
session_id: sessionId, chat_id: chatId,
name, 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 type { Message, Project, Session, ToolCall, UserStreamFrame } from '../types/api.js';
import { ALL_TOOLS, TOOLS_BY_NAME, toolJsonSchemas } from './tools.js'; import { ALL_TOOLS, TOOLS_BY_NAME, toolJsonSchemas } from './tools.js';
import { PathScopeError, resolveProjectRoot } from './path_guard.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) => 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.`; `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' | 'message_complete'
| 'messages_deleted' | 'messages_deleted'
| 'session_renamed' | 'session_renamed'
| 'chat_renamed'
| 'error'; | 'error';
message_id?: string; message_id?: string;
message_ids?: string[]; message_ids?: string[];
chat_id?: string;
tool_message_id?: string; tool_message_id?: string;
tool_call_id?: string; tool_call_id?: string;
role?: 'assistant' | 'tool' | 'user'; role?: 'assistant' | 'tool' | 'user';
@@ -101,8 +103,23 @@ export function buildMessagesPayload(
} }
out.push({ role: 'system', content: systemPrompt }); 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 === 'streaming') continue;
if (m.role === 'assistant' && m.status === 'cancelled') continue;
if (m.role === 'tool') { if (m.role === 'tool') {
const tr = m.tool_results; const tr = m.tool_results;
if (!tr) continue; if (!tr) continue;
@@ -140,10 +157,11 @@ export function buildMessagesPayload(
async function loadContext( async function loadContext(
sql: Sql, sql: Sql,
sessionId: string sessionId: string,
chatId: string
): Promise<{ session: Session; project: Project; history: Message[] } | null> { ): Promise<{ session: Session; project: Project; history: Message[] } | null> {
const sessionRows = await sql<Session[]>` 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} FROM sessions WHERE id = ${sessionId}
`; `;
if (sessionRows.length === 0) return null; if (sessionRows.length === 0) return null;
@@ -157,10 +175,10 @@ async function loadContext(
const project = projectRows[0]!; const project = projectRows[0]!;
const history = await sql<Message[]>` 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 tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at
FROM messages FROM messages
WHERE session_id = ${sessionId} WHERE chat_id = ${chatId}
ORDER BY created_at ASC, id ASC ORDER BY created_at ASC, id ASC
`; `;
@@ -204,7 +222,8 @@ async function streamCompletion(
model: string, model: string,
messages: OpenAiMessage[], messages: OpenAiMessage[],
includeTools: boolean, includeTools: boolean,
onDelta: (content: string) => void onDelta: (content: string) => void,
signal?: AbortSignal
): Promise<StreamResult> { ): Promise<StreamResult> {
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
model, model,
@@ -221,6 +240,7 @@ async function streamCompletion(
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
signal,
}); });
if (!res.ok || !res.body) { if (!res.ok || !res.body) {
const text = await res.text().catch(() => ''); const text = await res.text().catch(() => '');
@@ -331,8 +351,10 @@ async function executeToolCall(
async function runAssistantTurn( async function runAssistantTurn(
ctx: InferenceContext, ctx: InferenceContext,
sessionId: string, sessionId: string,
chatId: string,
assistantMessageId: string, assistantMessageId: string,
depth: number depth: number,
signal?: AbortSignal
): Promise<void> { ): Promise<void> {
if (depth > MAX_TOOL_LOOP_DEPTH) { if (depth > MAX_TOOL_LOOP_DEPTH) {
await ctx.sql` await ctx.sql`
@@ -345,12 +367,13 @@ async function runAssistantTurn(
ctx.publish(sessionId, { ctx.publish(sessionId, {
type: 'error', type: 'error',
message_id: assistantMessageId, message_id: assistantMessageId,
chat_id: chatId,
error: 'tool loop depth exceeded', error: 'tool loop depth exceeded',
}); });
return; return;
} }
const loaded = await loadContext(ctx.sql, sessionId); const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (!loaded) { if (!loaded) {
ctx.log.warn({ sessionId }, 'inference: session or project missing'); ctx.log.warn({ sessionId }, 'inference: session or project missing');
return; return;
@@ -370,6 +393,7 @@ async function runAssistantTurn(
ctx.publish(sessionId, { ctx.publish(sessionId, {
type: 'message_started', type: 'message_started',
message_id: assistantMessageId, message_id: assistantMessageId,
chat_id: chatId,
role: 'assistant', role: 'assistant',
}); });
@@ -408,21 +432,25 @@ async function runAssistantTurn(
ctx.publish(sessionId, { ctx.publish(sessionId, {
type: 'delta', type: 'delta',
message_id: assistantMessageId, message_id: assistantMessageId,
chat_id: chatId,
content: delta, content: delta,
}); });
ctx.log.debug({ sessionId, delta }, 'inference delta'); ctx.log.debug({ sessionId, delta }, 'inference delta');
scheduleFlush(); scheduleFlush();
} },
signal
); );
} catch (err) { } catch (err) {
if (pendingFlushTimer) { if (pendingFlushTimer) {
clearTimeout(pendingFlushTimer); clearTimeout(pendingFlushTimer);
pendingFlushTimer = null; 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` await ctx.sql`
UPDATE messages UPDATE messages
SET status = 'failed', SET status = ${finalStatus},
content = ${accumulated}, content = ${accumulated},
finished_at = clock_timestamp() finished_at = clock_timestamp()
WHERE id = ${assistantMessageId} WHERE id = ${assistantMessageId}
@@ -433,12 +461,23 @@ async function runAssistantTurn(
RETURNING project_id, name, updated_at 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.publishUser({ type: 'session_updated', session_id: sessionId, project_id: failSessRow!.project_id, name: failSessRow!.name, updated_at: failSessRow!.updated_at });
if (isAbort) {
ctx.publish(sessionId, {
type: 'message_complete',
message_id: assistantMessageId,
chat_id: chatId,
});
ctx.log.info({ sessionId, chatId, assistantMessageId }, 'inference cancelled');
} else {
const errMsg = err instanceof Error ? err.message : String(err);
ctx.publish(sessionId, { ctx.publish(sessionId, {
type: 'error', type: 'error',
message_id: assistantMessageId, message_id: assistantMessageId,
chat_id: chatId,
error: errMsg, error: errMsg,
}); });
ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed'); ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed');
}
return; return;
} }
@@ -475,12 +514,14 @@ async function runAssistantTurn(
ctx.publish(sessionId, { ctx.publish(sessionId, {
type: 'tool_call', type: 'tool_call',
message_id: assistantMessageId, message_id: assistantMessageId,
chat_id: chatId,
tool_call: tc, tool_call: tc,
}); });
} }
ctx.publish(sessionId, { ctx.publish(sessionId, {
type: 'message_complete', type: 'message_complete',
message_id: assistantMessageId, message_id: assistantMessageId,
chat_id: chatId,
tokens_used: updated?.tokens_used ?? null, tokens_used: updated?.tokens_used ?? null,
ctx_used: updated?.ctx_used ?? null, ctx_used: updated?.ctx_used ?? null,
ctx_max: updated?.ctx_max ?? null, ctx_max: updated?.ctx_max ?? null,
@@ -492,8 +533,8 @@ async function runAssistantTurn(
await Promise.all( await Promise.all(
toolCalls.map(async (tc) => { toolCalls.map(async (tc) => {
const [toolRow] = await ctx.sql<{ id: string }[]>` const [toolRow] = await ctx.sql<{ id: string }[]>`
INSERT INTO messages (session_id, role, content, status, created_at) INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, 'tool', '', 'complete', clock_timestamp()) VALUES (${sessionId}, ${chatId}, 'tool', '', 'complete', clock_timestamp())
RETURNING id RETURNING id
`; `;
const toolMessageId = toolRow!.id; const toolMessageId = toolRow!.id;
@@ -512,6 +553,7 @@ async function runAssistantTurn(
ctx.publish(sessionId, { ctx.publish(sessionId, {
type: 'tool_result', type: 'tool_result',
tool_message_id: toolMessageId, tool_message_id: toolMessageId,
chat_id: chatId,
tool_call_id: tc.id, tool_call_id: tc.id,
output: tres.output, output: tres.output,
truncated: tres.truncated, truncated: tres.truncated,
@@ -521,11 +563,11 @@ async function runAssistantTurn(
); );
const [nextAssistant] = await ctx.sql<{ id: string }[]>` const [nextAssistant] = await ctx.sql<{ id: string }[]>`
INSERT INTO messages (session_id, role, content, status, created_at) INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, 'assistant', '', 'streaming', clock_timestamp()) VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id RETURNING id
`; `;
await runAssistantTurn(ctx, sessionId, nextAssistant!.id, depth + 1); await runAssistantTurn(ctx, sessionId, chatId, nextAssistant!.id, depth + 1, signal);
return; return;
} }
@@ -551,6 +593,7 @@ async function runAssistantTurn(
ctx.publish(sessionId, { ctx.publish(sessionId, {
type: 'message_complete', type: 'message_complete',
message_id: assistantMessageId, message_id: assistantMessageId,
chat_id: chatId,
tokens_used: updated?.tokens_used ?? null, tokens_used: updated?.tokens_used ?? null,
ctx_used: updated?.ctx_used ?? null, ctx_used: updated?.ctx_used ?? null,
ctx_max: updated?.ctx_max ?? null, ctx_max: updated?.ctx_max ?? null,
@@ -561,6 +604,7 @@ async function runAssistantTurn(
ctx.log.info( ctx.log.info(
{ {
sessionId, sessionId,
chatId,
assistantMessageId, assistantMessageId,
finishReason, finishReason,
chars: content.length, chars: content.length,
@@ -574,36 +618,157 @@ async function runAssistantTurn(
export async function runInference( export async function runInference(
ctx: InferenceContext, ctx: InferenceContext,
sessionId: string, sessionId: string,
assistantMessageId: string chatId: string,
assistantMessageId: string,
signal?: AbortSignal
): Promise<void> { ): 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( export function createInferenceRunner(
ctx: Omit<InferenceContext, 'publishUser'>, ctx: Omit<InferenceContext, 'publishUser'>,
publishUserFn: (user: string, frame: UserStreamFrame) => void publishUserFn: (user: string, frame: UserStreamFrame) => void
) { ) {
const registry = new Map<string, InferenceRegistration>();
return { 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 = { const callCtx: InferenceContext = {
...ctx, ...ctx,
publishUser: (frame) => publishUserFn(user, frame), publishUser: (frame) => publishUserFn(user, frame),
}; };
void (async () => { void (async () => {
try { try {
await runInference(callCtx, sessionId, assistantMessageId); await runCompact(callCtx, sessionId, chatId, compactMessageId);
setImmediate(() => {
void maybeAutoNameSession(callCtx, sessionId).catch((err) => {
callCtx.log.warn({ err, sessionId }, 'auto-name failed');
});
});
} catch (err) { } 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;
},
hasActive(chatId: string): boolean {
return registry.has(chatId);
},
}; };
} }
// Reference to keep ALL_TOOLS imported for type checks if needed
export const _toolNames = ALL_TOOLS.map((t) => t.name); 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 { export interface Project {
id: string; id: string;
name: string; name: string;
path: string; path: string;
added_at: string; added_at: string;
last_session_id: string | null; last_session_id: string | null;
status: ProjectStatus;
gitea_remote: string | null;
} }
export interface AvailableProject { export interface AvailableProject {
@@ -11,18 +17,45 @@ export interface AvailableProject {
name: string; name: string;
} }
export type SessionStatus = 'open' | 'archived';
export interface Session { export interface Session {
id: string; id: string;
project_id: string; project_id: string;
name: string; name: string;
model: string; model: string;
system_prompt: string; system_prompt: string;
status: SessionStatus;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
export type MessageRole = 'user' | 'assistant' | 'tool'; // KEEP IN SYNC: apps/server/src/schema.sql chats_status_chk
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;
}
// 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 { export interface ToolCall {
id: string; id: string;
@@ -40,8 +73,10 @@ export interface ToolResult {
export interface Message { export interface Message {
id: string; id: string;
session_id: string; session_id: string;
chat_id: string;
role: MessageRole; role: MessageRole;
content: string; content: string;
kind: MessageKind;
tool_calls: ToolCall[] | null; tool_calls: ToolCall[] | null;
tool_results: ToolResult | null; tool_results: ToolResult | null;
status: MessageStatus; status: MessageStatus;
@@ -70,6 +105,8 @@ export interface SidebarSession {
export interface SidebarProject { export interface SidebarProject {
id: string; id: string;
name: string; name: string;
path: string;
gitea_remote: string | null;
recent_sessions: SidebarSession[]; recent_sessions: SidebarSession[];
total_sessions: number; total_sessions: number;
} }
@@ -139,9 +176,68 @@ export interface SessionUpdatedFrame {
name: string; name: string;
updated_at: string; updated_at: string;
} }
export interface SessionRenamedFrame {
type: 'session_renamed';
session_id: string;
name: string;
}
export interface SessionArchivedFrame {
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 = export type UserStreamFrame =
| ProjectCreatedFrame | ProjectCreatedFrame
| ProjectDeletedFrame | ProjectDeletedFrame
| SessionCreatedFrame | SessionCreatedFrame
| SessionDeletedFrame | SessionDeletedFrame
| SessionUpdatedFrame; | SessionUpdatedFrame
| SessionRenamedFrame
| 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 { ProjectSidebar } from '@/components/ProjectSidebar';
import { RightRail } from '@/components/RightRail';
import { Home } from '@/pages/Home'; import { Home } from '@/pages/Home';
import { Project } from '@/pages/Project'; import { Project } from '@/pages/Project';
import { Session } from '@/pages/Session'; import { Session } from '@/pages/Session';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import { useUserEvents } from '@/hooks/useUserEvents'; 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() { function AppShell() {
useUserEvents(); useUserEvents();
return ( return (
@@ -18,6 +36,9 @@ function AppShell() {
<Route path="/session/:id" element={<Session />} /> <Route path="/session/:id" element={<Session />} />
</Routes> </Routes>
</main> </main>
<Routes>
<Route path="/session/:id" element={<SessionRightRail />} />
</Routes>
<Toaster position="bottom-right" /> <Toaster position="bottom-right" />
</div> </div>
); );

View File

@@ -2,14 +2,12 @@ import type {
Project, Project,
AvailableProject, AvailableProject,
Session, Session,
Chat,
Message, Message,
ModelInfo, ModelInfo,
SidebarResponse, SidebarResponse,
ListDirResult, ListDirResult,
ViewFileResult, ViewFileResult,
Pane,
PaneCreateRequest,
PaneUpdateRequest,
} from './types'; } from './types';
export class ApiError extends Error { export class ApiError extends Error {
@@ -43,13 +41,43 @@ export const api = {
health: () => request<{ status: string; db: boolean }>('/api/health'), health: () => request<{ status: string; db: boolean }>('/api/health'),
projects: { 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'), available: () => request<AvailableProject[]>('/api/projects/available'),
add: (body: { path: string; name?: string }) => add: (body: { path: string; name?: string }) =>
request<Project>('/api/projects', { request<Project>('/api/projects', {
method: 'POST', method: 'POST',
body: JSON.stringify(body), 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) => remove: (id: string) =>
request<void>(`/api/projects/${id}`, { method: 'DELETE' }), request<void>(`/api/projects/${id}`, { method: 'DELETE' }),
listDir: (id: string, path: string) => listDir: (id: string, path: string) =>
@@ -61,8 +89,8 @@ export const api = {
}, },
sessions: { sessions: {
listForProject: (projectId: string) => listForProject: (projectId: string, status?: 'open' | 'archived') =>
request<Session[]>(`/api/projects/${projectId}/sessions`), request<Session[]>(`/api/projects/${projectId}/sessions${status ? `?status=${status}` : ''}`),
create: ( create: (
projectId: string, projectId: string,
body: { name?: string; model?: string; system_prompt?: string } body: { name?: string; model?: string; system_prompt?: string }
@@ -82,24 +110,71 @@ export const api = {
}), }),
remove: (id: string) => remove: (id: string) =>
request<void>(`/api/sessions/${id}`, { method: 'DELETE' }), 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 }) }
),
fork: (chatId: string, body: { messageId: string; name?: string }) =>
request<Chat>(`/api/chats/${chatId}/fork`, {
method: 'POST',
body: JSON.stringify({ message_id: body.messageId, name: body.name }),
}),
}, },
messages: { messages: {
list: (sessionId: string) => list: (sessionId: string) =>
request<Message[]>(`/api/sessions/${sessionId}/messages`), 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 }>( request<{ user_message_id: string; assistant_message_id: string }>(
`/api/sessions/${sessionId}/messages`, `/api/chats/${chatId}/messages`,
{ {
method: 'POST', method: 'POST',
body: JSON.stringify({ content }), body: JSON.stringify({ content }),
} }
), ),
regenerate: (sessionId: string, messageId: string) => regenerate: (chatId: string, messageId: string) =>
request<{ assistant_message_id: string }>( request<{ assistant_message_id: string }>(
`/api/sessions/${sessionId}/messages/${messageId}/regenerate`, `/api/chats/${chatId}/messages/${messageId}/regenerate`,
{ method: 'POST' } { method: 'POST' }
), ),
remove: (chatId: string, messageId: string) =>
request<void>(`/api/chats/${chatId}/messages/${messageId}`, {
method: 'DELETE',
}),
}, },
models: () => request<ModelInfo[]>('/api/models'), models: () => request<ModelInfo[]>('/api/models'),
@@ -116,21 +191,4 @@ export const api = {
sidebar: { sidebar: {
get: () => request<SidebarResponse>('/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 { export interface Project {
id: string; id: string;
name: string; name: string;
path: string; path: string;
added_at: string; added_at: string;
last_session_id: string | null; last_session_id: string | null;
status: ProjectStatus;
gitea_remote: string | null;
} }
export interface AvailableProject { export interface AvailableProject {
@@ -11,18 +16,38 @@ export interface AvailableProject {
name: string; name: string;
} }
export type SessionStatus = 'open' | 'archived';
export interface Session { export interface Session {
id: string; id: string;
project_id: string; project_id: string;
name: string; name: string;
model: string; model: string;
system_prompt: string; system_prompt: string;
status: SessionStatus;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
export type MessageRole = 'user' | 'assistant' | 'tool'; export const CHAT_STATUSES = ['open', 'archived'] as const;
export type MessageStatus = 'streaming' | 'complete' | 'failed'; 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 { export interface ToolCall {
id: string; id: string;
@@ -40,8 +65,10 @@ export interface ToolResult {
export interface Message { export interface Message {
id: string; id: string;
session_id: string; session_id: string;
chat_id: string;
role: MessageRole; role: MessageRole;
content: string; content: string;
kind: MessageKind;
tool_calls: ToolCall[] | null; tool_calls: ToolCall[] | null;
tool_results: ToolResult | null; tool_results: ToolResult | null;
status: MessageStatus; status: MessageStatus;
@@ -70,6 +97,8 @@ export interface SidebarSession {
export interface SidebarProject { export interface SidebarProject {
id: string; id: string;
name: string; name: string;
path: string;
gitea_remote: string | null;
recent_sessions: SidebarSession[]; recent_sessions: SidebarSession[];
total_sessions: number; total_sessions: number;
} }
@@ -127,14 +156,25 @@ export interface PaneUpdateRequest {
position?: number; 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 = export type WsFrame =
| { type: 'snapshot'; messages: Message[] } | { type: 'snapshot'; messages: Message[] }
| { type: 'message_started'; message_id: string; role: MessageRole } | { type: 'message_started'; message_id: string; chat_id?: string; role: MessageRole }
| { type: 'delta'; message_id: string; content: string } | { type: 'delta'; message_id: string; chat_id?: string; content: string }
| { type: 'tool_call'; message_id: string; tool_call: ToolCall } | { type: 'tool_call'; message_id: string; chat_id?: string; tool_call: ToolCall }
| { | {
type: 'tool_result'; type: 'tool_result';
tool_message_id: string; tool_message_id: string;
chat_id?: string;
tool_call_id: string; tool_call_id: string;
output: unknown; output: unknown;
truncated: boolean; truncated: boolean;
@@ -143,12 +183,14 @@ export type WsFrame =
| { | {
type: 'message_complete'; type: 'message_complete';
message_id: string; message_id: string;
chat_id?: string;
tokens_used?: number | null; tokens_used?: number | null;
ctx_used?: number | null; ctx_used?: number | null;
ctx_max?: number | null; ctx_max?: number | null;
started_at?: string | null; started_at?: string | null;
finished_at?: string | null; finished_at?: string | null;
} }
| { type: 'messages_deleted'; message_ids: string[] } | { type: 'messages_deleted'; message_ids: string[]; chat_id?: string }
| { type: 'session_renamed'; session_id: string; name: string } | { type: 'session_renamed'; session_id: string; name: string; chat_id?: string }
| { type: 'error'; message_id?: string; error: 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 { useEffect, useState } from 'react';
import { api } from '@/api/client'; import { api } from '@/api/client';
import type { AvailableProject } from '@/api/types'; import type { AvailableProject } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
@@ -43,8 +42,8 @@ export function AddProjectModal({ open, onOpenChange, onAdded }: Props) {
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
const created = await api.projects.add({ path }); await api.projects.add({ path });
sessionEvents.emit({ type: 'project_created', project: created }); // Server publishes project_created via WS; let useUserEvents deliver it.
onAdded(); onAdded();
onOpenChange(false); onOpenChange(false);
} catch (err) { } 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 { Send } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button'; 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 { interface Props {
disabled?: boolean; disabled?: boolean;
projectId: string;
onSend: (content: string) => void | Promise<void>; 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 [value, setValue] = useState('');
const [busy, setBusy] = useState(false); 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() { async function submit() {
const text = value.trim(); const text = value.trim();
if (!text || disabled || busy) return; if (!text && attachments.length === 0) return;
if (disabled || busy) return;
setBusy(true); setBusy(true);
try { try {
await onSend(text); const body = flattenToMessage(attachments, text);
await onSend(body);
setValue(''); setValue('');
setAttachments([]);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to send'); toast.error(err instanceof Error ? err.message : 'failed to send');
} finally { } 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>) { 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(); e.preventDefault();
void submit(); 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 ( return (
<div className="border-t px-4 py-3 flex items-end gap-2"> <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 <Textarea
ref={textareaRef}
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={handleChange}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
placeholder="Ask about this project. Cmd/Ctrl+Enter to send." placeholder="Ask about this project. Enter to send, Shift+Enter for newline."
disabled={disabled || busy} disabled={disabled || busy}
rows={3} rows={3}
className="resize-none min-h-[68px] max-h-[240px]" className="resize-none min-h-[68px] max-h-[240px]"
/> />
<Button <Button
onClick={() => void submit()} onClick={() => void submit()}
disabled={disabled || busy || !value.trim()} disabled={disabled || busy || (!value.trim() && attachments.length === 0)}
size="icon-lg" size="icon-lg"
aria-label="Send" aria-label="Send"
> >
<Send /> <Send />
</Button> </Button>
</div> </div>
</div>
<AttachmentPreviewModal
attachment={previewAttachment}
onClose={() => setPreviewAttachment(null)}
/>
{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,13 +2,22 @@ import { Children, cloneElement, isValidElement, useState } from 'react';
import type { ReactElement, ReactNode } from 'react'; import type { ReactElement, ReactNode } from 'react';
import Markdown from 'react-markdown'; import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { Copy, RefreshCw, Check } from 'lucide-react'; import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Message } from '@/api/types'; import type { Chat, Message } from '@/api/types';
import { api } from '@/api/client'; import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
import { ToolCallCard } from './ToolCallCard'; import { ToolCallCard } from './ToolCallCard';
import { CodeBlock } from './CodeBlock'; import { CodeBlock } from './CodeBlock';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
// Match path-shaped substrings ending in `.ext`. Additionally require a `/` // Match path-shaped substrings ending in `.ext`. Additionally require a `/`
// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't // in the match to reduce false positives in prose (e.g. plain `foo.ts` won't
@@ -84,7 +93,7 @@ function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
interface Props { interface Props {
message: Message; message: Message;
sessionId: string; sessionChats?: Chat[];
} }
function MarkdownBody({ content }: { content: string }) { function MarkdownBody({ content }: { content: string }) {
@@ -193,13 +202,14 @@ function StatsLine({ message }: { message: Message }) {
function ActionRow({ function ActionRow({
message, message,
sessionId,
}: { }: {
message: Message; message: Message;
sessionId: string;
}) { }) {
const [justCopied, setJustCopied] = useState(false); const [justCopied, setJustCopied] = useState(false);
const [regenerating, setRegenerating] = useState(false); const [regenerating, setRegenerating] = useState(false);
const [forking, setForking] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
async function copy() { async function copy() {
try { try {
@@ -215,7 +225,7 @@ function ActionRow({
if (regenerating || message.status === 'streaming') return; if (regenerating || message.status === 'streaming') return;
setRegenerating(true); setRegenerating(true);
try { try {
await api.messages.regenerate(sessionId, message.id); await api.messages.regenerate(message.chat_id, message.id);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'regenerate failed'); toast.error(err instanceof Error ? err.message : 'regenerate failed');
} finally { } finally {
@@ -223,10 +233,39 @@ function ActionRow({
} }
} }
async function fork() {
if (forking || message.status !== 'complete') return;
setForking(true);
try {
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
sessionEvents.emit({ type: 'open_chat_in_active_pane', chat_id: chat.id });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'fork failed');
} finally {
setForking(false);
}
}
async function confirmDelete() {
if (deleting) return;
setDeleting(true);
try {
await api.messages.remove(message.chat_id, message.id);
setDeleteOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'delete failed');
} finally {
setDeleting(false);
}
}
const isAssistant = message.role === 'assistant'; const isAssistant = message.role === 'assistant';
const canRegen = isAssistant && message.status !== 'streaming'; const canRegen = isAssistant && message.status !== 'streaming';
const canFork = message.status === 'complete';
const canDelete = message.status !== 'streaming';
return ( return (
<>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button <button
type="button" type="button"
@@ -249,11 +288,187 @@ function ActionRow({
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} /> <RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
</button> </button>
)} )}
<button
type="button"
onClick={() => void fork()}
disabled={!canFork || forking}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Fork from here"
title="Fork from here"
>
<GitFork className="size-3" />
</button>
<button
type="button"
onClick={() => setDeleteOpen(true)}
disabled={!canDelete}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Delete message"
title="Delete message"
>
<Trash2 className="size-3" />
</button>
</div>
<Dialog
open={deleteOpen}
onOpenChange={(open) => {
if (!deleting) setDeleteOpen(open);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this message and all messages after it?</DialogTitle>
<DialogDescription>
This removes the selected message and every later message in this chat. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteOpen(false)}
disabled={deleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => void confirmDelete()}
disabled={deleting}
>
{deleting ? 'Deleting…' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
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> </div>
); );
} }
export function MessageBubble({ message, sessionId }: Props) { export function MessageBubble({ message, sessionChats }: Props) {
if (message.kind === 'compact') {
return <CompactCard message={message} sessionChats={sessionChats} />;
}
if (message.role === 'tool') { if (message.role === 'tool') {
return <ToolCallCard message={message} />; return <ToolCallCard message={message} />;
} }
@@ -264,7 +479,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"> <div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap">
{message.content} {message.content}
</div> </div>
<ActionRow message={message} sessionId={sessionId} /> <ActionRow message={message} />
</div> </div>
); );
} }
@@ -292,7 +507,7 @@ export function MessageBubble({ message, sessionId }: Props) {
)} )}
{!isStreaming && <StatsLine message={message} />} {!isStreaming && <StatsLine message={message} />}
{!isStreaming && (hasContent || hasToolCalls) && ( {!isStreaming && (hasContent || hasToolCalls) && (
<ActionRow message={message} sessionId={sessionId} /> <ActionRow message={message} />
)} )}
</div> </div>
); );

View File

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

View File

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

View File

@@ -1,19 +1,28 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom'; 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 { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
DropdownMenu, ContextMenu,
DropdownMenuContent, ContextMenuContent,
DropdownMenuItem, ContextMenuItem,
DropdownMenuTrigger, ContextMenuSeparator,
} from '@/components/ui/dropdown-menu'; ContextMenuTrigger,
} from '@/components/ui/context-menu';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { AddProjectModal } from './AddProjectModal'; import { AddProjectModal } from './AddProjectModal';
import { api } from '@/api/client'; import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
import { useSidebar } from '@/hooks/useSidebar'; import { useSidebar } from '@/hooks/useSidebar';
import type { SidebarProject } from '@/api/types'; import type { SidebarProject } from '@/api/types';
import { giteaUrlFor } from '@/lib/projectUrls';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const EXPANDED_KEY = 'boocode.sidebar.expanded'; const EXPANDED_KEY = 'boocode.sidebar.expanded';
@@ -91,6 +100,12 @@ export function ProjectSidebar() {
useSidebar(); useSidebar();
const [addOpen, setAddOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
const [expanded, setExpanded] = useState<Set<string>>(() => readExpanded()); 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 navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const lastToastedError = useRef<string | null>(null); const lastToastedError = useRef<string | null>(null);
@@ -123,13 +138,57 @@ export function ProjectSidebar() {
}); });
} }
async function handleRemove(id: string) { async function handleArchiveProject(id: string) {
try { try {
await api.projects.remove(id); await api.projects.archive(id);
sessionEvents.emit({ type: 'project_deleted', project_id: id }); // Server publishes project_archived via WS.
navigate('/'); if (activeProject === id) navigate('/');
} catch (err) { } 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,17 +234,10 @@ export function ProjectSidebar() {
const visible = p.recent_sessions.slice(0, MAX_VISIBLE_SESSIONS); const visible = p.recent_sessions.slice(0, MAX_VISIBLE_SESSIONS);
return ( return (
<div key={p.id} className="px-2"> <div key={p.id} className="px-2">
<DropdownMenu> <ContextMenu>
<ContextMenuTrigger asChild>
<div <div
className={`group flex items-center gap-1 rounded-md px-2 py-1.5 text-sm ${rowCls(isActiveProject)}`} 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 <button
type="button" type="button"
@@ -207,26 +259,71 @@ export function ProjectSidebar() {
className={`size-3.5 transition-transform ${isExpanded ? 'rotate-90' : ''}`} className={`size-3.5 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
/> />
</button> </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"> <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" /> <Folder className="size-3.5 shrink-0 opacity-70" />
<span className="truncate" title={p.name}>{p.name}</span> <span className="truncate" title={p.name}>{p.name}</span>
</NavLink> </NavLink>
)}
</div> </div>
<DropdownMenuTrigger asChild> </ContextMenuTrigger>
<button data-ctxtrigger className="hidden" aria-hidden /> <ContextMenuContent>
</DropdownMenuTrigger> <ContextMenuItem onSelect={() => {
<DropdownMenuContent align="start"> setRenamingProject(p.id);
<DropdownMenuItem variant="destructive" onClick={() => void handleRemove(p.id)}> setRenameProjectValue(p.name);
Remove from sidebar }}>
</DropdownMenuItem> Rename
</DropdownMenuContent> </ContextMenuItem>
</DropdownMenu> <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 && ( {isExpanded && (
<div className="ml-5 mt-0.5 space-y-0.5"> <div className="ml-5 mt-0.5 space-y-0.5">
{visible.map((s) => ( {visible.map((s) => (
<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 <NavLink
key={s.id}
to={`/session/${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)}`} className={`flex items-center gap-2 rounded-md px-2 py-1 text-sm min-w-0 ${rowCls(activeSession === s.id)}`}
> >
@@ -236,6 +333,27 @@ export function ProjectSidebar() {
{relTime(s.updated_at)} {relTime(s.updated_at)}
</span> </span>
</NavLink> </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 && ( {p.total_sessions > MAX_VISIBLE_SESSIONS && (
<NavLink <NavLink
@@ -253,6 +371,60 @@ export function ProjectSidebar() {
</nav> </nav>
<AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={() => {}} /> <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> </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,20 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import type { DragEvent } from 'react'; import type { DragEvent } from 'react';
import { Plus } from 'lucide-react'; import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
import { usePanes } from '@/hooks/usePanes'; import { toast } from 'sonner';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
import type { FileBrowserPaneState, Pane, PaneKind } from '@/api/types'; import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
import { PaneTab } from '@/components/PaneTab'; import type { Chat, WorkspacePane } from '@/api/types';
import { PaneShell } from '@/components/panes/PaneShell';
import { ChatPane } from '@/components/panes/ChatPane'; 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'; import { cn } from '@/lib/utils';
interface Props { interface Props {
@@ -16,324 +23,502 @@ interface Props {
} }
const MAX_PANES = 5; const MAX_PANES = 5;
const STORAGE_KEY = 'boocode.workspace.panes';
function PaneSkeleton() { function generateId(): string {
return ( return crypto.randomUUID();
<div className="flex flex-col h-full">
<div className="flex items-center border-b border-border bg-muted/20 h-8" />
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">
Loading panes...
</div>
</div>
);
} }
function PaneError({ function emptyPane(): WorkspacePane {
message, return { id: generateId(), kind: 'empty', chatIds: [], activeChatIdx: -1 };
onRetry, }
}: {
message: string; function chatPane(chatId: string): WorkspacePane {
onRetry: () => void | Promise<void>; return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
}) { }
return (
<div className="flex flex-col h-full items-center justify-center gap-2 text-sm"> function loadPanes(sessionId: string): WorkspacePane[] | null {
<span className="text-destructive">{message}</span> try {
<button const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`);
type="button" if (!raw) return null;
onClick={() => void onRetry()} const parsed = JSON.parse(raw) as WorkspacePane[];
className="text-xs underline text-muted-foreground hover:text-foreground" if (!Array.isArray(parsed) || parsed.length === 0) return null;
> return parsed;
Retry } catch {
</button> return null;
</div> }
); }
function savePanes(sessionId: string, panes: WorkspacePane[]): void {
try {
localStorage.setItem(`${STORAGE_KEY}.${sessionId}`, JSON.stringify(panes));
} catch { /* quota or disabled */ }
} }
export function Workspace({ sessionId, projectId }: Props) { export function Workspace({ sessionId, projectId }: Props) {
const { panes, loading, error, create, update, remove, refresh } = const [panes, setPanes] = useState<WorkspacePane[]>(() => {
usePanes(sessionId); return loadPanes(sessionId) ?? [emptyPane()];
const [activeId, setActiveId] = useState<string | null>(null); });
const draggingIdRef = useRef<string | null>(null); 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(() => { useEffect(() => {
if (!panes || panes.length === 0) { let cancelled = false;
if (activeId !== null) setActiveId(null); api.chats.listForSession(sessionId).then((list) => {
if (cancelled) return;
setChats(list);
const openChat = list.find((c) => c.status === 'open');
if (openChat) {
setPanes((prev) => {
if (prev.length === 1 && prev[0]!.kind === 'empty') {
return [chatPane(openChat.id)];
}
return prev;
});
}
}).catch(() => {});
return () => { cancelled = true; };
}, [sessionId]);
useEffect(() => {
savePanes(sessionId, panes);
}, [sessionId, panes]);
useEffect(() => {
const active = panes[activePaneIdx];
if (!active) {
clearActivePane();
return; return;
} }
if (!panes.some((p) => p.id === activeId)) { setActivePaneInfo({
setActiveId(panes[0]!.id); sessionId,
} paneId: active.id,
}, [panes, activeId]); kind: active.kind,
activeFile: null,
});
}, [sessionId, panes, activePaneIdx]);
// Tracks an in-flight create() call so rapid open_file_in_browser events useEffect(() => {
// don't race to each spawn a new file_browser pane. While a create is in return () => {
// progress the subsequent events wait for it and update the same pane. clearActivePane();
const creatingRef = useRef<{ id: string; promise: Promise<string> } | null>( };
null }, []);
);
const activePaneIdxRef = useRef(activePaneIdx);
activePaneIdxRef.current = activePaneIdx;
// 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(() => { useEffect(() => {
return sessionEvents.subscribe((event) => { return sessionEvents.subscribe((event) => {
if (event.type !== 'open_file_in_browser') return; if (event.type === 'chat_created' && event.session_id === sessionId) {
void (async () => { setChats((prev) => {
// If a create is already in flight, wait for it to finish then update if (prev.some((c) => c.id === event.chat.id)) return prev;
// the newly-created pane rather than spawning a second one. return [event.chat, ...prev];
if (creatingRef.current) {
const { id: pendingId, promise } = creatingRef.current;
const resolvedId = await promise;
const targetId = resolvedId || pendingId;
const current = panesRef.current;
const fb = current?.find((p) => p.id === targetId);
const nextState: FileBrowserPaneState = {
...(fb?.kind === 'file_browser' ? fb.state : {}),
open_file: event.path,
};
await update(targetId, { state: nextState });
setActiveId(targetId);
return;
}
const current = panesRef.current;
if (!current) return;
const fb = current.find(
(p): p is Pane & { kind: 'file_browser' } =>
p.kind === 'file_browser'
);
if (fb) {
const nextState: FileBrowserPaneState = {
...fb.state,
open_file: event.path,
};
await update(fb.id, { state: nextState });
setActiveId(fb.id);
} else if (current.length < MAX_PANES) {
// Reserve the slot immediately so concurrent events see the flag.
const createPromise = (async (): Promise<string> => {
const newPane = await create({ kind: 'file_browser' });
return newPane.id;
})();
// Use a stable object; id is filled in once resolved.
const entry: { id: string; promise: Promise<string> } = {
id: '',
promise: createPromise,
};
creatingRef.current = entry;
try {
const newId = await createPromise;
entry.id = newId;
const nextState: FileBrowserPaneState = {
open_file: event.path,
filter: '',
expanded_dirs: [],
};
await update(newId, { state: nextState });
setActiveId(newId);
} finally {
if (creatingRef.current === entry) {
creatingRef.current = null;
}
}
}
})();
}); });
}, [create, update]);
const handleClose = useCallback(
async (id: string) => {
try {
await remove(id);
} catch {
/* error surfaced via hook state */
} }
}, if (event.type === 'chat_updated') {
[remove] setChats((prev) => prev.map((c) =>
); c.id === event.chat_id ? { ...c, name: event.name, updated_at: event.updated_at } : c
));
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 */
} }
}, if (event.type === 'chat_archived') {
[create] 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);
}
if (event.type === 'open_chat_in_active_pane') {
openChatInPane(activePaneIdxRef.current, event.chat_id);
}
});
}, [sessionId]);
const handleCloseOthers = useCallback( function removeChatFromPanes(chatId: string) {
async (id: string) => { setPanes((prev) => prev.map((p) => {
const current = panesRef.current; const idx = p.chatIds.indexOf(chatId);
if (!current) return; if (idx < 0) return p;
const targets = current.filter((p) => p.id !== id).map((p) => p.id); const nextIds = p.chatIds.filter((id) => id !== chatId);
for (const targetId of targets) { if (nextIds.length === 0) {
return { ...p, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 };
}
const nextActiveIdx = Math.min(p.activeChatIdx, nextIds.length - 1);
return {
...p,
chatIds: nextIds,
activeChatIdx: nextActiveIdx,
chatId: nextIds[nextActiveIdx],
};
}));
}
const openChatInPane = useCallback((paneIdx: number, chatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const existing = pane.chatIds.indexOf(chatId);
if (existing >= 0) {
next[paneIdx] = { ...pane, kind: 'chat', chatId, activeChatIdx: existing };
} else {
const newIds = [...pane.chatIds, chatId];
next[paneIdx] = {
...pane,
kind: 'chat',
chatId,
chatIds: newIds,
activeChatIdx: newIds.length - 1,
};
}
return next;
});
setActivePaneIdx(paneIdx);
}, []);
const switchTab = useCallback((paneIdx: number, tabIdx: number) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const chatId = pane.chatIds[tabIdx];
if (!chatId) return prev;
next[paneIdx] = { ...pane, chatId, activeChatIdx: tabIdx };
return next;
});
}, []);
const removeTab = useCallback((paneIdx: number, chatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const nextIds = pane.chatIds.filter((id) => id !== chatId);
if (nextIds.length === 0) {
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
} else {
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
next[paneIdx] = {
...pane,
chatIds: nextIds,
activeChatIdx: nextActiveIdx,
chatId: nextIds[nextActiveIdx],
};
}
return next;
});
}, []);
// Keep only the right-clicked tab open in this pane.
const closeOtherTabs = useCallback((paneIdx: number, keepChatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const keepIdx = pane.chatIds.indexOf(keepChatId);
if (keepIdx < 0) return prev;
next[paneIdx] = {
...pane,
kind: 'chat',
chatId: keepChatId,
chatIds: [keepChatId],
activeChatIdx: 0,
};
return next;
});
}, []);
// Close every tab to the right of the right-clicked one.
const closeTabsToRight = useCallback((paneIdx: number, pivotChatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const pivotIdx = pane.chatIds.indexOf(pivotChatId);
if (pivotIdx < 0 || pivotIdx === pane.chatIds.length - 1) return prev;
const nextIds = pane.chatIds.slice(0, pivotIdx + 1);
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
next[paneIdx] = {
...pane,
chatIds: nextIds,
activeChatIdx: nextActiveIdx,
chatId: nextIds[nextActiveIdx],
};
return next;
});
}, []);
// Close every tab in this pane; land on landing page.
const closeAllTabs = useCallback((paneIdx: number) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
return next;
});
}, []);
const createChat = useCallback(async (paneIdx: number) => {
try { try {
await remove(targetId); const chat = await api.chats.create(sessionId);
} catch { // Optimistic local insert; the WS chat_created echo will be deduped by id.
// Stop on first failure to avoid cascading errors. setChats((prev) => {
if (prev.some((c) => c.id === chat.id)) return prev;
return [chat, ...prev];
});
openChatInPane(paneIdx, chat.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create chat');
}
}, [sessionId, openChatInPane]);
const archiveChat = useCallback(async (chatId: string) => {
try {
await api.chats.archive(chatId);
// Server publishes chat_archived; bus forwarder updates state.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to archive chat');
}
}, []);
const unarchiveChat = useCallback(async (chatId: string) => {
try {
await api.chats.unarchive(chatId);
// Server publishes chat_unarchived.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to restore chat');
}
}, []);
const deleteChat = useCallback(async (chatId: string) => {
try {
await api.chats.remove(chatId);
setChats((prev) => prev.filter((c) => c.id !== chatId));
removeChatFromPanes(chatId);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete chat');
}
}, []);
const renameChat = useCallback(async (chatId: string, name: string) => {
try {
await api.chats.update(chatId, { name });
setChats((prev) => prev.map((c) =>
c.id === chatId ? { ...c, name } : c
));
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to rename chat');
}
}, []);
const showLandingPage = useCallback((paneIdx: number) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined };
return next;
});
}, []);
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => {
if (kind === 'terminal') {
toast('Terminal panes coming in BooTerm');
return; return;
} }
} if (kind === 'agent') {
}, toast('Agent panes coming in BooCoder');
[remove]
);
const handleCloseToRight = useCallback(
async (idx: number) => {
const current = panesRef.current;
if (!current) return;
const targets = current.slice(idx + 1).map((p) => p.id);
for (const targetId of targets) {
try {
await remove(targetId);
} catch {
return; return;
} }
setPanes((prev) => {
if (prev.length >= MAX_PANES) {
toast.error(`Maximum ${MAX_PANES} panes`);
return prev;
} }
}, const next = [...prev, emptyPane()];
[remove] setActivePaneIdx(next.length - 1);
); return next;
});
}, []);
const handleCloseAll = useCallback(async () => { const removePane = useCallback((idx: number) => {
const current = panesRef.current; setPanes((prev) => {
if (!current) return; if (prev.length <= 1) return prev;
const targets = current.map((p) => p.id); const next = prev.filter((_, i) => i !== idx);
for (const targetId of targets) { setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
try { return next;
await remove(targetId); });
} catch { }, []);
return;
}
}
}, [remove]);
const handleAdd = useCallback(async () => { const handlePaneDragStart = useCallback(
const current = panesRef.current; (idx: number) => (e: DragEvent<HTMLDivElement>) => {
if (current && current.length >= MAX_PANES) return; draggingIdxRef.current = idx;
try {
const created = await create({ kind: 'chat' });
setActiveId(created.id);
} catch {
/* error surfaced via hook state */
}
}, [create]);
const handleDragStart = useCallback(
(id: string) => (e: DragEvent<HTMLDivElement>) => {
draggingIdRef.current = id;
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', id); e.dataTransfer.setData('text/plain', String(idx));
}, },
[] []
); );
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => { const handlePaneDragOver = useCallback(
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
if (draggingIdxRef.current === null) return;
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'move'; e.dataTransfer.dropEffect = 'move';
}, []); if (dragOverIdx !== idx) setDragOverIdx(idx);
const handleDrop = useCallback(
(targetIdx: number) => async (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
const draggedId =
draggingIdRef.current || e.dataTransfer.getData('text/plain');
draggingIdRef.current = null;
if (!draggedId) return;
const current = panesRef.current;
if (!current) return;
const draggedIdx = current.findIndex((p) => p.id === draggedId);
if (draggedIdx < 0 || draggedIdx === targetIdx) return;
try {
await update(draggedId, { position: targetIdx });
} catch {
/* error surfaced via hook state */
}
}, },
[update] [dragOverIdx]
); );
if (loading && !panes) return <PaneSkeleton />; const handlePaneDragLeave = useCallback(() => {
if (error && !panes) return <PaneError message={error} onRetry={refresh} />; setDragOverIdx(null);
if (!panes) return <PaneSkeleton />; }, []);
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 ( return (
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
<div <div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
className="flex items-center border-b border-border bg-muted/20" <DropdownMenu>
role="tablist" <DropdownMenuTrigger asChild>
>
{panes.map((pane, idx) => (
<PaneTab
key={pane.id}
pane={pane}
isActive={pane.id === activeId}
onClick={() => setActiveId(pane.id)}
onClose={() => void handleClose(pane.id)}
onSplit={(kind) => void handleSplit(idx, kind)}
onCloseOthers={() => void handleCloseOthers(pane.id)}
onCloseToRight={() => void handleCloseToRight(idx)}
onCloseAll={() => void handleCloseAll()}
onDragStart={handleDragStart(pane.id)}
onDragOver={handleDragOver}
onDrop={handleDrop(idx)}
/>
))}
<button <button
type="button" type="button"
onClick={() => void handleAdd()}
disabled={panes.length >= MAX_PANES} disabled={panes.length >= MAX_PANES}
className={cn( className={cn(
'p-1.5 ml-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground', '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' panes.length >= MAX_PANES && 'opacity-40 cursor-not-allowed hover:bg-transparent'
)} )}
aria-label="Add pane"
> >
<Plus size={14} /> <PanelRight size={14} />
Split
</button> </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>
{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 <div
className="flex-1 grid min-h-0" className="flex-1 grid min-h-0"
style={{ style={{
gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))`, gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))`,
}} }}
> >
{panes.map((pane) => ( {panes.map((pane, idx) => (
<PaneShell <div
key={pane.id} key={pane.id}
pane={pane} className={cn(
onClose={() => void handleClose(pane.id)} '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}
> >
{pane.kind === 'chat' ? ( <div
<ChatPane sessionId={sessionId} /> draggable={panes.length > 1}
) : ( onDragStart={panes.length > 1 ? handlePaneDragStart(idx) : undefined}
<FileBrowserPane onDragEnd={panes.length > 1 ? handlePaneDragEnd : undefined}
>
<ChatTabBar
pane={pane} 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} />
) : (
<SessionLandingPage
sessionId={sessionId}
projectId={projectId} projectId={projectId}
onStateChange={(state) => chats={chats}
void update(pane.id, { state }) 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> </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 { toast } from 'sonner';
import { api } from '@/api/client'; import { api } from '@/api/client';
import { useSessionStream } from '@/hooks/useSessionStream'; import { useSessionStream } from '@/hooks/useSessionStream';
import { MessageList } from '@/components/MessageList'; import { MessageList } from '@/components/MessageList';
import { ChatInput } from '@/components/ChatInput'; import { ChatInput } from '@/components/ChatInput';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface Props { interface Props {
sessionId: string; 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 stream = useSessionStream(sessionId);
const lastErrorRef = useRef<string | null>(null); 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(() => { useEffect(() => {
if (stream.error && stream.error !== lastErrorRef.current) { if (stream.error && stream.error !== lastErrorRef.current) {
lastErrorRef.current = stream.error; lastErrorRef.current = stream.error;
@@ -24,16 +35,134 @@ export function ChatPane({ sessionId }: Props) {
} }
}, [stream.error]); }, [stream.error]);
async function handleSend(content: string) { const chatMessages = stream.messages.filter((m) => m.chat_id === chatId);
await api.messages.send(sessionId, content); 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 ( return (
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
<MessageList messages={stream.messages} sessionId={sessionId} /> <MessageList messages={chatMessages} sessionChats={sessionChats} />
<ChatInput disabled={streaming} onSend={handleSend} />
{/* 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> </div>
); );
} }

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { KeyboardEvent } 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 { api, ApiError } from '@/api/client';
import type { import type {
FileBrowserPaneState, FileBrowserPaneState,
@@ -8,7 +9,8 @@ import type {
Pane, Pane,
ViewFileResult, ViewFileResult,
} from '@/api/types'; } from '@/api/types';
import { CodeBlock } from '@/components/CodeBlock'; import { inferLanguage } from '@/lib/attachments';
import { sessionEvents } from '@/hooks/sessionEvents';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface Props { interface Props {
@@ -17,49 +19,113 @@ interface Props {
onStateChange: (state: FileBrowserPaneState) => void; onStateChange: (state: FileBrowserPaneState) => void;
} }
const LANG_BY_EXT: Record<string, string> = { const SHIKI_THEME = 'github-dark';
ts: 'typescript',
tsx: 'tsx',
js: 'javascript',
jsx: 'jsx',
mjs: 'javascript',
cjs: 'javascript',
py: 'python',
go: 'go',
rs: 'rust',
rb: 'ruby',
java: 'java',
c: 'c',
h: 'c',
cpp: 'cpp',
hpp: 'cpp',
cs: 'csharp',
php: 'php',
sh: 'bash',
bash: 'bash',
zsh: 'bash',
yaml: 'yaml',
yml: 'yaml',
json: 'json',
toml: 'toml',
md: 'markdown',
markdown: 'markdown',
sql: 'sql',
dockerfile: 'dockerfile',
html: 'html',
htm: 'html',
css: 'css',
scss: 'scss',
};
function deriveLang(filePath: string): string | undefined { function splitShikiLines(html: string): string[] {
// basename const match = html.match(/<code[^>]*>([\s\S]*)<\/code>/);
const base = filePath.split('/').pop() ?? filePath; if (!match) return [];
if (base.toLowerCase() === 'dockerfile') return 'dockerfile'; const inner = match[1]!;
const dot = base.lastIndexOf('.'); const lines = inner.split(/(?=<span class="line">)/);
if (dot < 0 || dot === base.length - 1) return undefined; return lines.filter(l => l.trim().length > 0);
const ext = base.slice(dot + 1).toLowerCase(); }
return LANG_BY_EXT[ext];
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 { 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 // Directory cache: dirPath -> entries
const [cache, setCache] = useState<Map<string, FileEntry[]>>(new Map()); const [cache, setCache] = useState<Map<string, FileEntry[]>>(new Map());
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set()); const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set());
@@ -380,11 +466,43 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
const trimmedFilter = filterDraft.trim(); const trimmedFilter = filterDraft.trim();
const filterActive = trimmedFilter.length > 0; const filterActive = trimmedFilter.length > 0;
const filterResults = useMemo<FlatEntry[]>(() => {
interface FilterResult {
path: string;
name: string;
}
const filterResults = useMemo<FilterResult[]>(() => {
if (!filterActive) return []; if (!filterActive) return [];
const needle = trimmedFilter.toLowerCase(); 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 // Keyboard navigation
const [highlightedPath, setHighlightedPath] = useState<string | null>(null); const [highlightedPath, setHighlightedPath] = useState<string | null>(null);
@@ -401,7 +519,38 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
}, [highlightedPath, filterActive, filterResults, flattenedVisible]); }, [highlightedPath, filterActive, filterResults, flattenedVisible]);
function onTreeKeyDown(e: KeyboardEvent<HTMLDivElement>) { 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; if (list.length === 0) return;
const idx = highlightedPath const idx = highlightedPath
? list.findIndex((entry) => entry.path === 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 // Viewer state
const [viewer, setViewer] = useState<{ const [viewer, setViewer] = useState<{
path: string; path: string;
@@ -490,6 +664,45 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
}; };
}, [openFile, projectId]); }, [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 // Root errors / loading
const rootEntries = cache.get(''); const rootEntries = cache.get('');
const rootLoading = loadingDirs.has('') && !rootEntries; const rootLoading = loadingDirs.has('') && !rootEntries;
@@ -534,8 +747,7 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
</li> </li>
) : ( ) : (
filterResults.map((entry) => { filterResults.map((entry) => {
const isActive = const isActive = openFile === entry.path;
entry.kind === 'file' && openFile === entry.path;
const isHighlight = highlightedPath === entry.path; const isHighlight = highlightedPath === entry.path;
return ( return (
<li key={entry.path}> <li key={entry.path}>
@@ -547,19 +759,14 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
)} )}
onClick={() => { onClick={() => {
setHighlightedPath(entry.path); 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" /> <FileText size={12} className="text-muted-foreground shrink-0" />
)} <span className="truncate">
<span className="truncate">{entry.path}</span> <span className="font-bold">{entry.name}</span>
<span className="text-muted-foreground ml-1">{entry.path}</span>
</span>
</div> </div>
</li> </li>
); );
@@ -606,7 +813,7 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
<X size={12} /> <X size={12} />
</button> </button>
</div> </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' && ( {viewer?.state === 'loading' && (
<div className="text-xs text-muted-foreground px-2 py-1.5"> <div className="text-xs text-muted-foreground px-2 py-1.5">
Loading... Loading...
@@ -619,12 +826,33 @@ export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
)} )}
{viewer?.state === 'ready' && viewer.result && ( {viewer?.state === 'ready' && viewer.result && (
<div className="p-2"> <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 && ( {viewer.result.truncated && (
<div className="text-[11px] text-muted-foreground mb-1 px-2 py-1 rounded bg-muted/40 border border-border"> <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. Showing first {viewer.result.bytes_returned} bytes; file is {viewer.result.total_bytes} bytes total.
</div> </div>
)} )}
<CodeBlock code={viewer.result.content} lang={deriveLang(openFile)} /> <FileViewer
code={viewer.result.content}
lang={inferLanguage(openFile)}
selectedLines={selectedLines}
onLineClick={handleLineClick}
/>
</div> </div>
)} )}
</div> </div>

View File

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

View File

@@ -2,7 +2,8 @@
// across hooks (e.g. AI rename arriving via WS in the session view needs to // across hooks (e.g. AI rename arriving via WS in the session view needs to
// also refresh the sidebar's session list). // 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 { export interface SessionRenamedEvent {
type: 'session_renamed'; type: 'session_renamed';
@@ -51,6 +52,69 @@ export interface OpenFileInBrowserEvent {
path: string; // project-relative path: string; // project-relative
} }
export interface AttachChatFileEvent {
type: 'attach_chat_file';
attachment: Omit<Attachment, 'id'>;
}
export interface OpenChatInActivePaneEvent {
type: 'open_chat_in_active_pane';
chat_id: string;
}
export interface SessionArchivedEvent {
type: 'session_archived';
session_id: string;
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 = export type SessionEvent =
| SessionRenamedEvent | SessionRenamedEvent
| ProjectCreatedEvent | ProjectCreatedEvent
@@ -59,7 +123,18 @@ export type SessionEvent =
| SessionDeletedEvent | SessionDeletedEvent
| SessionUpdatedEvent | SessionUpdatedEvent
| SessionLoadedEvent | SessionLoadedEvent
| OpenFileInBrowserEvent; | OpenFileInBrowserEvent
| AttachChatFileEvent
| OpenChatInActivePaneEvent
| SessionArchivedEvent
| ChatCreatedEvent
| ChatUpdatedEvent
| ChatArchivedEvent
| ChatUnarchivedEvent
| ChatDeletedEvent
| ProjectArchivedEvent
| ProjectUnarchivedEvent
| ProjectUpdatedEvent;
type Listener = (event: SessionEvent) => void; type Listener = (event: SessionEvent) => void;
const listeners = new Set<Listener>(); const listeners = new Set<Listener>();

View File

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

View File

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

View File

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

View File

@@ -52,9 +52,12 @@ function load(): Promise<void> {
function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').SessionEvent): SidebarResponse { function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').SessionEvent): SidebarResponse {
switch (event.type) { switch (event.type) {
case 'project_created': { case 'project_created': {
if (prev.projects.some((p) => p.id === event.project.id)) return prev;
const fresh: SidebarProject = { const fresh: SidebarProject = {
id: event.project.id, id: event.project.id,
name: event.project.name, name: event.project.name,
path: event.project.path,
gitea_remote: event.project.gitea_remote ?? null,
recent_sessions: [], recent_sessions: [],
total_sessions: 0, total_sessions: 0,
}; };
@@ -69,6 +72,7 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
let changed = false; let changed = false;
const projects = prev.projects.map((p) => { const projects = prev.projects.map((p) => {
if (p.id !== event.project_id) return p; if (p.id !== event.project_id) return p;
if (p.recent_sessions.some((s) => s.id === event.session.id)) return p;
changed = true; changed = true;
const fresh: SidebarSession = { const fresh: SidebarSession = {
id: event.session.id, id: event.session.id,
@@ -89,8 +93,10 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
let changed = false; let changed = false;
const projects = prev.projects.map((p) => { const projects = prev.projects.map((p) => {
if (p.id !== event.project_id) return p; if (p.id !== event.project_id) return p;
changed = true;
const recent = p.recent_sessions.filter((s) => s.id !== event.session_id); 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 { return {
...p, ...p,
recent_sessions: recent, recent_sessions: recent,
@@ -140,15 +146,70 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
case 'open_file_in_browser': case 'open_file_in_browser':
// Consumed by Workspace (T7); no sidebar state change needed. // Consumed by Workspace (T7); no sidebar state change needed.
return prev; return prev;
case 'attach_chat_file':
return prev;
case 'open_chat_in_active_pane':
// Consumed by Workspace; sidebar has no business with pane state.
return prev;
case 'session_archived': {
let changed = false;
const projects = prev.projects.map((p) => {
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 // One bus subscription for the lifetime of the module. Events arriving
// before the initial fetch resolves are dropped; the eventual fetch // before the initial fetch resolves are dropped; the eventual fetch
// result is the source of truth. // result is the source of truth.
// 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) => { 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') { if (event.type === 'session_loaded') {
activeSession = { session_id: event.session_id, project_id: event.project_id }; activeSession = { session_id: event.session_id, project_id: event.project_id };
notify(); notify();
@@ -160,6 +221,7 @@ sessionEvents.subscribe((event) => {
sharedData = next; sharedData = next;
notify(); notify();
}); });
}
interface Snapshot { interface Snapshot {
data: SidebarResponse | null; 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 { Button } from '@/components/ui/button';
import { AddProjectModal } from '@/components/AddProjectModal'; 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'; import { useSidebar } from '@/hooks/useSidebar';
export function Home() { export function Home() {
const { data } = useSidebar(); 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; 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 ( return (
<div className="flex-1 flex items-center justify-center px-6"> <div className="flex-1 flex flex-col items-center px-6 py-12 overflow-y-auto">
<div className="max-w-md text-center space-y-4"> <div className="w-full max-w-md space-y-6">
<div className="text-center space-y-3">
{empty ? ( {empty ? (
<> <>
<h1 className="text-2xl font-semibold tracking-tight">No projects yet</h1> <h1 className="text-2xl font-semibold tracking-tight">No projects yet</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Add a project from /opt to start chatting about its code. Add a project from /opt or create a new one.
</p> </p>
<Button onClick={() => setOpen(true)}>Add project</Button>
</> </>
) : ( ) : (
<> <>
<h1 className="text-2xl font-semibold tracking-tight">BooCode</h1> <h1 className="text-2xl font-semibold tracking-tight">BooCode</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Pick a project from the sidebar. Pick a project from the sidebar, or add another.
</p> </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>
<AddProjectModal open={open} onOpenChange={setOpen} onAdded={() => {}} /> </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={addOpen} onOpenChange={setAddOpen} onAdded={() => {}} />
<CreateProjectModal open={createOpen} onOpenChange={setCreateOpen} />
</div> </div>
); );
} }

View File

@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom'; import { Link, useNavigate, useParams } from 'react-router-dom';
import { Plus, MessageSquare, Trash2 } from 'lucide-react'; import { Plus, MessageSquare, Trash2, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api/client'; 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 { Button } from '@/components/ui/button';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
import { useSessions } from '@/hooks/useSessions'; import { useSessions } from '@/hooks/useSessions';
@@ -14,6 +14,8 @@ export function Project() {
const { sessions, create, remove } = useSessions(id); const { sessions, create, remove } = useSessions(id);
const [project, setProject] = useState<ProjectType | null>(null); const [project, setProject] = useState<ProjectType | null>(null);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [archivedSessions, setArchivedSessions] = useState<Session[] | null>(null);
const [showArchived, setShowArchived] = useState(false);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
@@ -23,18 +25,55 @@ export function Project() {
.catch(() => {}); .catch(() => {});
}, [id]); }, [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() { async function handleNew() {
if (!id || creating) return; if (!id || creating) return;
setCreating(true); setCreating(true);
try { try {
const s = await create({}); 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}`); navigate(`/session/${s.id}`);
} finally { } finally {
setCreating(false); 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 ( return (
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<header className="border-b px-6 py-3 flex items-center justify-between"> <header className="border-b px-6 py-3 flex items-center justify-between">
@@ -52,7 +91,7 @@ export function Project() {
</Button> </Button>
</header> </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 && ( {sessions === null && (
<div className="text-sm text-muted-foreground">Loading</div> <div className="text-sm text-muted-foreground">Loading</div>
)} )}
@@ -79,11 +118,7 @@ export function Project() {
onClick={async () => { onClick={async () => {
try { try {
await remove(s.id); await remove(s.id);
sessionEvents.emit({ // Server publishes session_deleted via WS.
type: 'session_deleted',
session_id: s.id,
project_id: id!,
});
} catch (err) { } catch (err) {
toast.error( toast.error(
err instanceof Error ? err.message : 'failed to delete session' err instanceof Error ? err.message : 'failed to delete session'
@@ -97,6 +132,61 @@ export function Project() {
))} ))}
</ul> </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>
</div> </div>
); );

View File

@@ -1,21 +1,26 @@
import { useEffect, useState } from 'react'; 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 { ChevronRight } from 'lucide-react';
import { api } from '@/api/client'; import { api } from '@/api/client';
import type { Session as SessionType } from '@/api/types'; import type { Project, Session as SessionType } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
import { useActivePane } from '@/hooks/useActivePane';
import { Workspace } from '@/components/Workspace'; import { Workspace } from '@/components/Workspace';
import { ModelPicker } from '@/components/ModelPicker'; import { ModelPicker } from '@/components/ModelPicker';
export function Session() { export function Session() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [session, setSession] = useState<SessionType | null>(null); const [session, setSession] = useState<SessionType | null>(null);
const [project, setProject] = useState<Project | null>(null);
const [name, setName] = useState(''); const [name, setName] = useState('');
const [editingName, setEditingName] = useState(false); const [editingName, setEditingName] = useState(false);
const active = useActivePane();
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
setSession(null); setSession(null);
setProject(null);
let cancelled = false; let cancelled = false;
api.sessions api.sessions
.get(id) .get(id)
@@ -23,16 +28,17 @@ export function Session() {
if (cancelled) return; if (cancelled) return;
setSession(s); setSession(s);
setName(s.name); setName(s.name);
// Emit unconditionally — the sidebar's session_loaded handler
// updates activeSession; redundant when the session is already in
// the recent_sessions cache but harmless. This lets the sidebar
// highlight the parent project for deep-linked sessions that
// aren't in the cache.
sessionEvents.emit({ sessionEvents.emit({
type: 'session_loaded', type: 'session_loaded',
session_id: id, session_id: id,
project_id: s.project_id, project_id: s.project_id,
}); });
// Load project for breadcrumb. Listing is fine — small N, cached by client.
api.projects.list().then((projects) => {
if (cancelled) return;
const p = projects.find((x) => x.id === s.project_id);
if (p) setProject(p);
}).catch(() => {});
}) })
.catch(() => {}); .catch(() => {});
return () => { return () => {
@@ -43,12 +49,19 @@ export function Session() {
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
return sessionEvents.subscribe((event) => { return sessionEvents.subscribe((event) => {
if (event.type !== 'session_renamed') return; if (event.type === 'session_renamed' && event.session_id === id) {
if (event.session_id !== id) return;
setSession((prev) => (prev ? { ...prev, name: event.name } : prev)); setSession((prev) => (prev ? { ...prev, name: event.name } : prev));
setName((prev) => (editingName ? prev : event.name)); 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() { async function saveName() {
if (!id || !session) return; if (!id || !session) return;
@@ -60,26 +73,33 @@ export function Session() {
} }
const updated = await api.sessions.update(id, { name: trimmed }); const updated = await api.sessions.update(id, { name: trimmed });
setSession(updated); setSession(updated);
sessionEvents.emit({
type: 'session_renamed',
session_id: id,
name: trimmed,
});
setEditingName(false); setEditingName(false);
// Server publishes session_renamed via broker.publishUser; no local emit needed.
} }
// Workspace only sets activeFile for file-browser panes; checking it alone
// suffices and is forward-compatible with future pane kinds.
const showActiveFile = active.sessionId === id && !!active.activeFile;
return ( return (
<div className="flex-1 flex flex-col min-h-0"> <div className="flex-1 flex flex-col min-h-0">
<header className="border-b px-4 py-2 flex items-center gap-2 shrink-0"> <header className="border-b px-4 py-2 flex items-center gap-1.5 shrink-0 text-sm">
{session && ( <Link to="/" className="text-muted-foreground hover:text-foreground">
<Link Projects
to={`/project/${session.project_id}`}
className="text-muted-foreground hover:text-foreground"
aria-label="Back to project"
>
<ChevronLeft className="size-4" />
</Link> </Link>
<ChevronRight className="size-3 text-muted-foreground/60" />
{project ? (
<Link
to={`/project/${project.id}`}
className="text-muted-foreground hover:text-foreground truncate max-w-[200px]"
title={project.name}
>
{project.name}
</Link>
) : (
<span className="text-muted-foreground/60"></span>
)} )}
<ChevronRight className="size-3 text-muted-foreground/60" />
{editingName ? ( {editingName ? (
<input <input
autoFocus autoFocus
@@ -98,14 +118,27 @@ export function Session() {
) : ( ) : (
<button <button
type="button" type="button"
className="text-sm font-medium hover:underline" className="text-sm font-medium hover:underline truncate max-w-[280px]"
onClick={() => setEditingName(true)} onClick={() => setEditingName(true)}
title={session?.name ?? ''}
> >
{session?.name ?? '…'} {session?.name ?? '…'}
</button> </button>
)} )}
{showActiveFile && active.activeFile && (
<>
<span className="text-muted-foreground/40 mx-1">·</span>
<span
className="text-xs font-mono text-muted-foreground truncate max-w-[320px]"
title={active.activeFile}
>
{active.activeFile}
</span>
</>
)}
<div className="ml-auto"> <div className="ml-auto">
{session && ( {session && (
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1">
<ModelPicker <ModelPicker
value={session.model} value={session.model}
onChange={async (model) => { onChange={async (model) => {
@@ -113,6 +146,7 @@ export function Session() {
setSession(updated); setSession(updated);
}} }}
/> />
</div>
)} )}
</div> </div>
</header> </header>

View File

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