From 59fe6f052229294ca25d3dabc770e63315c66f63 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sat, 16 May 2026 04:12:01 +0000 Subject: [PATCH] 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) --- apps/server/src/index.ts | 4 + apps/server/src/routes/chats.ts | 77 ++++++++++++ apps/server/src/routes/messages.ts | 48 +++++++ apps/server/src/routes/sessions.ts | 10 +- apps/server/src/schema.sql | 19 ++- apps/server/src/services/inference.ts | 4 + apps/server/src/types/api.ts | 6 + apps/web/src/api/client.ts | 9 ++ apps/web/src/components/MessageBubble.tsx | 133 +++++++++++++++++--- apps/web/src/components/PaneTab.tsx | 116 ----------------- apps/web/src/components/Workspace.tsx | 27 ++++ apps/web/src/components/panes/PaneShell.tsx | 31 ----- apps/web/src/hooks/sessionEvents.ts | 6 + apps/web/src/hooks/useActivePane.ts | 61 +++++++++ apps/web/src/hooks/useSidebar.ts | 3 + apps/web/src/pages/Session.tsx | 78 ++++++++---- 16 files changed, 426 insertions(+), 206 deletions(-) delete mode 100644 apps/web/src/components/PaneTab.tsx delete mode 100644 apps/web/src/components/panes/PaneShell.tsx create mode 100644 apps/web/src/hooks/useActivePane.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 13bee70..082a2f7 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -83,6 +83,7 @@ async function main() { cancelInference: async (sessionId, chatId) => { return inference.cancel(sessionId, chatId); }, + hasActiveInference: (chatId) => inference.hasActive(chatId), publishUserMessage: (sessionId, chatId, userMessageId, content) => { broker.publish(sessionId, { type: 'message_started', @@ -144,6 +145,9 @@ async function main() { process.on('SIGINT', () => void shutdown('SIGINT')); 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 }); app.log.info(`boocode server listening on http://${config.HOST}:${config.PORT}`); } diff --git a/apps/server/src/routes/chats.ts b/apps/server/src/routes/chats.ts index 0ba7c86..3cbfffa 100644 --- a/apps/server/src/routes/chats.ts +++ b/apps/server/src/routes/chats.ts @@ -12,6 +12,11 @@ const PatchBody = z.object({ 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( app: FastifyInstance, 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` + 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` + 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 } }>( '/api/chats/:id/messages', async (req, reply) => { diff --git a/apps/server/src/routes/messages.ts b/apps/server/src/routes/messages.ts index edfc716..d00cd7f 100644 --- a/apps/server/src/routes/messages.ts +++ b/apps/server/src/routes/messages.ts @@ -18,6 +18,7 @@ interface MessageHandlers { ) => void; publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void; cancelInference: (sessionId: string, chatId: string) => Promise; + hasActiveInference: (chatId: string) => boolean; } 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` + 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 } }>( '/api/chats/:id/compact', async (req, reply) => { diff --git a/apps/server/src/routes/sessions.ts b/apps/server/src/routes/sessions.ts index 464cb4a..707f8ad 100644 --- a/apps/server/src/routes/sessions.ts +++ b/apps/server/src/routes/sessions.ts @@ -134,7 +134,15 @@ export function registerSessionRoutes( reply.code(404); 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; } ); diff --git a/apps/server/src/schema.sql b/apps/server/src/schema.sql index a11d754..c399bdf 100644 --- a/apps/server/src/schema.sql +++ b/apps/server/src/schema.sql @@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS projects ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, path TEXT NOT NULL UNIQUE, - added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + added_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), last_session_id UUID ); @@ -12,8 +12,8 @@ CREATE TABLE IF NOT EXISTS sessions ( name TEXT NOT NULL, model TEXT NOT NULL, system_prompt TEXT NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() ); 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, status TEXT NOT NULL DEFAULT 'complete', 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); @@ -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); --- Backfill: ensure every session has at least one pane (default Chat). --- Idempotent: skipped on subsequent runs because session_panes rows already exist. -INSERT INTO session_panes (session_id, position, kind, state) -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.4: backfill removed. Pane layout is client-side (localStorage) since v1.2-batch4. +-- The CREATE TABLE above is retained for additive-schema discipline; drop is a +-- future destructive migration. -- v1.2: sessions.status (open | archived) ALTER TABLE sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open'; diff --git a/apps/server/src/services/inference.ts b/apps/server/src/services/inference.ts index ec97350..d706fe3 100644 --- a/apps/server/src/services/inference.ts +++ b/apps/server/src/services/inference.ts @@ -764,6 +764,10 @@ export function createInferenceRunner( await reg.completed.catch(() => {}); return true; }, + + hasActive(chatId: string): boolean { + return registry.has(chatId); + }, }; } diff --git a/apps/server/src/types/api.ts b/apps/server/src/types/api.ts index e3b8ac8..d7ac418 100644 --- a/apps/server/src/types/api.ts +++ b/apps/server/src/types/api.ts @@ -176,6 +176,11 @@ export interface SessionUpdatedFrame { name: string; updated_at: string; } +export interface SessionRenamedFrame { + type: 'session_renamed'; + session_id: string; + name: string; +} export interface SessionArchivedFrame { type: 'session_archived'; session_id: string; @@ -226,6 +231,7 @@ export type UserStreamFrame = | SessionCreatedFrame | SessionDeletedFrame | SessionUpdatedFrame + | SessionRenamedFrame | SessionArchivedFrame | ChatCreatedFrame | ChatUpdatedFrame diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index d5a9b2a..11704ba 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -148,6 +148,11 @@ export const api = { `/api/chats/${chatId}/force_send`, { method: 'POST', body: JSON.stringify({ content }) } ), + fork: (chatId: string, body: { messageId: string; name?: string }) => + request(`/api/chats/${chatId}/fork`, { + method: 'POST', + body: JSON.stringify({ message_id: body.messageId, name: body.name }), + }), }, messages: { @@ -166,6 +171,10 @@ export const api = { `/api/chats/${chatId}/messages/${messageId}/regenerate`, { method: 'POST' } ), + remove: (chatId: string, messageId: string) => + request(`/api/chats/${chatId}/messages/${messageId}`, { + method: 'DELETE', + }), }, models: () => request('/api/models'), diff --git a/apps/web/src/components/MessageBubble.tsx b/apps/web/src/components/MessageBubble.tsx index 92e13af..2ac9fb4 100644 --- a/apps/web/src/components/MessageBubble.tsx +++ b/apps/web/src/components/MessageBubble.tsx @@ -2,13 +2,22 @@ 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, RotateCw } from 'lucide-react'; +import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import type { Chat, Message } from '@/api/types'; import { api } from '@/api/client'; import { sessionEvents } from '@/hooks/sessionEvents'; import { ToolCallCard } from './ToolCallCard'; 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 `/` // 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 [regenerating, setRegenerating] = useState(false); + const [forking, setForking] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [deleting, setDeleting] = useState(false); async function copy() { 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 canRegen = isAssistant && message.status !== 'streaming'; + const canFork = message.status === 'complete'; + const canDelete = message.status !== 'streaming'; return ( -
- - {isAssistant && ( + <> +
- )} -
+ {isAssistant && ( + + )} + + +
+ { + if (!deleting) setDeleteOpen(open); + }} + > + + + Delete this message and all messages after it? + + This removes the selected message and every later message in this chat. This cannot be undone. + + + + + + + + + ); } diff --git a/apps/web/src/components/PaneTab.tsx b/apps/web/src/components/PaneTab.tsx deleted file mode 100644 index c05033c..0000000 --- a/apps/web/src/components/PaneTab.tsx +++ /dev/null @@ -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) => void; - onDragOver: (e: DragEvent) => void; - onDrop: (e: DragEvent) => 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 ( - - -
- - - {label} - - -
-
- - - Split - - onSplit('chat')}> - Chat - - onSplit('file_browser')}> - File Browser - - - - - Close - Close others - - Close to the right - - Close all - -
- ); -} diff --git a/apps/web/src/components/Workspace.tsx b/apps/web/src/components/Workspace.tsx index e8f82fd..87139c4 100644 --- a/apps/web/src/components/Workspace.tsx +++ b/apps/web/src/components/Workspace.tsx @@ -4,6 +4,7 @@ import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api/client'; import { sessionEvents } from '@/hooks/sessionEvents'; +import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane'; import type { Chat, WorkspacePane } from '@/api/types'; import { ChatPane } from '@/components/panes/ChatPane'; import { ChatTabBar } from '@/components/ChatTabBar'; @@ -87,6 +88,29 @@ export function Workspace({ sessionId, projectId }: Props) { savePanes(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(() => { return sessionEvents.subscribe((event) => { 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)); removeChatFromPanes(event.chat_id); } + if (event.type === 'open_chat_in_active_pane') { + openChatInPane(activePaneIdxRef.current, event.chat_id); + } }); }, [sessionId]); diff --git a/apps/web/src/components/panes/PaneShell.tsx b/apps/web/src/components/panes/PaneShell.tsx deleted file mode 100644 index 2e5d2f3..0000000 --- a/apps/web/src/components/panes/PaneShell.tsx +++ /dev/null @@ -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 ( -
-
- {label} - -
-
{children}
-
- ); -} diff --git a/apps/web/src/hooks/sessionEvents.ts b/apps/web/src/hooks/sessionEvents.ts index d499b81..ff4ea85 100644 --- a/apps/web/src/hooks/sessionEvents.ts +++ b/apps/web/src/hooks/sessionEvents.ts @@ -57,6 +57,11 @@ export interface AttachChatFileEvent { attachment: Omit; } +export interface OpenChatInActivePaneEvent { + type: 'open_chat_in_active_pane'; + chat_id: string; +} + export interface SessionArchivedEvent { type: 'session_archived'; session_id: string; @@ -120,6 +125,7 @@ export type SessionEvent = | SessionLoadedEvent | OpenFileInBrowserEvent | AttachChatFileEvent + | OpenChatInActivePaneEvent | SessionArchivedEvent | ChatCreatedEvent | ChatUpdatedEvent diff --git a/apps/web/src/hooks/useActivePane.ts b/apps/web/src/hooks/useActivePane.ts new file mode 100644 index 0000000..afca4e8 --- /dev/null +++ b/apps/web/src/hooks/useActivePane.ts @@ -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(current); + useEffect(() => { + const sub = () => setSnap(current); + subs.add(sub); + sub(); + return () => { + subs.delete(sub); + }; + }, []); + return snap; +} diff --git a/apps/web/src/hooks/useSidebar.ts b/apps/web/src/hooks/useSidebar.ts index 5ba2339..530b5f1 100644 --- a/apps/web/src/hooks/useSidebar.ts +++ b/apps/web/src/hooks/useSidebar.ts @@ -148,6 +148,9 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess return prev; case 'attach_chat_file': return prev; + case 'open_chat_in_active_pane': + // Consumed by Workspace; sidebar has no business with pane state. + return prev; case 'session_archived': { let changed = false; const projects = prev.projects.map((p) => { diff --git a/apps/web/src/pages/Session.tsx b/apps/web/src/pages/Session.tsx index fbd9648..ddf8390 100644 --- a/apps/web/src/pages/Session.tsx +++ b/apps/web/src/pages/Session.tsx @@ -1,9 +1,10 @@ import { useEffect, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; -import { ChevronLeft } from 'lucide-react'; +import { ChevronRight } from 'lucide-react'; 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 { useActivePane } from '@/hooks/useActivePane'; import { Workspace } from '@/components/Workspace'; import { ModelPicker } from '@/components/ModelPicker'; @@ -11,12 +12,15 @@ export function Session() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [session, setSession] = useState(null); + const [project, setProject] = useState(null); const [name, setName] = useState(''); const [editingName, setEditingName] = useState(false); + const active = useActivePane(); useEffect(() => { if (!id) return; setSession(null); + setProject(null); let cancelled = false; api.sessions .get(id) @@ -24,16 +28,17 @@ export function Session() { if (cancelled) return; setSession(s); 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({ type: 'session_loaded', session_id: 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(() => {}); return () => { @@ -68,26 +73,33 @@ export function Session() { } const updated = await api.sessions.update(id, { name: trimmed }); setSession(updated); - sessionEvents.emit({ - type: 'session_renamed', - session_id: id, - name: trimmed, - }); 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 (
-
- {session && ( +
+ + Projects + + + {project ? ( - + {project.name} + ) : ( + )} + {editingName ? ( setEditingName(true)} + title={session?.name ?? ''} > {session?.name ?? '…'} )} + {showActiveFile && active.activeFile && ( + <> + · + + {active.activeFile} + + + )}
{session && ( - { - const updated = await api.sessions.update(session.id, { model }); - setSession(updated); - }} - /> +
+ { + const updated = await api.sessions.update(session.id, { model }); + setSession(updated); + }} + /> +
)}