diff --git a/src/components/chat-surface.tsx b/src/components/chat-surface.tsx index be01fdadf..bb23331e5 100644 --- a/src/components/chat-surface.tsx +++ b/src/components/chat-surface.tsx @@ -25,7 +25,6 @@ import { useWorkspaceRailController } from "@/lib/use-workspace-rail-controller" import { useResolvedFamiliars } from "@/lib/familiar-resolve"; import type { Familiar, SessionOrigin, SessionRow } from "@/lib/types"; import type { PendingChatAction } from "@/lib/pending-chat-action"; -import type { PendingCodeRailOpen } from "@/lib/pending-code-rail-open"; import type { InitialCommandControls } from "@/lib/command-controls"; import { requestSummonFamiliar } from "@/lib/summon-events"; @@ -82,12 +81,10 @@ type Props = { onRetryFamiliars?: () => void; pendingProjectRoot: string | null; pendingChatAction?: PendingChatAction; - pendingCodeRailOpen?: PendingCodeRailOpen | null; onSetActiveFamiliar: (id: string | null) => void; onFamiliarScopeChange: (id: string | null, opts?: { multi?: boolean; preserveSurface?: boolean }) => void; onClearPendingProjectRoot: () => void; onPendingChatActionHandled: () => void; - onPendingCodeRailOpenHandled: () => void; onSessionStarted: () => void; onSlashFromChat: (command: string, args: string) => boolean; onOpenOnboarding: () => void; @@ -123,12 +120,10 @@ export function ChatSurface({ onRetryFamiliars, pendingProjectRoot, pendingChatAction, - pendingCodeRailOpen, onSetActiveFamiliar, onFamiliarScopeChange, onClearPendingProjectRoot, onPendingChatActionHandled, - onPendingCodeRailOpenHandled, onSessionStarted, onSlashFromChat, onOpenOnboarding, @@ -185,7 +180,6 @@ export function ChatSurface({ mobileAvailable: mobileRail, mobileOpen: mobileRailOpen, setMobileOpen: setMobileRailOpen, - openTarget: openCodeRailTarget, collapse: collapseCodeRail, } = railController; @@ -299,12 +293,6 @@ export function ChatSurface({ onPendingChatActionHandled(); }, [onPendingChatActionHandled, onSetActiveFamiliar, pendingChatAction, routerRef]); - useEffect(() => { - if (!pendingCodeRailOpen) return; - openCodeRailTarget(pendingCodeRailOpen); - onPendingCodeRailOpenHandled(); - }, [onPendingCodeRailOpenHandled, openCodeRailTarget, pendingCodeRailOpen]); - function startProjectChat(projectRoot: string) { setScope("conversation"); window.setTimeout(() => routerRef.current?.newChat(projectRoot), 0); diff --git a/src/components/code-surface-mode.test.ts b/src/components/code-surface-mode.test.ts index 28fa87f52..44dad3ec8 100644 --- a/src/components/code-surface-mode.test.ts +++ b/src/components/code-surface-mode.test.ts @@ -65,12 +65,13 @@ assert.match( "MODE_ALIASES routes the absorbed GitHub surface onto Code (cave-m6ys)", ); -// File/diff links from inbox cards etc. still target Chat's code rail this -// phase — retargeting them to the Code surface is an explicit follow-up. +// File/diff links from chat transcripts, inbox cards, the Projects hub — +// everywhere — land on the Code surface (cave-ohcj): the workspace bridges +// the events into code mode with the raising chat session attached. assert.match( workspace, - /File\/diff links target ChatSurface's code rail[\s\S]*?setPendingCodeRailOpen\([\s\S]*?setMode\("chat"\)/, - "file-open events keep targeting Chat's code rail until the flagged follow-up", + /File\/diff links land on the Code surface[\s\S]*?setPendingCodeOpen\([\s\S]*?setMode\("code"\)/, + "file-open events route to the Code surface, not Chat's code rail", ); // The primary keyboard cluster is unchanged: Code is a quiet destination, not @@ -118,7 +119,7 @@ assert.match( ); assert.match( workbench, - //, + / void; onFocusCard: (cardId: string) => void; githubTarget?: GitHubItemTarget | null; + /** A file/diff open raised anywhere in the app (cave-ohcj): the workspace + * routes cave:open-project-file / cave:open-file-diff / + * cave:browse-project-files here instead of Chat's code rail. */ + pendingOpen?: PendingCodeOpen | null; + onPendingOpenHandled?: () => void; onTasksRefresh: () => void; }; @@ -54,6 +61,8 @@ export function CodeView({ onJumpToSession, onFocusCard, githubTarget, + pendingOpen, + onPendingOpenHandled, onTasksRefresh, }: CodeViewProps) { // `?mode=code&session=&ctab=&wtab=` @@ -96,6 +105,36 @@ export function CodeView({ ); const groups = useMemo(() => groupCodeRailSessions(sessions), [sessions]); + + // Consume a routed file/diff open (cave-ohcj): select the raising chat + // session's workbench — or, for a Projects-hub root browse, the newest + // session working in that root — and hand the target down for tab focus. + // Held with the session it resolved to so a later manual session switch + // doesn't replay a stale file focus into an unrelated workbench. + const [workbenchTarget, setWorkbenchTarget] = useState<{ + open: PendingCodeOpen; + sessionId: string | null; + } | null>(null); + useEffect(() => { + if (!pendingOpen) return; + const byId = pendingOpen.sessionId + ? groups.flatMap((g) => g.sessions).find((row) => row.id === pendingOpen.sessionId) + : undefined; + const root = pendingOpen.kind === "files" ? pendingOpen.root : undefined; + const trim = (p: string) => p.replace(/\/+$/, ""); + const byRoot = + !byId && root + ? groups.flatMap((g) => g.sessions).find((row) => trim(codeSessionWorkRoot(row)) === trim(root)) + : undefined; + const target = byId ?? byRoot; + setTopTab("sessions"); + if (target) setSelectedId(target.id); + // Root browse with no matching session: there is no workbench to focus — + // land on the surface and leave the rail/selection as-is. + setWorkbenchTarget(root && !target ? null : { open: pendingOpen, sessionId: target?.id ?? null }); + onPendingOpenHandled?.(); + }, [groups, onPendingOpenHandled, pendingOpen]); + const selected = useMemo(() => { if (!selectedId) return null; for (const group of groups) { @@ -175,7 +214,12 @@ export function CodeView({ { + // A manual switch is a context change — drop any pending file + // focus so it can't replay into the newly picked workbench. + setWorkbenchTarget(null); + setSelectedId(id); + }} onNewSession={() => setNewSessionOpen(true)} /> @@ -198,6 +242,11 @@ export function CodeView({ key={selected.id} row={selected} initialTab={deepLink?.sessionId === selected.id ? deepLink?.workbenchTab : undefined} + openTarget={ + workbenchTarget && (workbenchTarget.sessionId ?? selected.id) === selected.id + ? workbenchTarget.open + : undefined + } onJumpToSession={onJumpToSession} onRefresh={onTasksRefresh} /> diff --git a/src/components/code-workbench-files.tsx b/src/components/code-workbench-files.tsx index 705d94195..fa289df92 100644 --- a/src/components/code-workbench-files.tsx +++ b/src/components/code-workbench-files.tsx @@ -9,16 +9,22 @@ * CodeMirror stays out of the surface's initial chunk. */ -import React, { useCallback, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { ProjectTree } from "@/components/project-tree"; import { RailFilePreview } from "@/components/rail-file-preview"; export function CodeWorkbenchFiles({ projectRoot, familiarId, + focusPath, + focusNonce, }: { projectRoot: string; familiarId?: string | null; + /** A routed file open (cave-ohcj): selects this path in the tree/preview. + * `focusNonce` re-applies the jump when the same path repeats. */ + focusPath?: string | null; + focusNonce?: number; }) { const [selectedPath, setSelectedPath] = useState(null); @@ -34,6 +40,11 @@ export function CodeWorkbenchFiles({ [projectRoot], ); + useEffect(() => { + if (!focusPath) return; + openPath(focusPath); + }, [focusNonce, focusPath, openPath]); + return (
diff --git a/src/components/code-workbench.tsx b/src/components/code-workbench.tsx index fe9b274b9..d5f2eb443 100644 --- a/src/components/code-workbench.tsx +++ b/src/components/code-workbench.tsx @@ -38,6 +38,7 @@ import { codeSessionWorkRoot, type CodeWorkbenchTab, } from "@/lib/code-surface"; +import type { PendingCodeOpen } from "@/lib/pending-code-open"; import type { SessionRow } from "@/lib/types"; const LazyFilesTab = dynamic( @@ -67,17 +68,27 @@ const TAB_LABELS: Array<{ id: CodeWorkbenchTab; label: string; icon: Parameters< export function CodeWorkbench({ row, initialTab, + openTarget, onJumpToSession, onRefresh, }: { row: SessionRow; /** Deep-linked workbench tab (?wtab=). */ initialTab?: CodeWorkbenchTab; + /** A routed file/diff open (cave-ohcj): lands on the Files or Diff tab with + * that path focused. `nonce` re-triggers the jump for a repeated path. */ + openTarget?: PendingCodeOpen; onJumpToSession: (sessionId: string, familiarId?: string | null) => void; /** Re-poll the enriched session list (branch/worktree chips) after inspector mutations. */ onRefresh?: () => void; }) { const [tab, setTab] = useState(initialTab ?? "diff"); + // A routed open outranks the resting/deep-linked tab — a diff jump shows + // the Diff tab, a file open the Files tab (also re-applied per nonce). + useEffect(() => { + if (!openTarget) return; + setTab(openTarget.kind === "changes" ? "diff" : "files"); + }, [openTarget]); // Inspector (branches/worktrees/env) is an opt-in right column; md+ only — // the narrow-screen treatment lands with the mobile layout pass. const [inspectorOpen, setInspectorOpen] = useState(false); @@ -173,10 +184,22 @@ export function CodeWorkbench({ {tab === "diff" ? ( // Keyed by work root: the panel's file/diff/checkpoint state is // per-repo, and switching sessions must never show stale rows. - + ) : null} {tab === "files" ? ( - + ) : null} {terminalOpened ? (
diff --git a/src/components/workspace-chat-handoff.test.ts b/src/components/workspace-chat-handoff.test.ts index ddc7157e6..e7b6dd761 100644 --- a/src/components/workspace-chat-handoff.test.ts +++ b/src/components/workspace-chat-handoff.test.ts @@ -6,7 +6,10 @@ const workspace = await readFile(new URL("./workspace.tsx", import.meta.url), "u const chatSurface = await readFile(new URL("./chat-surface.tsx", import.meta.url), "utf8"); const railController = await readFile(new URL("../lib/use-workspace-rail-controller.ts", import.meta.url), "utf8"); const pendingChatActionLib = await readFile(new URL("../lib/pending-chat-action.ts", import.meta.url), "utf8"); -const pendingCodeRailOpenLib = await readFile(new URL("../lib/pending-code-rail-open.ts", import.meta.url), "utf8"); +const pendingCodeOpenLib = await readFile(new URL("../lib/pending-code-open.ts", import.meta.url), "utf8"); +const codeView = await readFile(new URL("./code-view.tsx", import.meta.url), "utf8"); +const codeWorkbench = await readFile(new URL("./code-workbench.tsx", import.meta.url), "utf8"); +const codeWorkbenchFiles = await readFile(new URL("./code-workbench-files.tsx", import.meta.url), "utf8"); const workspaceRail = await readFile(new URL("./workspace-rail.tsx", import.meta.url), "utf8"); const railFilesPanel = await readFile(new URL("./rail-files-panel.tsx", import.meta.url), "utf8"); @@ -118,98 +121,119 @@ assert.match( "ChatSurface should pass pending initial controls into ChatRouter.newChat", ); -// File/diff links can be dispatched while ChatSurface is not mounted. Workspace -// must keep the event detail long enough for ChatSurface to route it into the -// repo-aware code rail after switching to chat. +// File/diff links land on the Code surface (cave-ohcj) — from ANY mode, +// including chat. Workspace keeps the event detail (plus the raising chat +// session) in state long enough for CodeView to mount and route it into the +// right session's workbench. assert.match( - pendingCodeRailOpenLib, - /export type PendingCodeRailOpen =[\s\S]*kind: "files"[\s\S]*kind: "changes"[\s\S]*path: string[\s\S]*nonce: number/, - "PendingCodeRailOpen should be defined once in the shared lib so Workspace and ChatSurface cannot drift", + pendingCodeOpenLib, + /export type PendingCodeOpen =[\s\S]*kind: "files"[\s\S]*sessionId\?: string[\s\S]*kind: "changes"[\s\S]*path: string[\s\S]*sessionId\?: string[\s\S]*nonce: number/, + "PendingCodeOpen should be defined once in the shared lib and carry the raising session", ); assert.match( workspace, - /import type \{ PendingCodeRailOpen \} from "@\/lib\/pending-code-rail-open"/, - "Workspace should import the shared pending code-rail open type", + /import type \{ PendingCodeOpen \} from "@\/lib\/pending-code-open"/, + "Workspace should import the shared pending code open type", ); -assert.match( +assert.doesNotMatch( chatSurface, - /import type \{ PendingCodeRailOpen \} from "@\/lib\/pending-code-rail-open"/, - "ChatSurface should import the shared pending code-rail open type", + /PendingCodeRailOpen|PendingCodeOpen|pendingCodeRailOpen/, + "ChatSurface no longer participates in file/diff open routing (cave-ohcj)", ); assert.match( workspace, - /const \[pendingCodeRailOpen, setPendingCodeRailOpen\] = useState\(null\)/, - "Workspace should retain file/diff open detail across the mode switch into chat", + /const \[pendingCodeOpen, setPendingCodeOpen\] = useState\(null\)/, + "Workspace should retain file/diff open detail across the mode switch into code", ); assert.match( workspace, /window\.addEventListener\("cave:open-project-file", onOpenProjectFile as EventListener\);[\s\S]*window\.addEventListener\("cave:open-file-diff", onOpenFileDiff as EventListener\);/, - "Workspace should bridge both file preview and diff events from non-chat modes", + "Workspace should bridge both file preview and diff events", ); assert.match( workspace, - /if \(modeRef\.current === "chat"\) return;[\s\S]*setPendingCodeRailOpen\([\s\S]*kind === "files"[\s\S]*path: detail\.path[\s\S]*line: detail\.line[\s\S]*path: detail\.path[\s\S]*nonce: Date\.now\(\)[\s\S]*\);[\s\S]*setMode\("chat"\)/, - "Workspace should skip duplicate handling in chat but preserve path/line detail before switching there", + /const sessionId = activeChatSessionIdRef\.current \?\? undefined;[\s\S]*setPendingCodeOpen\([\s\S]*kind === "files"[\s\S]*path: detail\.path[\s\S]*line: detail\.line[\s\S]*sessionId[\s\S]*path: detail\.path[\s\S]*sessionId[\s\S]*nonce: Date\.now\(\)[\s\S]*\);[\s\S]*setMode\("code"\)/, + "Workspace should attach the raising chat session and switch into code mode", ); assert.match( workspace, - /pendingCodeRailOpen=\{pendingCodeRailOpen\}[\s\S]*onPendingCodeRailOpenHandled=\{\(\) => setPendingCodeRailOpen\(null\)\}/, - "Workspace should pass pending file/diff opens into ChatSurface and clear them after consumption", -); -assert.match( - chatSurface, - /pendingCodeRailOpen\?: PendingCodeRailOpen/, - "ChatSurface should accept pending code-rail open actions", + /pendingOpen=\{pendingCodeOpen\}[\s\S]*onPendingOpenHandled=\{\(\) => setPendingCodeOpen\(null\)\}/, + "Workspace should pass pending file/diff opens into CodeView and clear them after consumption", ); assert.match( railController, /openTarget[\s\S]*rail\.reopen\(\)[\s\S]*rail\.setActiveTab\(target\.kind === "changes" \? "changes" : "files"\)[\s\S]*setFocus/, "The shared rail controller should reopen the code rail, select Files/Changes, and store the focused path", ); +assert.doesNotMatch( + railController, + /addEventListener\("cave:open-project-file"|addEventListener\("cave:open-file-diff"|addEventListener\("cave:browse-project-files"/, + "the rail controller no longer consumes global file/diff/browse open events (cave-ohcj)", +); assert.match( railController, - /openProjectFile[\s\S]*openTarget\(\{ kind: "files"[\s\S]*openFileDiff[\s\S]*openTarget\(\{ kind: "changes"[\s\S]*addEventListener\("cave:open-project-file"[\s\S]*addEventListener\("cave:open-file-diff"/, - "The shared rail controller should directly consume file and diff events while mounted", + /window\.addEventListener\("cave:changes-open", openChanges\)/, + "the rail controller keeps its surface-internal show-changes affordance", ); assert.match( - chatSurface, - /if \(!pendingCodeRailOpen\) return[\s\S]*openCodeRailTarget\(pendingCodeRailOpen\)[\s\S]*onPendingCodeRailOpenHandled\(\)/, - "ChatSurface should consume pending file/diff opens after mounting", + codeView, + /if \(!pendingOpen\) return;[\s\S]*pendingOpen\.sessionId[\s\S]*\.find\(\(row\) => row\.id === pendingOpen\.sessionId\)[\s\S]*setTopTab\("sessions"\);[\s\S]*if \(target\) setSelectedId\(target\.id\);[\s\S]*onPendingOpenHandled\?\.\(\)/, + "CodeView should consume pending opens by selecting the raising session's workbench", +); +assert.match( + codeView, + /openTarget=\{\s*workbenchTarget && \(workbenchTarget\.sessionId \?\? selected\.id\) === selected\.id\s*\? workbenchTarget\.open\s*: undefined\s*\}/, + "CodeView should hand the open target only to the session it resolved to", +); +assert.match( + codeWorkbench, + /if \(!openTarget\) return;[\s\S]*setTab\(openTarget\.kind === "changes" \? "diff" : "files"\)/, + "the workbench should land a routed open on the Diff or Files tab", +); +assert.match( + codeWorkbench, + / \{[\s\S]*if \(!focusPath\) return;[\s\S]*openPath\(focusPath\)/, + "the workbench Files tab should select an externally focused path", ); // cave-z44: Projects hub "Browse files" drills into a project ROOT (no file). -// The shared type carries an optional root; Workspace bridges the event from -// non-chat modes; ChatSurface consumes it directly when already mounted and -// arms the browse override. +// The shared type carries an optional root; Workspace bridges the event into +// code mode; CodeView picks that root's newest session (or degrades to the +// surface with no workbench focus when none exists). assert.match( - pendingCodeRailOpenLib, + pendingCodeOpenLib, /kind: "files";[\s\S]*root\?: string;/, "the shared type carries an optional browse root on the files open", ); assert.match( workspace, /window\.addEventListener\("cave:browse-project-files", onBrowseProjectFiles as EventListener\)/, - "Workspace bridges the Projects-hub browse-files event into chat mode", + "Workspace bridges the Projects-hub browse-files event into code mode", ); assert.match( workspace, - /onBrowseProjectFiles = \(e: Event\) => \{[\s\S]*if \(modeRef\.current === "chat"\) return;[\s\S]*if \(!detail\?\.root\) return;[\s\S]*setPendingCodeRailOpen\(\{ kind: "files", root: detail\.root, nonce: Date\.now\(\) \}\)[\s\S]*setMode\("chat"\)/, - "Workspace preserves the browse root and switches to chat", -); -assert.match( - railController, - /browseProjectFiles[\s\S]*if \(detail\?\.root\) openTarget\(\{ kind: "files", root: detail\.root, nonce: Date\.now\(\) \}\)/, - "The shared rail controller directly consumes the browse-files event when already mounted", + /onBrowseProjectFiles = \(e: Event\) => \{[\s\S]*if \(!detail\?\.root\) return;[\s\S]*setPendingCodeOpen\(\{ kind: "files", root: detail\.root, nonce: Date\.now\(\) \}\)[\s\S]*setMode\("code"\)/, + "Workspace preserves the browse root and switches to code", ); assert.match( - railController, - /window\.addEventListener\("cave:browse-project-files", browseProjectFiles as EventListener\)/, - "The shared rail controller listens for the browse-files event", + codeView, + /const byRoot =[\s\S]*trim\(codeSessionWorkRoot\(row\)\) === trim\(root\)/, + "CodeView resolves a browse root to the newest session working in it", ); assert.match( - railController, - /setBrowseRootOverride\(target\.kind === "files" \? \(target\.root \?\? null\) : null\)/, - "openTarget arms the browse override from a files target's root and clears it otherwise", + codeView, + /setWorkbenchTarget\(root && !target \? null : \{ open: pendingOpen, sessionId: target\?\.id \?\? null \}\)/, + "a browse root with no matching session degrades to the surface without a stale focus", ); assert.match( chatSurface, diff --git a/src/components/workspace-rail.tsx b/src/components/workspace-rail.tsx index 50d816d9d..abddcace4 100644 --- a/src/components/workspace-rail.tsx +++ b/src/components/workspace-rail.tsx @@ -7,7 +7,7 @@ import { SessionChangesPanel } from "@/components/session-changes-panel"; import { RailFilesPanel } from "@/components/rail-files-panel"; import { RailTerminalPanel } from "@/components/rail-terminal-panel"; import type { CodeRailTab } from "@/lib/code-rail"; -import type { PendingCodeRailOpen as CodeRailFocus } from "@/lib/pending-code-rail-open"; +import type { PendingCodeOpen as CodeRailFocus } from "@/lib/pending-code-open"; import { useStageChecksBadge } from "@/lib/use-stage-checks-badge"; const TAB_TITLE: Record = { diff --git a/src/components/workspace.tsx b/src/components/workspace.tsx index edceab4bf..04ddad4c8 100644 --- a/src/components/workspace.tsx +++ b/src/components/workspace.tsx @@ -143,7 +143,7 @@ import { } from "@/lib/first-project-gate-retry"; import type { PendingChatAction } from "@/lib/pending-chat-action"; import { consumePendingAgentsNewChat } from "@/lib/agents-new-chat"; -import type { PendingCodeRailOpen } from "@/lib/pending-code-rail-open"; +import type { PendingCodeOpen } from "@/lib/pending-code-open"; import type { ChatAttachment } from "@/lib/chat-attachments"; import { startVoiceConversation, voiceChatStartErrorMessage } from "@/lib/voice/start-voice-chat"; import { @@ -467,7 +467,11 @@ export function Workspace() { // router applies opens in a deferred hop, so render-time ref reads always // lagged one update behind (the n-1 highlight bug). const [activeChatSessionId, setActiveChatSessionId] = useState(null); - const [pendingCodeRailOpen, setPendingCodeRailOpen] = useState(null); + // Mirror for the []-dep file-open listener below: opens raised mid-chat + // attach the CURRENT session without re-subscribing on every change. + const activeChatSessionIdRef = useRef(null); + activeChatSessionIdRef.current = activeChatSessionId; + const [pendingCodeOpen, setPendingCodeOpen] = useState(null); const [onboardingOpen, setOnboardingOpen] = useState(false); const [onboardingResolved, setOnboardingResolved] = useState(false); const [autoFinishOnboarding, setAutoFinishOnboarding] = useState(false); @@ -783,30 +787,32 @@ export function Workspace() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // File/diff links target ChatSurface's code rail. ChatSurface only mounts in - // chat mode, so preserve event detail from non-chat surfaces until it mounts. + // File/diff links land on the Code surface (cave-ohcj): every open — from + // chat transcripts, the Projects hub, anywhere — routes into code mode with + // the raising chat session attached so CodeView can select its workbench. + // The event detail is preserved in state until CodeView mounts. useEffect(() => { - const enqueue = (kind: PendingCodeRailOpen["kind"], e: Event) => { - if (modeRef.current === "chat") return; + const enqueue = (kind: PendingCodeOpen["kind"], e: Event) => { const detail = (e as CustomEvent<{ path?: string; line?: number }>).detail; if (!detail?.path) return; - setPendingCodeRailOpen( + const sessionId = activeChatSessionIdRef.current ?? undefined; + setPendingCodeOpen( kind === "files" - ? { kind, path: detail.path, line: detail.line, nonce: Date.now() } - : { kind, path: detail.path, nonce: Date.now() }, + ? { kind, path: detail.path, line: detail.line, sessionId, nonce: Date.now() } + : { kind, path: detail.path, sessionId, nonce: Date.now() }, ); - setMode("chat"); + setMode("code"); }; const onOpenProjectFile = (e: Event) => enqueue("files", e); const onOpenFileDiff = (e: Event) => enqueue("changes", e); // Projects hub → "Browse files": carries a project ROOT (not a file path); - // ChatSurface browses that root with nothing selected (cave-z44). + // CodeView picks that project's newest session and browses its tree + // (cave-z44's peek, re-homed on the Code surface). const onBrowseProjectFiles = (e: Event) => { - if (modeRef.current === "chat") return; const detail = (e as CustomEvent<{ root?: string }>).detail; if (!detail?.root) return; - setPendingCodeRailOpen({ kind: "files", root: detail.root, nonce: Date.now() }); - setMode("chat"); + setPendingCodeOpen({ kind: "files", root: detail.root, nonce: Date.now() }); + setMode("code"); }; window.addEventListener("cave:open-project-file", onOpenProjectFile as EventListener); window.addEventListener("cave:open-file-diff", onOpenFileDiff as EventListener); @@ -2719,12 +2725,10 @@ export function Workspace() { onRetryFamiliars={() => void loadFamiliars()} pendingProjectRoot={pendingProjectChatRoot} pendingChatAction={pendingChatAction} - pendingCodeRailOpen={pendingCodeRailOpen} onSetActiveFamiliar={setActiveId} onFamiliarScopeChange={selectFamiliarScope} onClearPendingProjectRoot={() => setPendingProjectChatRoot(null)} onPendingChatActionHandled={() => setPendingChatAction(null)} - onPendingCodeRailOpenHandled={() => setPendingCodeRailOpen(null)} onActiveSessionChange={setActiveChatSessionId} onSessionStarted={loadSessions} onSlashFromChat={handleSlashIntent} @@ -2843,6 +2847,8 @@ export function Workspace() { onJumpToSession={openFamiliarSession} onFocusCard={(cardId) => onPaletteIntent({ kind: "focus-card", cardId })} githubTarget={githubTarget} + pendingOpen={pendingCodeOpen} + onPendingOpenHandled={() => setPendingCodeOpen(null)} onTasksRefresh={() => void loadGitHubTasks(true)} /> ) : mode === "marketplace" || mode === "roles" || mode === "capabilities" ? ( diff --git a/src/lib/pending-code-rail-open.ts b/src/lib/pending-code-open.ts similarity index 50% rename from src/lib/pending-code-rail-open.ts rename to src/lib/pending-code-open.ts index 40945c514..562fa0a7e 100644 --- a/src/lib/pending-code-rail-open.ts +++ b/src/lib/pending-code-open.ts @@ -1,18 +1,23 @@ -export type PendingCodeRailOpen = +export type PendingCodeOpen = | { kind: "files"; // Omitted for a "browse at root" open (Projects hub → Files): the Files // tab shows the tree with nothing selected. Present for a file open. path?: string; line?: number; - // When set, the code rail browses THIS project root instead of the active + // When set, the target browses THIS project root instead of the active // session's — a bounded "peek" that lets the Projects hub drill into any - // project's files (cave-z44). Cleared on session change / rail collapse. + // project's files (cave-z44). root?: string; + // The chat session the open was raised from (cave-ohcj): the Code + // surface selects this session's workbench so the file lands beside the + // conversation's diff/terminal context. Absent for root-only browses. + sessionId?: string; nonce: number; } | { kind: "changes"; path: string; + sessionId?: string; nonce: number; }; diff --git a/src/lib/tool-target-file.test.ts b/src/lib/tool-target-file.test.ts index 5f4e6a668..98bb5ee3f 100644 --- a/src/lib/tool-target-file.test.ts +++ b/src/lib/tool-target-file.test.ts @@ -57,16 +57,17 @@ assert.match( assert.match(chatView, /openTargetFile = \(e: ReactMouseEvent\) => \{[\s\S]*?stopPropagation\(\)/, "open handler stops propagation"); // (ComuxView's cave:open-project-file listener left with the component, -// cave-c3yt — the chat code rail below is the live consumer.) +// cave-c3yt — the workspace bridge into the Code surface is the live +// consumer, cave-ohcj.) assert.match( workspace, - /File\/diff links target ChatSurface's code rail[\s\S]*?setPendingCodeRailOpen\([\s\S]*?setMode\("chat"\)/, - "workspace preserves file-open event detail while switching into chat", + /File\/diff links land on the Code surface[\s\S]*?setPendingCodeOpen\([\s\S]*?setMode\("code"\)/, + "workspace preserves file-open event detail while switching into code", ); -assert.match( +assert.doesNotMatch( railController, - /addEventListener\("cave:open-project-file"[\s\S]*addEventListener\("cave:open-file-diff"[\s\S]*openTarget/, - "the shared rail controller routes file and diff open events into the code rail", + /addEventListener\("cave:open-project-file"|addEventListener\("cave:open-file-diff"/, + "the chat rail controller no longer consumes global file/diff open events (cave-ohcj)", ); console.log("tool-target-file.test.ts: ok"); diff --git a/src/lib/use-workspace-rail-controller.ts b/src/lib/use-workspace-rail-controller.ts index ffdb114d2..6ee770edd 100644 --- a/src/lib/use-workspace-rail-controller.ts +++ b/src/lib/use-workspace-rail-controller.ts @@ -7,7 +7,7 @@ import { useCodeRail } from "@/lib/use-code-rail"; import { useFocusTrap } from "@/lib/use-focus-trap"; import { useStageChecksBadge } from "@/lib/use-stage-checks-badge"; import { useIsMobile } from "@/lib/use-viewport"; -import type { PendingCodeRailOpen } from "@/lib/pending-code-rail-open"; +import type { PendingCodeOpen } from "@/lib/pending-code-open"; type Args = { containerRef: RefObject; @@ -106,7 +106,7 @@ export function useWorkspaceRailController({ terminalActive: terminalOpened, browseActive: browseRootOverride !== null, }); - const [focus, setFocus] = useState(null); + const [focus, setFocus] = useState(null); useEffect(() => { if (rail.activeTab === "terminal" && rail.open) setTerminalOpened(true); }, [rail.activeTab, rail.open]); @@ -144,7 +144,7 @@ export function useWorkspaceRailController({ if (!rail.available || (!isMobile && !paneNarrow) || !active) setMobileOpen(false); }, [active, isMobile, paneNarrow, rail.available]); - const openTarget = useCallback((target: PendingCodeRailOpen) => { + const openTarget = useCallback((target: PendingCodeOpen) => { onActivate?.(); setBrowseRootOverride(target.kind === "files" ? (target.root ?? null) : null); rail.reopen(); @@ -161,30 +161,16 @@ export function useWorkspaceRailController({ if (isMobile || paneNarrow) setMobileOpen(true); }, [isMobile, onActivate, paneNarrow, rail]); + // File/diff/browse open events (cave:open-project-file, cave:open-file-diff, + // cave:browse-project-files) are workspace-level concerns now — they route + // to the Code surface (cave-ohcj), not this rail. The rail keeps only its + // surface-internal "show changes" affordance. useEffect(() => { - const openProjectFile = (event: Event) => { - const detail = (event as CustomEvent<{ path?: string; line?: number }>).detail; - if (detail?.path) openTarget({ kind: "files", path: detail.path, line: detail.line, nonce: Date.now() }); - }; - const openFileDiff = (event: Event) => { - const detail = (event as CustomEvent<{ path?: string }>).detail; - if (detail?.path) openTarget({ kind: "changes", path: detail.path, nonce: Date.now() }); - }; - const browseProjectFiles = (event: Event) => { - const detail = (event as CustomEvent<{ root?: string }>).detail; - if (detail?.root) openTarget({ kind: "files", root: detail.root, nonce: Date.now() }); - }; - window.addEventListener("cave:open-project-file", openProjectFile as EventListener); - window.addEventListener("cave:open-file-diff", openFileDiff as EventListener); - window.addEventListener("cave:browse-project-files", browseProjectFiles as EventListener); window.addEventListener("cave:changes-open", openChanges); return () => { - window.removeEventListener("cave:open-project-file", openProjectFile as EventListener); - window.removeEventListener("cave:open-file-diff", openFileDiff as EventListener); - window.removeEventListener("cave:browse-project-files", browseProjectFiles as EventListener); window.removeEventListener("cave:changes-open", openChanges); }; - }, [openChanges, openTarget]); + }, [openChanges]); useEffect(() => { window.dispatchEvent(new CustomEvent("cave:code-rail-visibility", { detail: { open: showInline } }));