From 051f3b96ae078b997994b40974f041de614e4ceb Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Fri, 15 May 2026 23:36:01 +0000 Subject: [PATCH] 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) --- CLAUDE.md | 106 +++++++++++++++++ apps/server/src/index.ts | 16 +++ apps/server/src/routes/chats.ts | 34 +++++- apps/server/src/services/auto_name.ts | 2 + apps/server/src/types/api.ts | 4 + apps/web/src/api/types.ts | 4 + apps/web/src/components/AddProjectModal.tsx | 5 +- apps/web/src/components/MessageBubble.tsx | 76 ++++++++---- apps/web/src/components/ProjectSidebar.tsx | 60 +++++++++- .../web/src/components/SessionLandingPage.tsx | 81 +++++++++---- apps/web/src/components/Workspace.tsx | 108 +++++++++++++++--- apps/web/src/hooks/useSidebar.ts | 6 +- apps/web/src/lib/format.ts | 5 + apps/web/src/pages/Project.tsx | 14 ++- apps/web/src/pages/Session.tsx | 20 +++- 15 files changed, 451 insertions(+), 90 deletions(-) create mode 100644 CLAUDE.md create mode 100644 apps/web/src/lib/format.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..41767b6 --- /dev/null +++ b/CLAUDE.md @@ -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\`...\`` 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 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 1–5 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. diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 0d263e8..13bee70 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -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'); diff --git a/apps/server/src/routes/chats.ts b/apps/server/src/routes/chats.ts index 6007a59..2e3b7d1 100644 --- a/apps/server/src/routes/chats.ts +++ b/apps/server/src/routes/chats.ts @@ -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` - 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; } diff --git a/apps/server/src/services/auto_name.ts b/apps/server/src/services/auto_name.ts index 9d6be73..46a6370 100644 --- a/apps/server/src/services/auto_name.ts +++ b/apps/server/src/services/auto_name.ts @@ -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 `; diff --git a/apps/server/src/types/api.ts b/apps/server/src/types/api.ts index 0bfef9f..54eabfe 100644 --- a/apps/server/src/types/api.ts +++ b/apps/server/src/types/api.ts @@ -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 diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index b34ca36..46d06e1 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -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'; diff --git a/apps/web/src/components/AddProjectModal.tsx b/apps/web/src/components/AddProjectModal.tsx index 5f04b27..72016d1 100644 --- a/apps/web/src/components/AddProjectModal.tsx +++ b/apps/web/src/components/AddProjectModal.tsx @@ -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) { diff --git a/apps/web/src/components/MessageBubble.tsx b/apps/web/src/components/MessageBubble.tsx index eaf7047..92e13af 100644 --- a/apps/web/src/components/MessageBubble.tsx +++ b/apps/web/src/components/MessageBubble.tsx @@ -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,35 +316,51 @@ 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 ? : } - {otherChats.length > 0 && ( -
- - {shareOpen && ( -
- {otherChats.map((c) => ( +
+ + {shareOpen && ( +
+ {otherChats.length === 0 ? ( +
+ No other chats in this session +
+ ) : ( + otherChats.map((c) => ( - ))} -
- )} -
- )} + )) + )} +
+ )} +
+ {expanded && (
diff --git a/apps/web/src/components/ProjectSidebar.tsx b/apps/web/src/components/ProjectSidebar.tsx index a769482..4d8efc1 100644 --- a/apps/web/src/components/ProjectSidebar.tsx +++ b/apps/web/src/components/ProjectSidebar.tsx @@ -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>(() => readExpanded()); const [renamingSession, setRenamingSession] = useState(null); const [renameValue, setRenameValue] = useState(''); + const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null); const navigate = useNavigate(); const location = useLocation(); const lastToastedError = useRef(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 - void handleArchiveSession(s.id, p.id)}> Archive + + setDeleteConfirm({ id: s.id, name: s.name })} + > + Delete + ))} @@ -316,6 +340,36 @@ export function ProjectSidebar() { {}} /> + + { if (!open) setDeleteConfirm(null); }}> + + + Delete session? + + This will permanently delete {deleteConfirm ? `"${deleteConfirm.name}"` : 'this session'} and all its chats and messages. This cannot be undone. + + +
+ + +
+
+
); } diff --git a/apps/web/src/components/SessionLandingPage.tsx b/apps/web/src/components/SessionLandingPage.tsx index ea951a4..717667c 100644 --- a/apps/web/src/components/SessionLandingPage.tsx +++ b/apps/web/src/components/SessionLandingPage.tsx @@ -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 ( + + ); +} + 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 (
@@ -60,19 +110,7 @@ export function SessionLandingPage({
    {openChats.map((chat) => (
  • - + onOpenChat(chat.id)} />
  • ))}
@@ -94,19 +132,12 @@ export function SessionLandingPage({
    {closedChats.map((chat) => (
  • - + dimmed + trailing="Reopen" + />
  • ))}
diff --git a/apps/web/src/components/Workspace.tsx b/apps/web/src/components/Workspace.tsx index e1ffe40..b132737 100644 --- a/apps/web/src/components/Workspace.tsx +++ b/apps/web/src/components/Workspace.tsx @@ -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([]); const chatsRef = useRef([]); chatsRef.current = chats; + const draggingIdxRef = useRef(null); + const [dragOverIdx, setDragOverIdx] = useState(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) => { + draggingIdxRef.current = idx; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(idx)); + }, + [] + ); + + const handlePaneDragOver = useCallback( + (idx: number) => (e: DragEvent) => { + 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) => { + 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,23 +374,34 @@ export function Workspace({ sessionId, projectId }: Props) {
setActivePaneIdx(idx)} + onDragOver={panes.length > 1 ? handlePaneDragOver(idx) : undefined} + onDragLeave={panes.length > 1 ? handlePaneDragLeave : undefined} + onDrop={panes.length > 1 ? handlePaneDrop(idx) : undefined} > - switchTab(idx, tabIdx)} - onRemoveTab={(chatId) => removeTab(idx, chatId)} - onNewChat={() => void createChat(idx)} - onShowHistory={() => showLandingPage(idx)} - onRename={renameChat} - onClose={closeChat} - onDelete={deleteChat} - onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined} - /> +
1} + onDragStart={panes.length > 1 ? handlePaneDragStart(idx) : undefined} + onDragEnd={panes.length > 1 ? handlePaneDragEnd : undefined} + > + switchTab(idx, tabIdx)} + onRemoveTab={(chatId) => removeTab(idx, chatId)} + onNewChat={() => void createChat(idx)} + onShowHistory={() => showLandingPage(idx)} + onRename={renameChat} + onClose={closeChat} + onDelete={deleteChat} + onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined} + /> +
{pane.kind === 'chat' && pane.chatId ? ( diff --git a/apps/web/src/hooks/useSidebar.ts b/apps/web/src/hooks/useSidebar.ts index 46aed24..ec861e9 100644 --- a/apps/web/src/hooks/useSidebar.ts +++ b/apps/web/src/hooks/useSidebar.ts @@ -52,6 +52,7 @@ function load(): Promise { 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, diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts new file mode 100644 index 0000000..b70d03f --- /dev/null +++ b/apps/web/src/lib/format.ts @@ -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`; +} diff --git a/apps/web/src/pages/Project.tsx b/apps/web/src/pages/Project.tsx index 213a97c..579692d 100644 --- a/apps/web/src/pages/Project.tsx +++ b/apps/web/src/pages/Project.tsx @@ -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' diff --git a/apps/web/src/pages/Session.tsx b/apps/web/src/pages/Session.tsx index d0b706f..fbd9648 100644 --- a/apps/web/src/pages/Session.tsx +++ b/apps/web/src/pages/Session.tsx @@ -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(null); const [name, setName] = useState(''); const [editingName, setEditingName] = useState(false); @@ -43,12 +44,19 @@ export function Session() { useEffect(() => { if (!id) return; return sessionEvents.subscribe((event) => { - if (event.type !== 'session_renamed') return; - if (event.session_id !== id) return; - setSession((prev) => (prev ? { ...prev, name: event.name } : prev)); - setName((prev) => (editingName ? prev : event.name)); + if (event.type === 'session_renamed' && event.session_id === id) { + setSession((prev) => (prev ? { ...prev, name: event.name } : prev)); + setName((prev) => (editingName ? prev : event.name)); + return; + } + if ( + (event.type === 'session_deleted' || event.type === 'session_archived') && + event.session_id === id + ) { + navigate(`/project/${event.project_id}`); + } }); - }, [id, editingName]); + }, [id, editingName, navigate]); async function saveName() { if (!id || !session) return;