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 } }));