- Long-press selection + floating menu (mobile + desktop right-click): Copy, Paste, Select All, Search, Send to chat. Tap-outside / Esc dismiss. - Pane-header Paste button (📋) for iOS user-gesture clipboard read. - Swipe-left-to-close on mobile pane pill with red "Close" overlay and translateX visual hint; spring-back below 80px threshold. - Send-to-chat reverse path: chatInputsRegistry + sendToChat event mirror the existing terminalsRegistry pattern. ChatInput appends with newline separator on receive and focuses (no auto-send). - Scrollback search via xterm-addon-search@^0.13.0: SearchBar overlay with N-of-M match counter (onDidChangeResults), Enter/Shift-Enter cycling. - Cmd/Ctrl+F intercept in Session.tsx when active pane is terminal; xterm also intercepts when focused. Browser native find passes through elsewhere. - terminalsRegistry signature extended with openSearch + paste callbacks. Includes deferred CLAUDE.md updates documenting v1.10/v1.10.1/v1.10.2/v1.10.3 learnings (uid 1000 collision, libc match, two event buses, vite proxy order, mobile pane URL sync, xterm canvas selection). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
484 lines
18 KiB
TypeScript
484 lines
18 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import {
|
|
Link,
|
|
useLocation,
|
|
useNavigate,
|
|
useParams,
|
|
useSearchParams,
|
|
} from 'react-router-dom';
|
|
import { ChevronRight, FolderTree, Menu } 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,
|
|
} = panesHook;
|
|
|
|
const openChatInActivePane = useCallback(
|
|
(chatId: string) => openChatInPane(activePaneIdxRef.current, chatId),
|
|
[openChatInPane, activePaneIdxRef],
|
|
);
|
|
const chatsHook = useSessionChats(sessionId, {
|
|
removeChatFromPanes,
|
|
openChatInPane,
|
|
openChatInActivePane,
|
|
initializeFirstChatIfEmpty,
|
|
});
|
|
const { chats, renameChat } = chatsHook;
|
|
|
|
// 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]);
|
|
|
|
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') {
|
|
panesHook.toggleSettingsPane();
|
|
}
|
|
});
|
|
}, [sessionId, editingName, navigate, project, panesHook]);
|
|
|
|
// 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' | 'agent') => {
|
|
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],
|
|
);
|
|
|
|
// 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 && (
|
|
<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}
|
|
/>
|
|
</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 && (
|
|
<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}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|