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 },
|
logger: { level: config.LOG_LEVEL },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Allow empty JSON bodies on POSTs that don't take a body (archive, unarchive, stop, etc.).
|
||||||
|
// Default Fastify parser throws FST_ERR_CTP_EMPTY_JSON_BODY on empty string.
|
||||||
|
app.removeContentTypeParser(['application/json']);
|
||||||
|
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
|
||||||
|
const str = (body as string) ?? '';
|
||||||
|
if (str.trim().length === 0) {
|
||||||
|
done(null, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
done(null, JSON.parse(str));
|
||||||
|
} catch (err) {
|
||||||
|
done(err as Error, undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const sql = getSql(config);
|
const sql = getSql(config);
|
||||||
await applySchema(sql);
|
await applySchema(sql);
|
||||||
app.log.info('database schema applied');
|
app.log.info('database schema applied');
|
||||||
|
|||||||
@@ -26,11 +26,37 @@ export function registerChatRoutes(
|
|||||||
reply.code(404);
|
reply.code(404);
|
||||||
return { error: 'session not found' };
|
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[]>`
|
const rows = await sql<Chat[]>`
|
||||||
SELECT id, session_id, name, status, created_at, updated_at
|
SELECT
|
||||||
FROM chats
|
c.id, c.session_id, c.name, c.status, c.created_at, c.updated_at,
|
||||||
WHERE session_id = ${req.params.id}
|
COALESCE(mc.cnt, 0)::int AS message_count,
|
||||||
ORDER BY updated_at DESC
|
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;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export async function maybeAutoNameChat(
|
|||||||
WHERE chat_id = ${chatId}
|
WHERE chat_id = ${chatId}
|
||||||
AND role = 'assistant'
|
AND role = 'assistant'
|
||||||
AND status = 'complete'
|
AND status = 'complete'
|
||||||
|
AND content <> ''
|
||||||
`;
|
`;
|
||||||
if (counts[0]?.n !== 1) return;
|
if (counts[0]?.n !== 1) return;
|
||||||
|
|
||||||
@@ -80,6 +81,7 @@ export async function maybeAutoNameChat(
|
|||||||
WHERE chat_id = ${chatId}
|
WHERE chat_id = ${chatId}
|
||||||
AND role = 'assistant'
|
AND role = 'assistant'
|
||||||
AND status = 'complete'
|
AND status = 'complete'
|
||||||
|
AND content <> ''
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export interface Chat {
|
|||||||
status: ChatStatus;
|
status: ChatStatus;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_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
|
// KEEP IN SYNC: apps/server/src/schema.sql messages_role_chk / messages_status_chk
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export interface Chat {
|
|||||||
status: ChatStatus;
|
status: ChatStatus;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
// Populated by GET /api/sessions/:id/chats only.
|
||||||
|
message_count?: number;
|
||||||
|
last_message_preview?: string | null;
|
||||||
|
effective_context_tokens?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MessageRole = 'user' | 'assistant' | 'tool' | 'system';
|
export type MessageRole = 'user' | 'assistant' | 'tool' | 'system';
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { AvailableProject } from '@/api/types';
|
import type { AvailableProject } from '@/api/types';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -43,8 +42,8 @@ export function AddProjectModal({ open, onOpenChange, onAdded }: Props) {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const created = await api.projects.add({ path });
|
await api.projects.add({ path });
|
||||||
sessionEvents.emit({ type: 'project_created', project: created });
|
// Server publishes project_created via WS; let useUserEvents deliver it.
|
||||||
onAdded();
|
onAdded();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Children, cloneElement, isValidElement, useState } from 'react';
|
|||||||
import type { ReactElement, ReactNode } from 'react';
|
import type { ReactElement, ReactNode } from 'react';
|
||||||
import Markdown from 'react-markdown';
|
import Markdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { 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 { toast } from 'sonner';
|
||||||
import type { Chat, Message } from '@/api/types';
|
import type { Chat, Message } from '@/api/types';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
@@ -255,6 +255,7 @@ function CompactCard({ message, sessionChats }: { message: Message; sessionChats
|
|||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [shareOpen, setShareOpen] = useState(false);
|
const [shareOpen, setShareOpen] = useState(false);
|
||||||
|
const [rerunning, setRerunning] = useState(false);
|
||||||
|
|
||||||
const headerMatch = message.content.match(/^\[Context compacted — (\d+) messages summarized\]/);
|
const headerMatch = message.content.match(/^\[Context compacted — (\d+) messages summarized\]/);
|
||||||
const headerText = headerMatch ? headerMatch[0] : 'Context compacted';
|
const headerText = headerMatch ? headerMatch[0] : 'Context compacted';
|
||||||
@@ -267,21 +268,34 @@ function CompactCard({ message, sessionChats }: { message: Message; sessionChats
|
|||||||
await navigator.clipboard.writeText(summaryText);
|
await navigator.clipboard.writeText(summaryText);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 1200);
|
setTimeout(() => setCopied(false), 1200);
|
||||||
|
toast.success('Summary copied to clipboard');
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Copy failed');
|
toast.error('Copy failed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleShareToChat(chatId: string) {
|
async function handleShareToChat(chat: Chat) {
|
||||||
try {
|
try {
|
||||||
await api.messages.send(chatId, summaryText);
|
await api.messages.send(chat.id, summaryText);
|
||||||
toast.success('Summary sent to chat');
|
toast.success(`Summary sent to ${chat.name ?? 'New chat'}`);
|
||||||
setShareOpen(false);
|
setShareOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Failed to share');
|
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(
|
const otherChats = (sessionChats ?? []).filter(
|
||||||
(c) => c.id !== message.chat_id && c.status === 'open'
|
(c) => c.id !== message.chat_id && c.status === 'open'
|
||||||
);
|
);
|
||||||
@@ -302,36 +316,52 @@ function CompactCard({ message, sessionChats }: { message: Message; sessionChats
|
|||||||
onClick={() => void handleCopy()}
|
onClick={() => void handleCopy()}
|
||||||
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
||||||
aria-label="Copy summary"
|
aria-label="Copy summary"
|
||||||
|
title="Copy summary"
|
||||||
>
|
>
|
||||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||||
</button>
|
</button>
|
||||||
{otherChats.length > 0 && (
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShareOpen(!shareOpen)}
|
onClick={() => setShareOpen(!shareOpen)}
|
||||||
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
||||||
aria-label="Send to chat"
|
aria-label="Send to chat"
|
||||||
|
title="Send to chat"
|
||||||
>
|
>
|
||||||
<Share2 size={12} />
|
<Share2 size={12} />
|
||||||
</button>
|
</button>
|
||||||
{shareOpen && (
|
{shareOpen && (
|
||||||
<div className="absolute right-0 top-full mt-1 z-50 bg-popover border rounded-md shadow-md min-w-[160px] py-1">
|
<div className="absolute right-0 top-full mt-1 z-50 bg-popover border rounded-md shadow-md min-w-[180px] py-1">
|
||||||
{otherChats.map((c) => (
|
{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
|
<button
|
||||||
key={c.id}
|
key={c.id}
|
||||||
type="button"
|
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"
|
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent truncate"
|
||||||
>
|
>
|
||||||
{c.name ?? 'New chat'}
|
{c.name ?? 'New chat'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</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 && (
|
{expanded && (
|
||||||
<div className="px-3 pb-3 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap border-t pt-2">
|
<div className="px-3 pb-3 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap border-t pt-2">
|
||||||
{summaryText}
|
{summaryText}
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ import {
|
|||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from '@/components/ui/context-menu';
|
} from '@/components/ui/context-menu';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { AddProjectModal } from './AddProjectModal';
|
import { AddProjectModal } from './AddProjectModal';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
@@ -100,6 +107,7 @@ export function ProjectSidebar() {
|
|||||||
const [expanded, setExpanded] = useState<Set<string>>(() => readExpanded());
|
const [expanded, setExpanded] = useState<Set<string>>(() => readExpanded());
|
||||||
const [renamingSession, setRenamingSession] = useState<string | null>(null);
|
const [renamingSession, setRenamingSession] = useState<string | null>(null);
|
||||||
const [renameValue, setRenameValue] = useState('');
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const lastToastedError = useRef<string | null>(null);
|
const lastToastedError = useRef<string | null>(null);
|
||||||
@@ -135,7 +143,7 @@ export function ProjectSidebar() {
|
|||||||
async function handleRemove(id: string) {
|
async function handleRemove(id: string) {
|
||||||
try {
|
try {
|
||||||
await api.projects.remove(id);
|
await api.projects.remove(id);
|
||||||
sessionEvents.emit({ type: 'project_deleted', project_id: id });
|
// Server publishes project_deleted via WS; useUserEvents delivers it.
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'failed to remove project');
|
toast.error(err instanceof Error ? err.message : 'failed to remove project');
|
||||||
@@ -145,13 +153,23 @@ export function ProjectSidebar() {
|
|||||||
async function handleArchiveSession(sessionId: string, projectId: string) {
|
async function handleArchiveSession(sessionId: string, projectId: string) {
|
||||||
try {
|
try {
|
||||||
await api.sessions.archive(sessionId);
|
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}`);
|
if (activeSession === sessionId) navigate(`/project/${projectId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'failed to archive session');
|
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) {
|
async function handleRenameSession(sessionId: string) {
|
||||||
const trimmed = renameValue.trim();
|
const trimmed = renameValue.trim();
|
||||||
setRenamingSession(null);
|
setRenamingSession(null);
|
||||||
@@ -293,10 +311,16 @@ export function ProjectSidebar() {
|
|||||||
}}>
|
}}>
|
||||||
Rename
|
Rename
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuSeparator />
|
|
||||||
<ContextMenuItem onSelect={() => void handleArchiveSession(s.id, p.id)}>
|
<ContextMenuItem onSelect={() => void handleArchiveSession(s.id, p.id)}>
|
||||||
Archive
|
Archive
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onSelect={() => setDeleteConfirm({ id: s.id, name: s.name })}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</ContextMenuItem>
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
))}
|
))}
|
||||||
@@ -316,6 +340,36 @@ export function ProjectSidebar() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={() => {}} />
|
<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>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { MessageSquare, Send, ChevronDown, ChevronRight } from 'lucide-react';
|
|||||||
import type { Chat } from '@/api/types';
|
import type { Chat } from '@/api/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { formatTokens } from '@/lib/format';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -27,6 +28,51 @@ function relTime(iso: string): string {
|
|||||||
return `${day}d ago`;
|
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({
|
export function SessionLandingPage({
|
||||||
chats,
|
chats,
|
||||||
onOpenChat,
|
onOpenChat,
|
||||||
@@ -50,6 +96,10 @@ export function SessionLandingPage({
|
|||||||
setComposerValue('');
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
<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">
|
<ul className="divide-y rounded-md border">
|
||||||
{openChats.map((chat) => (
|
{openChats.map((chat) => (
|
||||||
<li key={chat.id}>
|
<li key={chat.id}>
|
||||||
<button
|
<ChatRow chat={chat} onClick={() => onOpenChat(chat.id)} />
|
||||||
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>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -94,19 +132,12 @@ export function SessionLandingPage({
|
|||||||
<ul className="divide-y rounded-md border">
|
<ul className="divide-y rounded-md border">
|
||||||
{closedChats.map((chat) => (
|
{closedChats.map((chat) => (
|
||||||
<li key={chat.id}>
|
<li key={chat.id}>
|
||||||
<button
|
<ChatRow
|
||||||
type="button"
|
chat={chat}
|
||||||
onClick={() => void onReopenChat(chat.id)}
|
onClick={() => void onReopenChat(chat.id)}
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-muted/50 text-left"
|
dimmed
|
||||||
>
|
trailing="Reopen"
|
||||||
<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>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import type { DragEvent } from 'react';
|
||||||
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
|
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
@@ -61,6 +62,8 @@ export function Workspace({ sessionId, projectId }: Props) {
|
|||||||
const [chats, setChats] = useState<Chat[]>([]);
|
const [chats, setChats] = useState<Chat[]>([]);
|
||||||
const chatsRef = useRef<Chat[]>([]);
|
const chatsRef = useRef<Chat[]>([]);
|
||||||
chatsRef.current = chats;
|
chatsRef.current = chats;
|
||||||
|
const draggingIdxRef = useRef<number | null>(null);
|
||||||
|
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -87,7 +90,10 @@ export function Workspace({ sessionId, projectId }: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return sessionEvents.subscribe((event) => {
|
return sessionEvents.subscribe((event) => {
|
||||||
if (event.type === 'chat_created' && event.session_id === sessionId) {
|
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') {
|
if (event.type === 'chat_updated') {
|
||||||
setChats((prev) => prev.map((c) =>
|
setChats((prev) => prev.map((c) =>
|
||||||
@@ -177,8 +183,11 @@ export function Workspace({ sessionId, projectId }: Props) {
|
|||||||
const createChat = useCallback(async (paneIdx: number) => {
|
const createChat = useCallback(async (paneIdx: number) => {
|
||||||
try {
|
try {
|
||||||
const chat = await api.chats.create(sessionId);
|
const chat = await api.chats.create(sessionId);
|
||||||
setChats((prev) => [chat, ...prev]);
|
// Optimistic local insert; the WS chat_created echo will be deduped by id.
|
||||||
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);
|
openChatInPane(paneIdx, chat.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Failed to create chat');
|
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) => {
|
const handleLandingSend = useCallback(async (paneIdx: number, content: string) => {
|
||||||
try {
|
try {
|
||||||
const chat = await api.chats.create(sessionId);
|
const chat = await api.chats.create(sessionId);
|
||||||
setChats((prev) => [chat, ...prev]);
|
setChats((prev) => {
|
||||||
sessionEvents.emit({ type: 'chat_created', chat, session_id: sessionId });
|
if (prev.some((c) => c.id === chat.id)) return prev;
|
||||||
|
return [chat, ...prev];
|
||||||
|
});
|
||||||
openChatInPane(paneIdx, chat.id);
|
openChatInPane(paneIdx, chat.id);
|
||||||
await api.messages.send(chat.id, content);
|
await api.messages.send(chat.id, content);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -315,10 +374,20 @@ export function Workspace({ sessionId, projectId }: Props) {
|
|||||||
<div
|
<div
|
||||||
key={pane.id}
|
key={pane.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col h-full min-h-0 border-r border-border last:border-r-0',
|
'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'
|
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)}
|
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
|
<ChatTabBar
|
||||||
pane={pane}
|
pane={pane}
|
||||||
@@ -332,6 +401,7 @@ export function Workspace({ sessionId, projectId }: Props) {
|
|||||||
onDelete={deleteChat}
|
onDelete={deleteChat}
|
||||||
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
{pane.kind === 'chat' && pane.chatId ? (
|
{pane.kind === 'chat' && pane.chatId ? (
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ function load(): Promise<void> {
|
|||||||
function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').SessionEvent): SidebarResponse {
|
function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').SessionEvent): SidebarResponse {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'project_created': {
|
case 'project_created': {
|
||||||
|
if (prev.projects.some((p) => p.id === event.project.id)) return prev;
|
||||||
const fresh: SidebarProject = {
|
const fresh: SidebarProject = {
|
||||||
id: event.project.id,
|
id: event.project.id,
|
||||||
name: event.project.name,
|
name: event.project.name,
|
||||||
@@ -69,6 +70,7 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
|||||||
let changed = false;
|
let changed = false;
|
||||||
const projects = prev.projects.map((p) => {
|
const projects = prev.projects.map((p) => {
|
||||||
if (p.id !== event.project_id) return p;
|
if (p.id !== event.project_id) return p;
|
||||||
|
if (p.recent_sessions.some((s) => s.id === event.session.id)) return p;
|
||||||
changed = true;
|
changed = true;
|
||||||
const fresh: SidebarSession = {
|
const fresh: SidebarSession = {
|
||||||
id: event.session.id,
|
id: event.session.id,
|
||||||
@@ -89,8 +91,10 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
|||||||
let changed = false;
|
let changed = false;
|
||||||
const projects = prev.projects.map((p) => {
|
const projects = prev.projects.map((p) => {
|
||||||
if (p.id !== event.project_id) return p;
|
if (p.id !== event.project_id) return p;
|
||||||
changed = true;
|
|
||||||
const recent = p.recent_sessions.filter((s) => s.id !== event.session_id);
|
const recent = p.recent_sessions.filter((s) => s.id !== event.session_id);
|
||||||
|
const wasPresent = recent.length !== p.recent_sessions.length;
|
||||||
|
if (!wasPresent) return p;
|
||||||
|
changed = true;
|
||||||
return {
|
return {
|
||||||
...p,
|
...p,
|
||||||
recent_sessions: recent,
|
recent_sessions: recent,
|
||||||
|
|||||||
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) {
|
if (event.type === 'session_archived' && event.project_id === id) {
|
||||||
setArchivedSessions((prev) => {
|
setArchivedSessions((prev) => {
|
||||||
if (!prev) return 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);
|
const session = sessions?.find((s) => s.id === event.session_id);
|
||||||
if (!session) return prev;
|
if (!session) return prev;
|
||||||
return [{ ...session, status: 'archived' as const }, ...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]);
|
}, [id, sessions]);
|
||||||
|
|
||||||
@@ -50,7 +56,7 @@ export function Project() {
|
|||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
const s = await create({});
|
const s = await create({});
|
||||||
sessionEvents.emit({ type: 'session_created', session: s, project_id: id });
|
// Server publishes session_created via WS; let useUserEvents deliver it.
|
||||||
navigate(`/session/${s.id}`);
|
navigate(`/session/${s.id}`);
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
@@ -112,11 +118,7 @@ export function Project() {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
await remove(s.id);
|
await remove(s.id);
|
||||||
sessionEvents.emit({
|
// Server publishes session_deleted via WS.
|
||||||
type: 'session_deleted',
|
|
||||||
session_id: s.id,
|
|
||||||
project_id: id!,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(
|
toast.error(
|
||||||
err instanceof Error ? err.message : 'failed to delete session'
|
err instanceof Error ? err.message : 'failed to delete session'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useParams } from 'react-router-dom';
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { ChevronLeft } from 'lucide-react';
|
import { ChevronLeft } from 'lucide-react';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { Session as SessionType } from '@/api/types';
|
import type { Session as SessionType } from '@/api/types';
|
||||||
@@ -9,6 +9,7 @@ import { ModelPicker } from '@/components/ModelPicker';
|
|||||||
|
|
||||||
export function Session() {
|
export function Session() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [session, setSession] = useState<SessionType | null>(null);
|
const [session, setSession] = useState<SessionType | null>(null);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [editingName, setEditingName] = useState(false);
|
const [editingName, setEditingName] = useState(false);
|
||||||
@@ -43,12 +44,19 @@ export function Session() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
return sessionEvents.subscribe((event) => {
|
return sessionEvents.subscribe((event) => {
|
||||||
if (event.type !== 'session_renamed') return;
|
if (event.type === 'session_renamed' && event.session_id === id) {
|
||||||
if (event.session_id !== id) return;
|
|
||||||
setSession((prev) => (prev ? { ...prev, name: event.name } : prev));
|
setSession((prev) => (prev ? { ...prev, name: event.name } : prev));
|
||||||
setName((prev) => (editingName ? prev : event.name));
|
setName((prev) => (editingName ? prev : event.name));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(event.type === 'session_deleted' || event.type === 'session_archived') &&
|
||||||
|
event.session_id === id
|
||||||
|
) {
|
||||||
|
navigate(`/project/${event.project_id}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, [id, editingName]);
|
}, [id, editingName, navigate]);
|
||||||
|
|
||||||
async function saveName() {
|
async function saveName() {
|
||||||
if (!id || !session) return;
|
if (!id || !session) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user