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:
106
CLAUDE.md
Normal file
106
CLAUDE.md
Normal 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 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.
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ? <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"
|
||||
>
|
||||
<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="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.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>
|
||||
)}
|
||||
</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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,23 +374,34 @@ 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}
|
||||
>
|
||||
<ChatTabBar
|
||||
pane={pane}
|
||||
tabs={chatsForPane(pane)}
|
||||
onSwitchTab={(tabIdx) => 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}
|
||||
/>
|
||||
<div
|
||||
draggable={panes.length > 1}
|
||||
onDragStart={panes.length > 1 ? handlePaneDragStart(idx) : undefined}
|
||||
onDragEnd={panes.length > 1 ? handlePaneDragEnd : undefined}
|
||||
>
|
||||
<ChatTabBar
|
||||
pane={pane}
|
||||
tabs={chatsForPane(pane)}
|
||||
onSwitchTab={(tabIdx) => 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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{pane.kind === 'chat' && pane.chatId ? (
|
||||
|
||||
@@ -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,
|
||||
|
||||
5
apps/web/src/lib/format.ts
Normal file
5
apps/web/src/lib/format.ts
Normal 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`;
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { api } from '@/api/client';
|
||||
import type { Session as SessionType } from '@/api/types';
|
||||
@@ -9,6 +9,7 @@ import { ModelPicker } from '@/components/ModelPicker';
|
||||
|
||||
export function Session() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [session, setSession] = useState<SessionType | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
@@ -43,12 +44,19 @@ export function Session() {
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
return sessionEvents.subscribe((event) => {
|
||||
if (event.type !== 'session_renamed') return;
|
||||
if (event.session_id !== id) return;
|
||||
setSession((prev) => (prev ? { ...prev, name: event.name } : prev));
|
||||
setName((prev) => (editingName ? prev : event.name));
|
||||
if (event.type === 'session_renamed' && event.session_id === id) {
|
||||
setSession((prev) => (prev ? { ...prev, name: event.name } : prev));
|
||||
setName((prev) => (editingName ? prev : event.name));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(event.type === 'session_deleted' || event.type === 'session_archived') &&
|
||||
event.session_id === id
|
||||
) {
|
||||
navigate(`/project/${event.project_id}`);
|
||||
}
|
||||
});
|
||||
}, [id, editingName]);
|
||||
}, [id, editingName, navigate]);
|
||||
|
||||
async function saveName() {
|
||||
if (!id || !session) return;
|
||||
|
||||
Reference in New Issue
Block a user