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>
535 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|