Refactor chat file-link opens to a single disposition seam (#1038)
* Open chat file links in a side pane on cmd-click
* Add hover tooltips to file links showing cmd-click hint
* Collapse file-link open path to a single disposition seam
Replace the parallel "open in side" callback that was threaded
alongside the normal open callback at every layer between the chat
click handler and the workspace router. One callback now carries a
`OpenFileDisposition` ("main" | "side") so the modifier-key decision
lives at the leaf and the routing decision lives at the workspace
screen, with no branching in between.
* Give assistant file links a home
This commit is contained in:
@@ -20,6 +20,7 @@ import type { Agent } from "@/stores/session-store";
|
||||
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import type { OpenFileDisposition } from "@/utils/workspace-file-open";
|
||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
||||
@@ -318,7 +319,7 @@ interface WorkspaceDraftAgentTabProps {
|
||||
initialSetup?: WorkspaceDraftTabSetup;
|
||||
isPaneFocused: boolean;
|
||||
onCreated: (snapshot: AgentSnapshotPayload) => void;
|
||||
onOpenWorkspaceFile: (input: { filePath: string }) => void;
|
||||
onOpenWorkspaceFile: (input: { filePath: string; disposition: OpenFileDisposition }) => void;
|
||||
onOpenImportSheet?: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import type { OpenFileDisposition } from "@/utils/workspace-file-open";
|
||||
|
||||
export interface WorkspacePaneContentModel {
|
||||
key: string;
|
||||
@@ -23,7 +24,7 @@ export interface BuildWorkspacePaneContentModelInput {
|
||||
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
||||
onCloseCurrentTab: () => void;
|
||||
onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
||||
onOpenWorkspaceFile: (filePath: string) => void;
|
||||
onOpenWorkspaceFile: (filePath: string, disposition: OpenFileDisposition) => void;
|
||||
onOpenImportSheet: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deriveWorkspacePaneState,
|
||||
getWorkspacePaneDescriptors,
|
||||
resolveSideFileOpenPlacement,
|
||||
} from "@/screens/workspace/workspace-pane-state";
|
||||
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||
@@ -104,4 +105,80 @@ describe("workspace-pane-state", () => {
|
||||
path: "/repo/README.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves side file opens to an existing file tab", () => {
|
||||
const layout: WorkspaceLayout = {
|
||||
root: {
|
||||
kind: "pane",
|
||||
pane: {
|
||||
id: "main",
|
||||
tabIds: ["file_/repo/README.md"],
|
||||
focusedTabId: "file_/repo/README.md",
|
||||
},
|
||||
},
|
||||
focusedPaneId: "main",
|
||||
};
|
||||
const tabs = [createTab("file_/repo/README.md", { kind: "file", path: "/repo/README.md" })];
|
||||
|
||||
expect(
|
||||
resolveSideFileOpenPlacement({
|
||||
layout,
|
||||
sourcePaneId: "main",
|
||||
tabs,
|
||||
target: { kind: "file", path: "/repo/README.md" },
|
||||
}),
|
||||
).toEqual({ kind: "open-in-source" });
|
||||
});
|
||||
|
||||
it("resolves side file opens to an existing right pane", () => {
|
||||
const layout: WorkspaceLayout = {
|
||||
root: {
|
||||
kind: "group",
|
||||
group: {
|
||||
id: "group-root",
|
||||
direction: "horizontal",
|
||||
sizes: [0.5, 0.5],
|
||||
children: [
|
||||
{
|
||||
kind: "pane",
|
||||
pane: { id: "left", tabIds: ["agent_agent-a"], focusedTabId: "agent_agent-a" },
|
||||
},
|
||||
{
|
||||
kind: "pane",
|
||||
pane: { id: "right", tabIds: [], focusedTabId: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
focusedPaneId: "left",
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveSideFileOpenPlacement({
|
||||
layout,
|
||||
sourcePaneId: "left",
|
||||
tabs: [createTab("agent_agent-a", { kind: "agent", agentId: "agent-a" })],
|
||||
target: { kind: "file", path: "/repo/README.md" },
|
||||
}),
|
||||
).toEqual({ kind: "focus-side-pane", paneId: "right" });
|
||||
});
|
||||
|
||||
it("resolves side file opens to a split when there is no right pane", () => {
|
||||
const layout: WorkspaceLayout = {
|
||||
root: {
|
||||
kind: "pane",
|
||||
pane: { id: "main", tabIds: ["agent_agent-a"], focusedTabId: "agent_agent-a" },
|
||||
},
|
||||
focusedPaneId: "main",
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveSideFileOpenPlacement({
|
||||
layout,
|
||||
sourcePaneId: "main",
|
||||
tabs: [createTab("agent_agent-a", { kind: "agent", agentId: "agent-a" })],
|
||||
target: { kind: "file", path: "/repo/README.md" },
|
||||
}),
|
||||
).toEqual({ kind: "split-side-pane", paneId: "main" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
normalizeWorkspaceTabTarget,
|
||||
workspaceTabTargetsEqual,
|
||||
} from "@/utils/workspace-tab-identity";
|
||||
import { findAdjacentPane } from "@/utils/split-navigation";
|
||||
|
||||
export interface WorkspaceDerivedTab {
|
||||
descriptor: WorkspaceTabDescriptor;
|
||||
@@ -23,6 +24,11 @@ export interface WorkspacePaneState {
|
||||
activeTab: WorkspaceDerivedTab | null;
|
||||
}
|
||||
|
||||
export type WorkspaceSideFileOpenPlacement =
|
||||
| { kind: "open-in-source" }
|
||||
| { kind: "focus-side-pane"; paneId: string }
|
||||
| { kind: "split-side-pane"; paneId: string };
|
||||
|
||||
interface NormalizeWorkspacePaneTabsResult {
|
||||
tabs: WorkspaceDerivedTab[];
|
||||
openTabIds: Set<string>;
|
||||
@@ -196,3 +202,28 @@ export function getWorkspacePaneDescriptors(input: {
|
||||
}): WorkspaceTabDescriptor[] {
|
||||
return deriveWorkspacePaneState(input).tabs.map((tab) => tab.descriptor);
|
||||
}
|
||||
|
||||
export function resolveSideFileOpenPlacement(input: {
|
||||
layout?: WorkspaceLayout | null;
|
||||
sourcePaneId?: string | null;
|
||||
tabs: WorkspaceTab[];
|
||||
target: WorkspaceTabTarget;
|
||||
}): WorkspaceSideFileOpenPlacement {
|
||||
const existingTab = input.tabs.find((tab) => workspaceTabTargetsEqual(tab.target, input.target));
|
||||
if (existingTab) {
|
||||
return { kind: "open-in-source" };
|
||||
}
|
||||
|
||||
const layout = input.layout ?? null;
|
||||
const sourcePaneId = trimNonEmpty(input.sourcePaneId);
|
||||
if (!layout || !sourcePaneId) {
|
||||
return { kind: "open-in-source" };
|
||||
}
|
||||
|
||||
const sidePaneId = findAdjacentPane(layout.root, sourcePaneId, "right");
|
||||
if (sidePaneId) {
|
||||
return { kind: "focus-side-pane", paneId: sidePaneId };
|
||||
}
|
||||
|
||||
return { kind: "split-side-pane", paneId: sourcePaneId };
|
||||
}
|
||||
|
||||
@@ -135,7 +135,10 @@ import {
|
||||
deriveWorkspaceAgentVisibility,
|
||||
workspaceAgentVisibilityEqual,
|
||||
} from "@/workspace-tabs/agent-visibility";
|
||||
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
|
||||
import {
|
||||
deriveWorkspacePaneState,
|
||||
resolveSideFileOpenPlacement,
|
||||
} from "@/screens/workspace/workspace-pane-state";
|
||||
import {
|
||||
buildWorkspacePaneContentModel,
|
||||
WorkspacePaneContent,
|
||||
@@ -2029,7 +2032,7 @@ function WorkspaceScreenContent({
|
||||
);
|
||||
|
||||
const handleOpenFileFromChat = useCallback(
|
||||
({ filePath }: { filePath: string }) => {
|
||||
(filePath: string) => {
|
||||
const normalizedFilePath = filePath.trim();
|
||||
if (!normalizedFilePath) {
|
||||
return;
|
||||
@@ -2039,6 +2042,51 @@ function WorkspaceScreenContent({
|
||||
[handleOpenFileFromExplorer],
|
||||
);
|
||||
|
||||
const handleOpenFileFromChatInSidePane = useCallback(
|
||||
(input: { filePath: string; sourcePaneId?: string }) => {
|
||||
const normalizedFilePath = input.filePath.trim();
|
||||
if (!normalizedFilePath) {
|
||||
return;
|
||||
}
|
||||
if (!persistenceKey || isMobile || !input.sourcePaneId) {
|
||||
handleOpenFileFromExplorer(normalizedFilePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const target: WorkspaceTabTarget = { kind: "file", path: normalizedFilePath };
|
||||
const placement = resolveSideFileOpenPlacement({
|
||||
layout: workspaceLayout,
|
||||
sourcePaneId: input.sourcePaneId,
|
||||
tabs: uiTabs,
|
||||
target,
|
||||
});
|
||||
if (placement.kind === "focus-side-pane") {
|
||||
focusWorkspacePane(persistenceKey, placement.paneId);
|
||||
} else if (placement.kind === "split-side-pane") {
|
||||
splitWorkspacePaneEmpty(persistenceKey, {
|
||||
targetPaneId: placement.paneId,
|
||||
position: "right",
|
||||
});
|
||||
}
|
||||
|
||||
const tabId = openWorkspaceTabFocused(persistenceKey, target);
|
||||
if (tabId) {
|
||||
navigateToTabId(tabId);
|
||||
}
|
||||
},
|
||||
[
|
||||
handleOpenFileFromExplorer,
|
||||
isMobile,
|
||||
focusWorkspacePane,
|
||||
navigateToTabId,
|
||||
openWorkspaceTabFocused,
|
||||
persistenceKey,
|
||||
splitWorkspacePaneEmpty,
|
||||
uiTabs,
|
||||
workspaceLayout,
|
||||
],
|
||||
);
|
||||
|
||||
const [_hoveredTabKey, setHoveredTabKey] = useState<string | null>(null);
|
||||
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState<string | null>(null);
|
||||
|
||||
@@ -2707,17 +2755,25 @@ function WorkspaceScreenContent({
|
||||
}
|
||||
retargetWorkspaceTab(persistenceKey, input.tab.tabId, target);
|
||||
},
|
||||
onOpenWorkspaceFile: (filePath) => {
|
||||
onOpenWorkspaceFile: (filePath, disposition) => {
|
||||
if (input.focusPaneBeforeOpen && input.paneId && persistenceKey) {
|
||||
focusWorkspacePane(persistenceKey, input.paneId);
|
||||
}
|
||||
handleOpenFileFromChat({ filePath });
|
||||
if (disposition === "side") {
|
||||
handleOpenFileFromChatInSidePane({
|
||||
filePath,
|
||||
sourcePaneId: input.paneId ?? undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleOpenFileFromChat(filePath);
|
||||
},
|
||||
onOpenImportSheet: openImportSheet,
|
||||
}),
|
||||
[
|
||||
handleCloseTabById,
|
||||
handleOpenFileFromChat,
|
||||
handleOpenFileFromChatInSidePane,
|
||||
focusWorkspacePane,
|
||||
navigateToTabId,
|
||||
normalizedServerId,
|
||||
|
||||
Reference in New Issue
Block a user