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>
This commit is contained in:
2026-05-15 23:36:01 +00:00
parent c35ec65fc4
commit 051f3b96ae
15 changed files with 451 additions and 90 deletions

106
CLAUDE.md Normal file
View File

@@ -0,0 +1,106 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What is BooCode
Self-hosted single-user developer chat app. AI assistant with read-only file tools (view_file, list_dir, grep, find_files) running against a local llama-swap inference server. Sessions organized by project, with a multi-pane workspace (chat + file browser side by side).
## Commands
```bash
# Development (run in separate terminals)
pnpm dev:server # tsx watch, port 3000
pnpm dev:web # Vite dev server, port 5173 (proxies /api to :3000)
# Build
pnpm build # builds web then server
pnpm -C apps/server build # server only (tsc + copy schema.sql)
pnpm -C apps/web build # web only (vite)
# Type checking (no emit)
npx tsc --noEmit # project references (root)
npx tsc -p apps/web/tsconfig.app.json --noEmit # web app specifically
# IMPORTANT: root tsc --noEmit uses project references and can miss errors
# that the per-app tsconfig catches. Always verify with the per-app command
# when editing web code. The server build (pnpm -C apps/server build) is
# authoritative for server code.
# Production
docker compose build --no-cache boocode && docker compose up -d
```
There are no tests or linters configured.
## Architecture
**Monorepo**: pnpm workspaces with `apps/server` (Fastify + postgres) and `apps/web` (React + Vite).
### Server (`apps/server/src/`)
- **Fastify** with `@fastify/websocket` and `@fastify/static` (serves built frontend)
- **postgres** (porsager/postgres) with tagged-template SQL — no ORM. Schema in `schema.sql`, applied on startup. LSP may false-positive on `sql<Type[]>\`...\`` generics; CLI `tsc` / `pnpm build` is authoritative.
- **Zod** for request validation and config parsing.
Key services:
- **`services/inference.ts`** — Streams LLM responses, executes tool loops (max 5 depth), flushes to DB every 500ms. Publishes `InferenceFrame` events through the broker.
- **`services/broker.ts`** — In-memory pub/sub with two channel types: per-session (message streaming) and per-user (sidebar updates). No persistence; clients reconnect on restart.
- **`services/tools.ts`** — Four read-only file tools exposed as OpenAI function-calling schemas. All file access goes through `path_guard.ts` which resolves against project root.
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
- **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply.
Route registration: all routes registered in `index.ts` via `register*Routes(app, sql, ...)` functions. Routes are in `routes/*.ts`.
### Frontend (`apps/web/src/`)
- **React 18** + React Router v6 + **Tailwind v4** + shadcn/radix-ui primitives.
- **Shiki** for syntax highlighting (async `codeToHtml` in `CodeBlock.tsx` and `FileViewer` in `FileBrowserPane.tsx`).
- Path alias: `@/` maps to `src/`.
Key patterns:
- **`hooks/sessionEvents.ts`** — Module-singleton event bus (Set of listeners). Used for cross-component communication: session renames, file-open events, attachment dispatch. 9 event types in the discriminated union. When adding a new event type to the `SessionEvent` union, you must also add a case to the `applyEvent` switch in `useSidebar.ts` (even if it's a no-op `return prev`).
- **`hooks/useSessionStream.ts`** — WebSocket per session, `applyFrame` reducer builds message list from streaming frames.
- **`hooks/useUserEvents.ts`** — Single app-level WS to `/api/ws/user` with exponential backoff reconnect. Forwards frames onto the sessionEvents bus.
- **`hooks/usePanes.ts`** — Per-session pane CRUD with 300ms debounced state PATCH (Map-based coalescing for last-write-wins).
- **`hooks/useSidebar.ts`** — Module-singleton with Set<setState> subscriber pattern. Handles all sessionEvent types to keep sidebar in sync.
- **`api/client.ts`** — Centralized typed fetch wrapper. All endpoints under `api.*` namespace.
### Data flow for chat
1. User sends message → POST `/api/sessions/:id/messages` creates user + assistant (status=streaming) rows
2. `inference.enqueue()` starts async streaming loop
3. LLM deltas published via `broker.publish(sessionId, frame)`
4. Client's `useSessionStream` WS receives frames, `applyFrame` reducer updates message list
5. Tool calls: inference executes tools server-side, publishes tool_call/tool_result frames, loops back to LLM
6. Terminal states (complete/error): DB updated with final content + token counts, `session_updated` frame published on user channel
### Multi-pane workspace
Sessions hold 15 panes (chat or file_browser). `Workspace.tsx` renders tab strip + CSS grid layout. Pane state persisted in `session_panes` table (position + JSONB state). Tab reorder via native HTML5 drag events.
## Database
PostgreSQL 16. Tables: `projects`, `sessions`, `messages`, `settings`, `session_panes`. Schema applied idempotently on startup via `applySchema()`. Use `clock_timestamp()` (not `NOW()`) inside transactions for accurate per-statement timestamps.
Position-shift pattern for panes: negate-and-restore to avoid UNIQUE(session_id, position) collisions during reorder/insert/delete. Sentinel value -100 for the moving pane.
## Environment
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt), `DEFAULT_MODEL`, `LOG_LEVEL`.
## Workflow
- Sam reviews all diffs and commits manually. Do not commit unless explicitly asked.
- Deploy: `cd /opt/boocode && docker compose build --no-cache boocode && docker compose up -d`
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
## Conventions
- `overflowWrap` not `wordWrap` — TypeScript's CSSStyleDeclaration marks `wordWrap` as deprecated (error 6385).
- No app-layer auth. Authelia handles auth at the reverse proxy. All `broker.publishUser`/`subscribeUser` calls use `'default'` as the user key.
- TypeScript strict mode. Both apps share `tsconfig.base.json`.
- Server uses NodeNext module resolution (`.js` extensions in imports).
- Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`).
- shadcn primitives live in `components/ui/`. Don't modify them unless adding a new primitive.
- `inferLanguage()` from `lib/attachments.ts` is the canonical file-extension-to-language map. `CodeBlock.tsx` keeps its own `LANG_MAP` because it also resolves markdown fence names.

View File

@@ -24,6 +24,22 @@ async function main() {
logger: { level: config.LOG_LEVEL },
});
// Allow empty JSON bodies on POSTs that don't take a body (archive, unarchive, stop, etc.).
// Default Fastify parser throws FST_ERR_CTP_EMPTY_JSON_BODY on empty string.
app.removeContentTypeParser(['application/json']);
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
const str = (body as string) ?? '';
if (str.trim().length === 0) {
done(null, {});
return;
}
try {
done(null, JSON.parse(str));
} catch (err) {
done(err as Error, undefined);
}
});
const sql = getSql(config);
await applySchema(sql);
app.log.info('database schema applied');

View File

@@ -26,11 +26,37 @@ export function registerChatRoutes(
reply.code(404);
return { error: 'session not found' };
}
// Enriched list: computed per-chat fields via LATERAL joins.
// `effective_context_tokens` = ctx_used (prompt tokens) on the most
// recent complete assistant message — represents the current context
// window consumption post-compact.
const rows = await sql<Chat[]>`
SELECT id, session_id, name, status, created_at, updated_at
FROM chats
WHERE session_id = ${req.params.id}
ORDER BY updated_at DESC
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}
ORDER BY c.updated_at DESC
`;
return rows;
}

View File

@@ -51,6 +51,7 @@ export async function maybeAutoNameChat(
WHERE chat_id = ${chatId}
AND role = 'assistant'
AND status = 'complete'
AND content <> ''
`;
if (counts[0]?.n !== 1) return;
@@ -80,6 +81,7 @@ export async function maybeAutoNameChat(
WHERE chat_id = ${chatId}
AND role = 'assistant'
AND status = 'complete'
AND content <> ''
ORDER BY created_at ASC
LIMIT 1
`;

View File

@@ -33,6 +33,10 @@ export interface Chat {
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

View File

@@ -33,6 +33,10 @@ export interface Chat {
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';

View File

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

View File

@@ -2,7 +2,7 @@ import { Children, cloneElement, isValidElement, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2 } from 'lucide-react';
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw } from 'lucide-react';
import { toast } from 'sonner';
import type { Chat, Message } from '@/api/types';
import { api } from '@/api/client';
@@ -255,6 +255,7 @@ function CompactCard({ message, sessionChats }: { message: Message; sessionChats
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';
@@ -267,21 +268,34 @@ function CompactCard({ message, sessionChats }: { message: Message; sessionChats
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(chatId: string) {
async function handleShareToChat(chat: Chat) {
try {
await api.messages.send(chatId, summaryText);
toast.success('Summary sent to chat');
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'
);
@@ -302,36 +316,52 @@ function CompactCard({ message, sessionChats }: { message: Message; sessionChats
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>
{otherChats.length > 0 && (
<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-[160px] py-1">
{otherChats.map((c) => (
<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.id)}
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>
)}
</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}

View File

@@ -16,6 +16,13 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { AddProjectModal } from './AddProjectModal';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
@@ -100,6 +107,7 @@ export function ProjectSidebar() {
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 navigate = useNavigate();
const location = useLocation();
const lastToastedError = useRef<string | null>(null);
@@ -135,7 +143,7 @@ export function ProjectSidebar() {
async function handleRemove(id: string) {
try {
await api.projects.remove(id);
sessionEvents.emit({ type: 'project_deleted', project_id: id });
// Server publishes project_deleted via WS; useUserEvents delivers it.
navigate('/');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to remove project');
@@ -145,13 +153,23 @@ export function ProjectSidebar() {
async function handleArchiveSession(sessionId: string, projectId: string) {
try {
await api.sessions.archive(sessionId);
sessionEvents.emit({ type: 'session_archived', session_id: sessionId, project_id: projectId });
// 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);
@@ -293,10 +311,16 @@ export function ProjectSidebar() {
}}>
Rename
</ContextMenuItem>
<ContextMenuSeparator />
<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>
))}
@@ -316,6 +340,36 @@ export function ProjectSidebar() {
</nav>
<AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={() => {}} />
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) setDeleteConfirm(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete session?</DialogTitle>
<DialogDescription>
This will permanently delete {deleteConfirm ? `"${deleteConfirm.name}"` : 'this session'} and all its chats and messages. This cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
if (deleteConfirm) {
const projectId = projects.find((p) =>
p.recent_sessions.some((s) => s.id === deleteConfirm.id)
)?.id;
if (projectId) void handleDeleteSession(deleteConfirm.id, projectId);
}
setDeleteConfirm(null);
}}
>
Delete
</Button>
</div>
</DialogContent>
</Dialog>
</aside>
);
}

View File

@@ -3,6 +3,7 @@ import { MessageSquare, Send, ChevronDown, ChevronRight } from 'lucide-react';
import type { Chat } from '@/api/types';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { formatTokens } from '@/lib/format';
interface Props {
sessionId: string;
@@ -27,6 +28,51 @@ function relTime(iso: string): string {
return `${day}d ago`;
}
function ChatRow({
chat,
onClick,
dimmed,
trailing,
}: {
chat: Chat;
onClick: () => void;
dimmed?: boolean;
trailing?: string;
}) {
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;
return (
<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'}`} />
<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>
)}
</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>
);
}
export function SessionLandingPage({
chats,
onOpenChat,
@@ -50,6 +96,10 @@ export function SessionLandingPage({
setComposerValue('');
}
// 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.
// Wiring WS reactivity through here is deferred (rare use case: user is in
// a pane when messages stream, not on the landing page).
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">
@@ -60,19 +110,7 @@ export function SessionLandingPage({
<ul className="divide-y rounded-md border">
{openChats.map((chat) => (
<li key={chat.id}>
<button
type="button"
onClick={() => onOpenChat(chat.id)}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-muted/50 text-left"
>
<MessageSquare className="size-3.5 opacity-70 shrink-0" />
<span className="truncate text-sm flex-1">
{chat.name ?? 'New chat'}
</span>
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
{relTime(chat.updated_at)}
</span>
</button>
<ChatRow chat={chat} onClick={() => onOpenChat(chat.id)} />
</li>
))}
</ul>
@@ -94,19 +132,12 @@ export function SessionLandingPage({
<ul className="divide-y rounded-md border">
{closedChats.map((chat) => (
<li key={chat.id}>
<button
type="button"
<ChatRow
chat={chat}
onClick={() => void onReopenChat(chat.id)}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-muted/50 text-left"
>
<MessageSquare className="size-3.5 opacity-40 shrink-0" />
<span className="truncate text-sm flex-1 text-muted-foreground">
{chat.name ?? 'New chat'}
</span>
<span className="text-xs text-muted-foreground shrink-0">
Reopen
</span>
</button>
dimmed
trailing="Reopen"
/>
</li>
))}
</ul>

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { DragEvent } from 'react';
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
@@ -61,6 +62,8 @@ export function Workspace({ sessionId, projectId }: Props) {
const [chats, setChats] = useState<Chat[]>([]);
const chatsRef = useRef<Chat[]>([]);
chatsRef.current = chats;
const draggingIdxRef = useRef<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
@@ -87,7 +90,10 @@ export function Workspace({ sessionId, projectId }: Props) {
useEffect(() => {
return sessionEvents.subscribe((event) => {
if (event.type === 'chat_created' && event.session_id === sessionId) {
setChats((prev) => [event.chat, ...prev]);
setChats((prev) => {
if (prev.some((c) => c.id === event.chat.id)) return prev;
return [event.chat, ...prev];
});
}
if (event.type === 'chat_updated') {
setChats((prev) => prev.map((c) =>
@@ -177,8 +183,11 @@ export function Workspace({ sessionId, projectId }: Props) {
const createChat = useCallback(async (paneIdx: number) => {
try {
const chat = await api.chats.create(sessionId);
setChats((prev) => [chat, ...prev]);
sessionEvents.emit({ type: 'chat_created', chat, session_id: sessionId });
// Optimistic local insert; the WS chat_created echo will be deduped by id.
setChats((prev) => {
if (prev.some((c) => c.id === chat.id)) return prev;
return [chat, ...prev];
});
openChatInPane(paneIdx, chat.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create chat');
@@ -256,11 +265,61 @@ export function Workspace({ sessionId, projectId }: Props) {
});
}, []);
const handlePaneDragStart = useCallback(
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
draggingIdxRef.current = idx;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(idx));
},
[]
);
const handlePaneDragOver = useCallback(
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
if (draggingIdxRef.current === null) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragOverIdx !== idx) setDragOverIdx(idx);
},
[dragOverIdx]
);
const handlePaneDragLeave = useCallback(() => {
setDragOverIdx(null);
}, []);
const handlePaneDrop = useCallback(
(targetIdx: number) => (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
const fromIdx = draggingIdxRef.current;
draggingIdxRef.current = null;
setDragOverIdx(null);
if (fromIdx === null || fromIdx === targetIdx) return;
setPanes((prev) => {
const next = [...prev];
const [moved] = next.splice(fromIdx, 1);
if (!moved) return prev;
next.splice(targetIdx, 0, moved);
// Keep active selection on the same logical pane (the one being dragged).
setActivePaneIdx(targetIdx);
return next;
});
},
[]
);
const handlePaneDragEnd = useCallback(() => {
draggingIdxRef.current = null;
setDragOverIdx(null);
}, []);
const handleLandingSend = useCallback(async (paneIdx: number, content: string) => {
try {
const chat = await api.chats.create(sessionId);
setChats((prev) => [chat, ...prev]);
sessionEvents.emit({ type: 'chat_created', chat, session_id: 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) {
@@ -315,10 +374,20 @@ export function Workspace({ sessionId, projectId }: Props) {
<div
key={pane.id}
className={cn(
'flex flex-col h-full min-h-0 border-r border-border last:border-r-0',
idx === activePaneIdx && 'ring-1 ring-inset ring-ring/20'
'flex flex-col h-full min-h-0 border-r border-border last:border-r-0 relative',
idx === activePaneIdx && 'ring-1 ring-inset ring-ring/20',
dragOverIdx === idx && draggingIdxRef.current !== idx &&
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
)}
onClick={() => setActivePaneIdx(idx)}
onDragOver={panes.length > 1 ? handlePaneDragOver(idx) : undefined}
onDragLeave={panes.length > 1 ? handlePaneDragLeave : undefined}
onDrop={panes.length > 1 ? handlePaneDrop(idx) : undefined}
>
<div
draggable={panes.length > 1}
onDragStart={panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={panes.length > 1 ? handlePaneDragEnd : undefined}
>
<ChatTabBar
pane={pane}
@@ -332,6 +401,7 @@ export function Workspace({ sessionId, projectId }: Props) {
onDelete={deleteChat}
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
/>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{pane.kind === 'chat' && pane.chatId ? (

View File

@@ -52,6 +52,7 @@ function load(): Promise<void> {
function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').SessionEvent): SidebarResponse {
switch (event.type) {
case 'project_created': {
if (prev.projects.some((p) => p.id === event.project.id)) return prev;
const fresh: SidebarProject = {
id: event.project.id,
name: event.project.name,
@@ -69,6 +70,7 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
let changed = false;
const projects = prev.projects.map((p) => {
if (p.id !== event.project_id) return p;
if (p.recent_sessions.some((s) => s.id === event.session.id)) return p;
changed = true;
const fresh: SidebarSession = {
id: event.session.id,
@@ -89,8 +91,10 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
let changed = false;
const projects = prev.projects.map((p) => {
if (p.id !== event.project_id) return p;
changed = true;
const recent = p.recent_sessions.filter((s) => s.id !== event.session_id);
const wasPresent = recent.length !== p.recent_sessions.length;
if (!wasPresent) return p;
changed = true;
return {
...p,
recent_sessions: recent,

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

@@ -37,11 +37,17 @@ export function Project() {
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]);
@@ -50,7 +56,7 @@ export function Project() {
setCreating(true);
try {
const s = await create({});
sessionEvents.emit({ type: 'session_created', session: s, project_id: id });
// Server publishes session_created via WS; let useUserEvents deliver it.
navigate(`/session/${s.id}`);
} finally {
setCreating(false);
@@ -112,11 +118,7 @@ export function Project() {
onClick={async () => {
try {
await remove(s.id);
sessionEvents.emit({
type: 'session_deleted',
session_id: s.id,
project_id: id!,
});
// Server publishes session_deleted via WS.
} catch (err) {
toast.error(
err instanceof Error ? err.message : 'failed to delete session'

View File

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