import { useCallback, useEffect, useRef, useState } from 'react'; import { Link, useLocation, useNavigate, useParams, useSearchParams, } from 'react-router-dom'; import { ChevronRight, FolderTree, Menu, X } from 'lucide-react'; import { api } from '@/api/client'; import type { Project, Session as SessionType } from '@/api/types'; import { sessionEvents } from '@/hooks/sessionEvents'; import { terminalsRegistry } from '@/lib/events'; import { useActivePane } from '@/hooks/useActivePane'; import { useSidebarDrawer } from '@/hooks/useSidebarDrawer'; import { useRightRailDrawer } from '@/hooks/useRightRailDrawer'; import { useViewport } from '@/hooks/useViewport'; import { useWorkspacePanes, MAX_PANES } from '@/hooks/useWorkspacePanes'; import { useSessionChats } from '@/hooks/useSessionChats'; import { useProjectGit } from '@/hooks/useProjectGit'; import { Workspace } from '@/components/Workspace'; import { ModelPicker } from '@/components/ModelPicker'; import { MobileTabSwitcher } from '@/components/MobileTabSwitcher'; import { NewPaneMenu } from '@/components/NewPaneMenu'; import { cn } from '@/lib/utils'; export function Session() { const { id } = useParams<{ id: string }>(); if (!id) return null; // v1.8: key on id so route navigation remounts SessionInner — the hoisted // useWorkspacePanes + useSessionChats then reinitialize cleanly from the // new sessionId instead of carrying stale state across sessions. return ; } function SessionInner({ sessionId }: { sessionId: string }) { const navigate = useNavigate(); const location = useLocation(); const [searchParams] = useSearchParams(); const [session, setSession] = useState(null); const [project, setProject] = useState(null); const [name, setName] = useState(''); const [editingName, setEditingName] = useState(false); const active = useActivePane(); const { setOpen: setDrawerOpen } = useSidebarDrawer(); const { toggle: toggleRightRail } = useRightRailDrawer(); const { isMobile } = useViewport(); // v1.8: pane + chat state hoisted into Session so the mobile header pill // (MobileTabSwitcher) shares one source of truth with the pane grid below. const panesHook = useWorkspacePanes(sessionId); const { panes, activePaneIdx, setActivePaneIdx, openChatInPane, activePaneIdxRef, addSplitPane, removePane, removeChatFromPanes, initializeFirstChatIfEmpty, validatePanes, } = panesHook; const [coderConnected, setCoderConnected] = useState>({}); const activePane = panes[activePaneIdx]; const activeIsCoder = activePane?.kind === 'coder'; const openChatInActivePane = useCallback( (chatId: string) => openChatInPane(activePaneIdxRef.current, chatId), [openChatInPane, activePaneIdxRef], ); const chatsHook = useSessionChats(sessionId, { removeChatFromPanes, openChatInPane, openChatInActivePane, initializeFirstChatIfEmpty, validatePanes, }); const { chats, renameChat } = chatsHook; // v2.3: fix hydrate race — if workspace hydrate clobbers the chat-pane // promotion (panes[0] is still 'empty' while an open chat exists), // re-promote immediately. Guarded by a ref to avoid infinite loops. const promotedRef = useRef(false); useEffect(() => { if (panes.length !== 1 || panes[0]?.kind !== 'empty') return; const openChat = chats.find((c) => c.status === 'open'); if (!openChat) return; if (promotedRef.current) return; promotedRef.current = true; initializeFirstChatIfEmpty(openChat.id); }, [panes, chats, initializeFirstChatIfEmpty]); // v1.8 Level 1: branch indicator. Polls every 30s; server caches the same // span so back-to-back loads are cheap. Returns null until the first fetch // resolves or if the project isn't a git repo. const git = useProjectGit(project?.id); useEffect(() => { setSession(null); setProject(null); let cancelled = false; api.sessions .get(sessionId) .then((s) => { if (cancelled) return; setSession(s); setName(s.name); sessionEvents.emit({ type: 'session_loaded', session_id: sessionId, project_id: s.project_id, }); api.projects.list().then((projects) => { if (cancelled) return; const p = projects.find((x) => x.id === s.project_id); if (p) setProject(p); }).catch((err) => console.warn('Session: failed to load project for breadcrumb', err)); }) .catch((err) => console.warn('Session: failed to fetch session', err)); return () => { cancelled = true; }; }, [sessionId]); // v2.3: opening the settings pane on mobile must push ?pane= atomically, or // the URL-sync effect below snaps activePaneIdx back to the chat pane and the // settings pane never shows (same fix as addPaneAndSwitch). toggleSettingsPane // returns the new pane id when it opens (null when it closes → drop ?pane= so // the effect falls back to pane 0). Desktop has no URL pane state — no-op. const toggleSettingsAndSync = useCallback(() => { const openedId = panesHook.toggleSettingsPane(); if (!isMobile) return; const params = new URLSearchParams(location.search); if (openedId) params.set('pane', openedId); else params.delete('pane'); navigate(`${location.pathname}?${params.toString()}`); }, [panesHook, isMobile, navigate, location.pathname, location.search]); useEffect(() => { return sessionEvents.subscribe((event) => { if (event.type === 'session_renamed' && event.session_id === sessionId) { setSession((prev) => (prev ? { ...prev, name: event.name } : prev)); setName((prev) => (editingName ? prev : event.name)); return; } if ( (event.type === 'session_deleted' || event.type === 'session_archived') && event.session_id === sessionId ) { navigate(`/project/${event.project_id}`); return; } // v1.9: any session_updated for this session triggers a full refetch so // SettingsPane (mounted in a workspace pane) picks up system_prompt / // web_search_enabled / model edits made from another tab. if (event.type === 'session_updated' && event.session_id === sessionId) { void api.sessions.get(sessionId).then((s) => { setSession(s); setName((prev) => (editingName ? prev : s.name)); }).catch(() => {}); return; } // v1.9: project_updated → refetch project so the Project section in // SettingsPane reflects the new defaults. if (event.type === 'project_updated' && project && event.project_id === project.id) { void api.projects.get(project.id).then(setProject).catch(() => {}); return; } // Sidebar Settings button broadcasts this when a session is mounted; // toggleSettingsPane opens on first click, closes on second. if (event.type === 'open_settings_pane') { toggleSettingsAndSync(); } }); }, [sessionId, editingName, navigate, project, toggleSettingsAndSync]); // v1.8: URL ?pane= sync (mobile only). Lifted from Workspace.tsx so // MobileTabSwitcher's onSwitchPane can push the same URL state and the // browser Back button continues to walk pane history on mobile. useEffect(() => { if (!isMobile || panes.length === 0) return; const paneId = searchParams.get('pane'); if (!paneId) { if (activePaneIdx !== 0) setActivePaneIdx(0); return; } const idx = panes.findIndex((p) => p.id === paneId); if (idx >= 0 && idx !== activePaneIdx) setActivePaneIdx(idx); }, [isMobile, searchParams, panes, activePaneIdx, setActivePaneIdx]); const switchActivePane = useCallback( (idx: number) => { setActivePaneIdx(idx); if (isMobile) { const pane = panes[idx]; if (!pane) return; const params = new URLSearchParams(location.search); params.set('pane', pane.id); navigate(`${location.pathname}?${params.toString()}`); } }, [setActivePaneIdx, isMobile, panes, navigate, location.pathname, location.search], ); // v1.10.3 fix: addSplitPane sets activePaneIdx, but on mobile the URL-sync // effect below sees a stale ?pane= and immediately resets the index. Push // the new pane's id to the URL atomically so the effect's next pass sees a // matching id and is a no-op. Desktop has no URL pane state — fall through. const addPaneAndSwitch = useCallback( (kind: 'chat' | 'terminal' | 'coder') => { const newPaneId = addSplitPane(kind); if (newPaneId === null) return; if (isMobile) { const params = new URLSearchParams(location.search); params.set('pane', newPaneId); navigate(`${location.pathname}?${params.toString()}`); } }, [addSplitPane, isMobile, navigate, location.pathname, location.search], ); const activePaneKind = panes[activePaneIdx]?.kind; const showSessionModelPicker = activePaneKind !== 'coder'; // v1.10.3 keyboard shortcuts. Window-level keydown so they fire from // anywhere in the session view. Only Cmd/Ctrl-Shift-C defers to the xterm // (which has its own copy binding for that combo); everything else fires // regardless of focus. Cmd-W and Cmd-T are typically reserved by the // browser — preventDefault() works in most browsers but not all. useEffect(() => { function onKey(e: KeyboardEvent): void { const mod = e.ctrlKey || e.metaKey; if (!mod) return; const key = e.key.toLowerCase(); const target = e.target; const inXterm = target instanceof Element && target.closest('.xterm') !== null; // Cmd/Ctrl + ` — focus the active terminal or jump to the most recent // terminal pane and focus it. No-op if there are no terminal panes. if (key === '`') { e.preventDefault(); const activePane = panes[activePaneIdx]; if (activePane?.kind === 'terminal') { terminalsRegistry.get(activePane.id)?.focus(); return; } let lastTermIdx = -1; for (let i = panes.length - 1; i >= 0; i--) { if (panes[i]?.kind === 'terminal') { lastTermIdx = i; break; } } if (lastTermIdx < 0) return; const target = panes[lastTermIdx]; switchActivePane(lastTermIdx); if (target) { // The terminal may have just mounted on mobile (it was return-null // before the switch). Defer focus until the new render commits. setTimeout(() => terminalsRegistry.get(target.id)?.focus(), 80); } return; } // Cmd/Ctrl + Shift + T — new terminal pane and switch to it. if (key === 't' && e.shiftKey) { e.preventDefault(); addPaneAndSwitch('terminal'); return; } // Cmd/Ctrl + Shift + C — new chat pane and switch to it. The xterm's // own Shift-C binding is "copy selection" — defer to it when in xterm. if (key === 'c' && e.shiftKey) { if (inXterm) return; e.preventDefault(); addPaneAndSwitch('chat'); return; } // Cmd/Ctrl + W — close the active pane. if (key === 'w' && !e.shiftKey) { e.preventDefault(); removePane(activePaneIdx); return; } // v1.10.4: Cmd/Ctrl + F — when the active pane is a terminal, open the // scrollback search bar. When it isn't, fall through to the browser's // native find (no preventDefault, no early return). if (key === 'f' && !e.shiftKey) { const activePane = panes[activePaneIdx]; if (activePane?.kind === 'terminal') { e.preventDefault(); terminalsRegistry.get(activePane.id)?.openSearch(); } return; } // Cmd/Ctrl + Tab / Shift+Tab — cycle through panes. if (key === 'tab') { if (panes.length <= 1) return; e.preventDefault(); const dir = e.shiftKey ? -1 : 1; const next = (activePaneIdx + dir + panes.length) % panes.length; switchActivePane(next); return; } // Cmd/Ctrl + 1..9 — direct jump to pane N. if (/^[1-9]$/.test(key)) { const idx = parseInt(key, 10) - 1; if (idx < panes.length) { e.preventDefault(); switchActivePane(idx); } return; } } window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [panes, activePaneIdx, switchActivePane, addPaneAndSwitch, removePane]); async function saveName() { if (!session) return; const trimmed = name.trim(); if (!trimmed || trimmed === session.name) { setName(session.name); setEditingName(false); return; } const updated = await api.sessions.update(sessionId, { name: trimmed }); setSession(updated); 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 === sessionId && !!active.activeFile; return (
{isMobile ? ( <> {/* v1.8 mobile row 1: hamburger | repo+branch | ModelPicker | FolderTree. Gear/kebab cluster lands in Batch 7; ModelPicker stays here until then so mobile users keep model-switching access. */}
{project ? ( {project.name} ) : ( )} {git?.branch && ( · {git.branch} )}
{session && showSessionModelPicker && ( { const updated = await api.sessions.update(session.id, { model }); setSession(updated); }} /> )}
{/* v1.8 mobile row 2: pane-switcher pill + new-pane menu. Pill expands; NewPaneMenu is the trailing 44x44 trigger. */}
= MAX_PANES} /> {activeIsCoder && activePane && panes.length > 1 && ( )}
) : ( <> {/* Desktop: unchanged single-row header. */}
Projects {project ? ( {project.name} ) : ( )}
{editingName ? ( setName(e.target.value)} onBlur={() => void saveName()} onKeyDown={(e) => { if (e.key === 'Enter') void saveName(); if (e.key === 'Escape') { setName(session?.name ?? ''); setEditingName(false); } }} className="bg-transparent border-b border-border px-1 py-0.5 text-sm font-medium outline-none focus:border-ring min-w-0" /> ) : ( )} {showActiveFile && active.activeFile && ( <> · {active.activeFile} )}
{session && showSessionModelPicker && (
{ const updated = await api.sessions.update(session.id, { model }); setSession(updated); }} />
)}
)}
{session && ( { const updated = await api.sessions.update(session.id, { agent_id }); setSession(updated); }} panesHook={panesHook} chatsHook={chatsHook} session={session} project={project} onAddPane={addPaneAndSwitch} onCoderConnectedChange={(paneId, connected) => setCoderConnected((prev) => prev[paneId] === connected ? prev : { ...prev, [paneId]: connected }, ) } /> )}
); }