Files
boocode/apps/web/src/pages/Session.tsx
indifferentketchup 21384cce5b web: fix Settings pane unreachable on mobile (push ?pane= atomically)
Opening the settings pane on mobile set activePaneIdx, but the ?pane= URL-sync
effect snapped it back to the chat pane on the panes change, so the pane never
showed. toggleSettingsPane now returns the new pane id (id generated outside the
updater, strict-mode safe); Session's toggleSettingsAndSync pushes ?pane=<id> on
mobile when opening (and drops it on close) so the sync effect keeps it active —
mirrors the existing addPaneAndSwitch pattern. Desktop unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 20:20:24 +00:00

535 lines
20 KiB
TypeScript

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 <SessionInner key={id} sessionId={id} />;
}
function SessionInner({ sessionId }: { sessionId: string }) {
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const [session, setSession] = useState<SessionType | null>(null);
const [project, setProject] = useState<Project | null>(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<Record<string, boolean>>({});
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 (
<div className="flex-1 flex flex-col min-h-0">
<header
className={cn(
'border-b shrink-0 text-sm',
isMobile
? 'flex flex-col gap-1.5 px-3 py-2'
: 'flex items-center gap-1.5 px-3 sm:px-4 py-2',
)}
style={{ paddingTop: 'max(0.5rem, env(safe-area-inset-top))' }}
>
{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. */}
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => setDrawerOpen(true)}
className="inline-flex items-center justify-center -ml-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
aria-label="Open sidebar"
>
<Menu className="size-5" />
</button>
<div className="flex-1 min-w-0 flex items-center justify-center gap-1.5">
{project ? (
<span className="text-sm font-medium truncate" title={project.name}>
{project.name}
</span>
) : (
<span className="text-muted-foreground/60"></span>
)}
{git?.branch && (
<span
className="text-muted-foreground/80 text-xs truncate"
title={`branch: ${git.branch}${git.is_dirty ? ' (dirty)' : ''}`}
>
· {git.branch}
</span>
)}
</div>
{session && showSessionModelPicker && (
<ModelPicker
value={session.model}
onChange={async (model) => {
const updated = await api.sessions.update(session.id, { model });
setSession(updated);
}}
/>
)}
<button
type="button"
onClick={toggleRightRail}
className="inline-flex items-center justify-center -mr-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
aria-label="Toggle file browser"
>
<FolderTree className="size-5" />
</button>
</div>
{/* v1.8 mobile row 2: pane-switcher pill + new-pane menu. Pill
expands; NewPaneMenu is the trailing 44x44 trigger. */}
<div className="flex items-center gap-1.5">
<MobileTabSwitcher
panes={panes}
activePaneIdx={activePaneIdx}
chats={chats}
onSwitchPane={switchActivePane}
onRemovePane={removePane}
onRenameChat={renameChat}
/>
<NewPaneMenu
onAddPane={addPaneAndSwitch}
disabled={panes.length >= MAX_PANES}
/>
{activeIsCoder && activePane && panes.length > 1 && (
<button
type="button"
onClick={() => removePane(activePaneIdx)}
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-full bg-muted/40 hover:bg-muted/70 text-foreground shrink-0"
aria-label="Close pane"
>
<X size={16} />
</button>
)}
</div>
</>
) : (
<>
{/* Desktop: unchanged single-row header. */}
<div className="hidden sm:flex items-center gap-1.5 min-w-0">
<Link to="/" className="text-muted-foreground hover:text-foreground shrink-0 text-xs">
Projects
</Link>
<ChevronRight className="size-3 text-muted-foreground/60 shrink-0" />
{project ? (
<Link
to={`/project/${project.id}`}
className="text-muted-foreground hover:text-foreground truncate max-w-[200px]"
title={project.name}
>
{project.name}
</Link>
) : (
<span className="text-muted-foreground/60"></span>
)}
<ChevronRight className="size-3 text-muted-foreground/60 shrink-0" />
</div>
{editingName ? (
<input
autoFocus
value={name}
onChange={(e) => 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"
/>
) : (
<button
type="button"
className="text-sm font-medium hover:underline truncate max-w-[280px] min-w-0"
onClick={() => setEditingName(true)}
title={session?.name ?? ''}
>
{session?.name ?? '…'}
</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-[200px]"
title={active.activeFile}
>
{active.activeFile}
</span>
</>
)}
<div className="ml-auto shrink-0">
{session && showSessionModelPicker && (
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1">
<ModelPicker
value={session.model}
onChange={async (model) => {
const updated = await api.sessions.update(session.id, { model });
setSession(updated);
}}
/>
</div>
)}
</div>
</>
)}
</header>
{session && (
<Workspace
sessionId={sessionId}
projectId={session.project_id}
agentId={session.agent_id}
onAgentChange={async (agent_id) => {
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 },
)
}
/>
)}
</div>
);
}