Compare commits

...

2 Commits

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

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

View File

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

View File

@@ -83,6 +83,7 @@ async function main() {
cancelInference: async (sessionId, chatId) => { cancelInference: async (sessionId, chatId) => {
return inference.cancel(sessionId, chatId); return inference.cancel(sessionId, chatId);
}, },
hasActiveInference: (chatId) => inference.hasActive(chatId),
publishUserMessage: (sessionId, chatId, userMessageId, content) => { publishUserMessage: (sessionId, chatId, userMessageId, content) => {
broker.publish(sessionId, { broker.publish(sessionId, {
type: 'message_started', type: 'message_started',
@@ -144,6 +145,9 @@ async function main() {
process.on('SIGINT', () => void shutdown('SIGINT')); process.on('SIGINT', () => void shutdown('SIGINT'));
process.on('SIGTERM', () => void shutdown('SIGTERM')); process.on('SIGTERM', () => void shutdown('SIGTERM'));
// Bound to 0.0.0.0 intentionally. Public access goes through Caddy → Authelia.
// Direct Tailscale access (100.114.205.53:9500) is unauthenticated by design;
// the threat model treats Tailnet membership as the trust boundary.
await app.listen({ port: config.PORT, host: config.HOST }); await app.listen({ port: config.PORT, host: config.HOST });
app.log.info(`boocode server listening on http://${config.HOST}:${config.PORT}`); app.log.info(`boocode server listening on http://${config.HOST}:${config.PORT}`);
} }

View File

@@ -12,6 +12,11 @@ const PatchBody = z.object({
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
}); });
const ForkBody = z.object({
message_id: z.string().uuid(),
name: z.string().min(1).max(200).optional(),
});
export function registerChatRoutes( export function registerChatRoutes(
app: FastifyInstance, app: FastifyInstance,
sql: Sql, sql: Sql,
@@ -181,6 +186,78 @@ export function registerChatRoutes(
} }
); );
app.post<{ Params: { id: string } }>(
'/api/chats/:id/fork',
async (req, reply) => {
const parsed = ForkBody.safeParse(req.body ?? {});
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const sourceRows = await sql<Chat[]>`
SELECT id, session_id, name, status, created_at, updated_at
FROM chats WHERE id = ${req.params.id}
`;
if (sourceRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const source = sourceRows[0]!;
const targetRows = await sql<{ created_at: string; status: string }[]>`
SELECT created_at, status FROM messages
WHERE chat_id = ${source.id} AND id = ${parsed.data.message_id}
`;
if (targetRows.length === 0) {
reply.code(404);
return { error: 'message not found in chat' };
}
const target = targetRows[0]!;
if (target.status !== 'complete') {
reply.code(400);
return { error: 'can only fork from completed messages' };
}
const newName = parsed.data.name ?? `${source.name ?? 'Chat'} (fork)`;
const newChat = await sql.begin(async (tx) => {
const [chat] = await tx<Chat[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${source.session_id}, ${newName}, 'open')
RETURNING id, session_id, name, status, created_at, updated_at
`;
await tx`
INSERT INTO messages (
session_id, chat_id, role, content, kind, tool_calls, tool_results,
status, tokens_used, ctx_used, ctx_max, started_at, finished_at,
created_at
)
SELECT
${source.session_id}, ${chat!.id}, role, content, kind,
tool_calls, tool_results, status,
tokens_used, ctx_used, ctx_max, started_at, finished_at,
clock_timestamp() + (
ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) * INTERVAL '1 microsecond'
)
FROM messages
WHERE chat_id = ${source.id}
AND created_at <= ${target.created_at}::timestamptz
AND status = 'complete'
`;
return chat!;
});
broker.publishUser('default', {
type: 'chat_created',
chat: newChat,
session_id: source.session_id,
});
reply.code(201);
return newChat;
}
);
app.get<{ Params: { id: string } }>( app.get<{ Params: { id: string } }>(
'/api/chats/:id/messages', '/api/chats/:id/messages',
async (req, reply) => { async (req, reply) => {

View File

@@ -18,6 +18,7 @@ interface MessageHandlers {
) => void; ) => void;
publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void; publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void;
cancelInference: (sessionId: string, chatId: string) => Promise<boolean>; cancelInference: (sessionId: string, chatId: string) => Promise<boolean>;
hasActiveInference: (chatId: string) => boolean;
} }
export function registerMessageRoutes( export function registerMessageRoutes(
@@ -156,6 +157,53 @@ export function registerMessageRoutes(
} }
); );
app.delete<{ Params: { id: string; message_id: string } }>(
'/api/chats/:id/messages/:message_id',
async (req, reply) => {
const { id: chatId, message_id: messageId } = req.params;
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${chatId}
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const chat = chatRows[0]!;
if (handlers.hasActiveInference(chatId)) {
reply.code(409);
return { error: 'chat is currently streaming; stop it first' };
}
const deletedIds = await sql.begin(async (tx) => {
const deletedRows = await tx<{ id: string }[]>`
DELETE FROM messages
WHERE chat_id = ${chatId}
AND created_at >= (
SELECT created_at FROM messages
WHERE id = ${messageId} AND chat_id = ${chatId}
)
RETURNING id
`;
if (deletedRows.length > 0) {
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
}
return deletedRows.map((r) => r.id);
});
if (deletedIds.length === 0) {
reply.code(404);
return { error: 'message not found' };
}
handlers.publishMessagesDeleted(chat.session_id, chatId, deletedIds);
reply.code(204);
return null;
}
);
app.post<{ Params: { id: string } }>( app.post<{ Params: { id: string } }>(
'/api/chats/:id/compact', '/api/chats/:id/compact',
async (req, reply) => { async (req, reply) => {

View File

@@ -134,7 +134,15 @@ export function registerSessionRoutes(
reply.code(404); reply.code(404);
return { error: 'session not found' }; return { error: 'session not found' };
} }
return rows[0]; const session = rows[0]!;
if (name !== undefined) {
broker.publishUser('default', {
type: 'session_renamed',
session_id: session.id,
name: session.name,
});
}
return session;
} }
); );

View File

@@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL, name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE, path TEXT NOT NULL UNIQUE,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), added_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
last_session_id UUID last_session_id UUID
); );
@@ -12,8 +12,8 @@ CREATE TABLE IF NOT EXISTS sessions (
name TEXT NOT NULL, name TEXT NOT NULL,
model TEXT NOT NULL, model TEXT NOT NULL,
system_prompt TEXT NOT NULL DEFAULT '', system_prompt TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
); );
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id, updated_at DESC);
@@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS messages (
tool_results JSONB, tool_results JSONB,
status TEXT NOT NULL DEFAULT 'complete', status TEXT NOT NULL DEFAULT 'complete',
last_seq INT NOT NULL DEFAULT 0, last_seq INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
); );
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
@@ -60,14 +60,9 @@ CREATE TABLE IF NOT EXISTS session_panes (
); );
CREATE INDEX IF NOT EXISTS idx_session_panes_session ON session_panes (session_id); CREATE INDEX IF NOT EXISTS idx_session_panes_session ON session_panes (session_id);
-- Backfill: ensure every session has at least one pane (default Chat). -- v1.4: backfill removed. Pane layout is client-side (localStorage) since v1.2-batch4.
-- Idempotent: skipped on subsequent runs because session_panes rows already exist. -- The CREATE TABLE above is retained for additive-schema discipline; drop is a
INSERT INTO session_panes (session_id, position, kind, state) -- future destructive migration.
SELECT s.id, 0, 'chat', '{}'::jsonb
FROM sessions s
WHERE NOT EXISTS (
SELECT 1 FROM session_panes p WHERE p.session_id = s.id
);
-- v1.2: sessions.status (open | archived) -- v1.2: sessions.status (open | archived)
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open'; ALTER TABLE sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';

View File

@@ -764,6 +764,10 @@ export function createInferenceRunner(
await reg.completed.catch(() => {}); await reg.completed.catch(() => {});
return true; return true;
}, },
hasActive(chatId: string): boolean {
return registry.has(chatId);
},
}; };
} }

View File

@@ -176,6 +176,11 @@ export interface SessionUpdatedFrame {
name: string; name: string;
updated_at: string; updated_at: string;
} }
export interface SessionRenamedFrame {
type: 'session_renamed';
session_id: string;
name: string;
}
export interface SessionArchivedFrame { export interface SessionArchivedFrame {
type: 'session_archived'; type: 'session_archived';
session_id: string; session_id: string;
@@ -226,6 +231,7 @@ export type UserStreamFrame =
| SessionCreatedFrame | SessionCreatedFrame
| SessionDeletedFrame | SessionDeletedFrame
| SessionUpdatedFrame | SessionUpdatedFrame
| SessionRenamedFrame
| SessionArchivedFrame | SessionArchivedFrame
| ChatCreatedFrame | ChatCreatedFrame
| ChatUpdatedFrame | ChatUpdatedFrame

View File

@@ -148,6 +148,11 @@ export const api = {
`/api/chats/${chatId}/force_send`, `/api/chats/${chatId}/force_send`,
{ method: 'POST', body: JSON.stringify({ content }) } { method: 'POST', body: JSON.stringify({ content }) }
), ),
fork: (chatId: string, body: { messageId: string; name?: string }) =>
request<Chat>(`/api/chats/${chatId}/fork`, {
method: 'POST',
body: JSON.stringify({ message_id: body.messageId, name: body.name }),
}),
}, },
messages: { messages: {
@@ -166,6 +171,10 @@ export const api = {
`/api/chats/${chatId}/messages/${messageId}/regenerate`, `/api/chats/${chatId}/messages/${messageId}/regenerate`,
{ method: 'POST' } { method: 'POST' }
), ),
remove: (chatId: string, messageId: string) =>
request<void>(`/api/chats/${chatId}/messages/${messageId}`, {
method: 'DELETE',
}),
}, },
models: () => request<ModelInfo[]>('/api/models'), models: () => request<ModelInfo[]>('/api/models'),

View File

@@ -2,13 +2,22 @@ import { Children, cloneElement, isValidElement, useState } from 'react';
import type { ReactElement, ReactNode } from 'react'; import type { ReactElement, ReactNode } from 'react';
import Markdown from 'react-markdown'; import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw } from 'lucide-react'; import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Chat, Message } from '@/api/types'; import type { Chat, Message } from '@/api/types';
import { api } from '@/api/client'; import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
import { ToolCallCard } from './ToolCallCard'; import { ToolCallCard } from './ToolCallCard';
import { CodeBlock } from './CodeBlock'; import { CodeBlock } from './CodeBlock';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
// Match path-shaped substrings ending in `.ext`. Additionally require a `/` // Match path-shaped substrings ending in `.ext`. Additionally require a `/`
// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't // in the match to reduce false positives in prose (e.g. plain `foo.ts` won't
@@ -198,6 +207,9 @@ function ActionRow({
}) { }) {
const [justCopied, setJustCopied] = useState(false); const [justCopied, setJustCopied] = useState(false);
const [regenerating, setRegenerating] = useState(false); const [regenerating, setRegenerating] = useState(false);
const [forking, setForking] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
async function copy() { async function copy() {
try { try {
@@ -221,33 +233,114 @@ function ActionRow({
} }
} }
async function fork() {
if (forking || message.status !== 'complete') return;
setForking(true);
try {
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
sessionEvents.emit({ type: 'open_chat_in_active_pane', chat_id: chat.id });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'fork failed');
} finally {
setForking(false);
}
}
async function confirmDelete() {
if (deleting) return;
setDeleting(true);
try {
await api.messages.remove(message.chat_id, message.id);
setDeleteOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'delete failed');
} finally {
setDeleting(false);
}
}
const isAssistant = message.role === 'assistant'; const isAssistant = message.role === 'assistant';
const canRegen = isAssistant && message.status !== 'streaming'; const canRegen = isAssistant && message.status !== 'streaming';
const canFork = message.status === 'complete';
const canDelete = message.status !== 'streaming';
return ( return (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <>
<button <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
type="button"
onClick={() => void copy()}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Copy message"
title="Copy"
>
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
</button>
{isAssistant && (
<button <button
type="button" type="button"
onClick={() => void regenerate()} onClick={() => void copy()}
disabled={!canRegen || regenerating} className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed" aria-label="Copy message"
aria-label="Regenerate message" title="Copy"
title="Regenerate"
> >
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} /> {justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
</button> </button>
)} {isAssistant && (
</div> <button
type="button"
onClick={() => void regenerate()}
disabled={!canRegen || regenerating}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Regenerate message"
title="Regenerate"
>
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
</button>
)}
<button
type="button"
onClick={() => void fork()}
disabled={!canFork || forking}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Fork from here"
title="Fork from here"
>
<GitFork className="size-3" />
</button>
<button
type="button"
onClick={() => setDeleteOpen(true)}
disabled={!canDelete}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Delete message"
title="Delete message"
>
<Trash2 className="size-3" />
</button>
</div>
<Dialog
open={deleteOpen}
onOpenChange={(open) => {
if (!deleting) setDeleteOpen(open);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this message and all messages after it?</DialogTitle>
<DialogDescription>
This removes the selected message and every later message in this chat. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteOpen(false)}
disabled={deleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => void confirmDelete()}
disabled={deleting}
>
{deleting ? 'Deleting…' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
); );
} }

View File

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

View File

@@ -4,6 +4,7 @@ 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';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
import type { Chat, WorkspacePane } from '@/api/types'; import type { Chat, WorkspacePane } from '@/api/types';
import { ChatPane } from '@/components/panes/ChatPane'; import { ChatPane } from '@/components/panes/ChatPane';
import { ChatTabBar } from '@/components/ChatTabBar'; import { ChatTabBar } from '@/components/ChatTabBar';
@@ -87,6 +88,29 @@ export function Workspace({ sessionId, projectId }: Props) {
savePanes(sessionId, panes); savePanes(sessionId, panes);
}, [sessionId, panes]); }, [sessionId, panes]);
useEffect(() => {
const active = panes[activePaneIdx];
if (!active) {
clearActivePane();
return;
}
setActivePaneInfo({
sessionId,
paneId: active.id,
kind: active.kind,
activeFile: null,
});
}, [sessionId, panes, activePaneIdx]);
useEffect(() => {
return () => {
clearActivePane();
};
}, []);
const activePaneIdxRef = useRef(activePaneIdx);
activePaneIdxRef.current = activePaneIdx;
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) {
@@ -118,6 +142,9 @@ export function Workspace({ sessionId, projectId }: Props) {
setChats((prev) => prev.filter((c) => c.id !== event.chat_id)); setChats((prev) => prev.filter((c) => c.id !== event.chat_id));
removeChatFromPanes(event.chat_id); removeChatFromPanes(event.chat_id);
} }
if (event.type === 'open_chat_in_active_pane') {
openChatInPane(activePaneIdxRef.current, event.chat_id);
}
}); });
}, [sessionId]); }, [sessionId]);

View File

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

View File

@@ -57,6 +57,11 @@ export interface AttachChatFileEvent {
attachment: Omit<Attachment, 'id'>; attachment: Omit<Attachment, 'id'>;
} }
export interface OpenChatInActivePaneEvent {
type: 'open_chat_in_active_pane';
chat_id: string;
}
export interface SessionArchivedEvent { export interface SessionArchivedEvent {
type: 'session_archived'; type: 'session_archived';
session_id: string; session_id: string;
@@ -120,6 +125,7 @@ export type SessionEvent =
| SessionLoadedEvent | SessionLoadedEvent
| OpenFileInBrowserEvent | OpenFileInBrowserEvent
| AttachChatFileEvent | AttachChatFileEvent
| OpenChatInActivePaneEvent
| SessionArchivedEvent | SessionArchivedEvent
| ChatCreatedEvent | ChatCreatedEvent
| ChatUpdatedEvent | ChatUpdatedEvent

View File

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

View File

@@ -148,6 +148,9 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
return prev; return prev;
case 'attach_chat_file': case 'attach_chat_file':
return prev; return prev;
case 'open_chat_in_active_pane':
// Consumed by Workspace; sidebar has no business with pane state.
return prev;
case 'session_archived': { case 'session_archived': {
let changed = false; let changed = false;
const projects = prev.projects.map((p) => { const projects = prev.projects.map((p) => {

View File

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