From 618b4a30b17d828e141e7d95668cd3ab066e2e6c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 16 May 2026 17:27:20 +0800 Subject: [PATCH] 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 --- .../app/src/components/agent-stream-view.tsx | 10 +- .../src/components/assistant-file-link.tsx | 346 ++++++++++++++++++ packages/app/src/components/message.tsx | 163 +-------- .../hooks/use-assistant-file-link-resolver.ts | 14 +- packages/app/src/panels/agent-panel.tsx | 19 +- packages/app/src/panels/draft-panel.tsx | 5 +- packages/app/src/panels/pane-context.tsx | 3 +- .../workspace/workspace-draft-agent-tab.tsx | 3 +- .../workspace/workspace-pane-content.tsx | 3 +- .../workspace/workspace-pane-state.test.ts | 77 ++++ .../screens/workspace/workspace-pane-state.ts | 31 ++ .../screens/workspace/workspace-screen.tsx | 64 +++- .../assistant-file-link-resolver.test.ts | 151 +++++--- .../src/utils/assistant-file-link-resolver.ts | 21 +- packages/app/src/utils/workspace-file-open.ts | 1 + 15 files changed, 684 insertions(+), 227 deletions(-) create mode 100644 packages/app/src/components/assistant-file-link.tsx create mode 100644 packages/app/src/utils/workspace-file-open.ts diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 102ef6d89..11895cfa6 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -75,6 +75,7 @@ import { type BottomAnchorRouteRequest, } from "./use-bottom-anchor-controller"; import { normalizeInlinePathTarget } from "@/utils/inline-path"; +import type { OpenFileDisposition } from "@/utils/workspace-file-open"; import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { useStableEvent } from "@/hooks/use-stable-event"; @@ -242,7 +243,7 @@ export interface AgentStreamViewProps { routeBottomAnchorRequest?: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady?: boolean; toast?: ToastApi | null; - onOpenWorkspaceFile?: (input: { filePath: string }) => void; + onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void; } const EMPTY_STREAM_HEAD: StreamItem[] = []; @@ -304,6 +305,7 @@ const AgentStreamViewComponent = forwardRef { + (target: InlinePathTarget, disposition: OpenFileDisposition) => { if (!target.path) { return; } @@ -335,7 +337,7 @@ const AgentStreamViewComponent = forwardRef { - handleInlinePathPress({ raw: filePath, path: filePath }); + handleInlinePathPress({ raw: filePath, path: filePath }, "main"); }, [handleInlinePathPress], ); diff --git a/packages/app/src/components/assistant-file-link.tsx b/packages/app/src/components/assistant-file-link.tsx new file mode 100644 index 000000000..c70666923 --- /dev/null +++ b/packages/app/src/components/assistant-file-link.tsx @@ -0,0 +1,346 @@ +import { + useCallback, + useMemo, + useState, + type CSSProperties, + type ReactNode, + type MouseEvent, +} from "react"; +import { + Pressable, + Text, + View, + type StyleProp, + type TextStyle, + type ViewStyle, +} from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { isNative, isWeb } from "@/constants/platform"; +import { classifyAssistantFileLink, type InlinePathTarget } from "@/utils/inline-path"; +import type { AssistantFileLinkSource } from "@/utils/assistant-file-link-resolver"; +import type { OpenFileDisposition } from "@/utils/workspace-file-open"; +import { Shortcut } from "@/components/ui/shortcut"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +interface AssistantInlinePathLinkProps { + content: string; + parsed: InlinePathTarget; + onPress: (target: InlinePathTarget, disposition: OpenFileDisposition) => void; + workspaceRoot?: string; + style: StyleProp; +} + +export function AssistantInlinePathLink({ + content, + parsed, + onPress, + workspaceRoot, + style, +}: AssistantInlinePathLinkProps) { + const handlePress = useCallback(() => onPress(parsed, "main"), [onPress, parsed]); + const handleAnchorClickCapture = useCallback( + (event: MouseEvent) => { + event.preventDefault(); + if (!isModifiedOpenEvent(event)) { + return; + } + event.stopPropagation(); + onPress(parsed, "side"); + }, + [onPress, parsed], + ); + + if (!isNative) { + return ( + + + + {content} + + + + ); + } + + return ( + + {content} + + ); +} + +interface AssistantMarkdownLinkProps { + source: AssistantFileLinkSource; + style: StyleProp; + onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void; + onPrefetch: (source: AssistantFileLinkSource) => void; + workspaceRoot?: string; + children: ReactNode; +} + +export function AssistantMarkdownLink({ + source, + style, + onPress, + onPrefetch, + workspaceRoot, + children, +}: AssistantMarkdownLinkProps) { + const [hovered, setHovered] = useState(false); + const href = source.href; + const handlePress = useCallback(() => onPress(source, "main"), [onPress, source]); + const handleAnchorClickCapture = useCallback( + (event: MouseEvent) => { + event.preventDefault(); + if (!isModifiedOpenEvent(event)) { + return; + } + event.stopPropagation(); + onPress(source, "side"); + }, + [onPress, source], + ); + const handlePrefetch = useCallback(() => onPrefetch(source), [onPrefetch, source]); + const handleHoverIn = useCallback(() => { + setHovered(true); + handlePrefetch(); + }, [handlePrefetch]); + const handleHoverOut = useCallback(() => setHovered(false), []); + const hoveredTextStyle = useMemo>( + () => [style, hovered && { textDecorationLine: "underline" as const }], + [style, hovered], + ); + const tooltipFilePath = useMemo( + () => getMarkdownLinkTooltipFilePath(source.href, workspaceRoot), + [source.href, workspaceRoot], + ); + if (isNative) { + return ( + + {children} + + ); + } + + const anchor = ( + + + {children} + + + ); + + if (tooltipFilePath) { + return {anchor}; + } + return anchor; +} + +interface AssistantMarkdownCodeLinkProps { + source: AssistantFileLinkSource; + inheritedStyles: TextStyle; + codeInlineStyle: TextStyle; + linkStyle: TextStyle; + onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void; + onPrefetch: (source: AssistantFileLinkSource) => void; + workspaceRoot?: string; + children: ReactNode; +} + +export function AssistantMarkdownCodeLink({ + source, + inheritedStyles, + codeInlineStyle, + linkStyle, + onPress, + onPrefetch, + workspaceRoot, + children, +}: AssistantMarkdownCodeLinkProps) { + const style = useMemo( + () => [inheritedStyles, codeInlineStyle, linkStyle], + [inheritedStyles, codeInlineStyle, linkStyle], + ); + return ( + + {children} + + ); +} + +interface AssistantInlineCodePathLinkProps { + content: string; + inheritedStyles: TextStyle; + codeInlineStyle: TextStyle; + linkStyle: TextStyle; + onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void; + onPrefetch: (source: AssistantFileLinkSource) => void; + workspaceRoot?: string; +} + +export function AssistantInlineCodePathLink({ + content, + inheritedStyles, + codeInlineStyle, + linkStyle, + onPress, + onPrefetch, + workspaceRoot, +}: AssistantInlineCodePathLinkProps) { + const source = useMemo( + () => ({ + href: content, + text: content, + sourceType: "inline-code", + }), + [content], + ); + + return ( + + {content} + + ); +} + +function getMarkdownLinkTooltipFilePath( + href: string, + workspaceRoot: string | undefined, +): string | null { + const classification = classifyAssistantFileLink(href, { workspaceRoot }); + if (classification?.kind !== "directFile") { + return null; + } + return formatInlinePathTargetForTooltip(classification.target, workspaceRoot); +} + +function formatInlinePathTargetForTooltip( + target: InlinePathTarget, + workspaceRoot: string | undefined, +): string { + let result = relativizePathToWorkspace(target.path, workspaceRoot); + if (target.lineStart) { + result += `:${target.lineStart}`; + if (target.lineEnd && target.lineEnd !== target.lineStart) { + result += `-${target.lineEnd}`; + } + } + return result; +} + +function relativizePathToWorkspace(filePath: string, workspaceRoot: string | undefined): string { + if (!workspaceRoot) { + return filePath; + } + const root = workspaceRoot.replace(/\/+$/, ""); + if (!root) { + return filePath; + } + if (filePath === root) { + return "."; + } + const prefix = `${root}/`; + if (filePath.startsWith(prefix)) { + return filePath.slice(prefix.length); + } + return filePath; +} + +const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = { + // RN doesn't type "inline-flex" but RN-web honors it at runtime, which keeps + // the tooltip wrapper from breaking inline link flow. + display: "inline-flex" as ViewStyle["display"], +}; + +const FILE_LINK_TOOLTIP_MOD_KEYS = ["mod"]; + +function FileLinkHoverTooltip({ filePath, children }: { filePath: string; children: ReactNode }) { + if (!isWeb) { + return children; + } + return ( + + + {children} + + + + + {filePath} + + + + + click for side pane + + + + + + ); +} + +const LINK_ANCHOR_STYLE: CSSProperties = { + display: "contents", + color: "inherit", + textDecoration: "none", +}; + +function preventAnchorNavigation(event: MouseEvent): void { + event.preventDefault(); +} + +function isModifiedOpenEvent(event: MouseEvent): boolean { + return event.metaKey || event.ctrlKey; +} + +const styles = StyleSheet.create((theme) => ({ + tooltipBody: { + gap: theme.spacing[1], + }, + tooltipPath: { + color: theme.colors.foreground, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + }, + tooltipHintRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + tooltipHintText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + }, +})); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 05154d3bb..b4feabf0d 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -68,7 +68,8 @@ import type { AgentAttachment } from "@server/shared/messages"; import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; import { buildToolCallPresentation } from "@/tool-calls/presentation"; import { resolveToolCallIcon } from "@/utils/tool-call-icon"; -import { type InlinePathTarget, parseInlinePathToken } from "@/utils/inline-path"; +import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path"; +import type { OpenFileDisposition } from "@/utils/workspace-file-open"; import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markdown-list"; import { type AssistantFileLinkSource } from "@/utils/assistant-file-link-resolver"; import { useAssistantFileLinkResolver } from "@/hooks/use-assistant-file-link-resolver"; @@ -91,6 +92,11 @@ import { import { PlanCard } from "./plan-card"; import { useToolCallSheet } from "./tool-call-sheet"; import { ToolCallDetailsContent } from "./tool-call-details"; +import { + AssistantInlineCodePathLink, + AssistantMarkdownCodeLink, + AssistantMarkdownLink, +} from "./assistant-file-link"; import { getCompactionMarkerLabel } from "./message-compaction-label"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service"; @@ -709,7 +715,7 @@ export const LiveElapsed = memo(function LiveElapsed({ interface AssistantMessageProps { message: string; timestamp: number; - onInlinePathPress?: (target: InlinePathTarget) => void; + onInlinePathPress?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void; workspaceRoot?: string; serverId?: string; client?: DaemonClient | null; @@ -1001,70 +1007,6 @@ function resolveAssistantImageErrorText(fileError: unknown, dataError: unknown): return "Unable to load image preview."; } -function MarkdownLink({ - source, - style, - onPress, - onPrefetch, - children, -}: { - source: AssistantFileLinkSource; - style: StyleProp; - onPress: (source: AssistantFileLinkSource) => void; - onPrefetch: (source: AssistantFileLinkSource) => void; - children: ReactNode; -}) { - const [hovered, setHovered] = useState(false); - const href = source.href; - const handlePress = useCallback(() => onPress(source), [onPress, source]); - const handlePrefetch = useCallback(() => onPrefetch(source), [onPrefetch, source]); - const handleHoverIn = useCallback(() => { - setHovered(true); - handlePrefetch(); - }, [handlePrefetch]); - const handleHoverOut = useCallback(() => setHovered(false), []); - const hoveredTextStyle = useMemo>( - () => [style, hovered && { textDecorationLine: "underline" as const }], - [style, hovered], - ); - if (isNative) { - return ( - - {children} - - ); - } - - return ( - - - {children} - - - ); -} - -const MARKDOWN_LINK_ANCHOR_STYLE: React.CSSProperties = { - display: "contents", - color: "inherit", - textDecoration: "none", -}; - -function preventAnchorNavigation(event: React.MouseEvent): void { - event.preventDefault(); -} - function getInlineCodeAutoLinkUrl( markdownParser: ReturnType, content: string, @@ -1556,76 +1498,6 @@ function MarkdownInheritedText({ return {children}; } -interface MarkdownInheritedCodeLinkProps { - source: AssistantFileLinkSource; - inheritedStyles: TextStyle; - codeInlineStyle: TextStyle; - linkStyle: TextStyle; - onPress: (source: AssistantFileLinkSource) => void; - onPrefetch: (source: AssistantFileLinkSource) => void; - children: ReactNode; -} - -function MarkdownInheritedCodeLink({ - source, - inheritedStyles, - codeInlineStyle, - linkStyle, - onPress, - onPrefetch, - children, -}: MarkdownInheritedCodeLinkProps) { - const style = useMemo( - () => [inheritedStyles, codeInlineStyle, linkStyle], - [inheritedStyles, codeInlineStyle, linkStyle], - ); - return ( - - {children} - - ); -} - -interface MarkdownInlinePathCodeLinkProps { - content: string; - inheritedStyles: TextStyle; - codeInlineStyle: TextStyle; - linkStyle: TextStyle; - onPress: (source: AssistantFileLinkSource) => void; - onPrefetch: (source: AssistantFileLinkSource) => void; -} - -function MarkdownInlinePathCodeLink({ - content, - inheritedStyles, - codeInlineStyle, - linkStyle, - onPress, - onPrefetch, -}: MarkdownInlinePathCodeLinkProps) { - const source = useMemo( - () => ({ - href: content, - text: content, - sourceType: "inline-code", - }), - [content], - ); - - return ( - - {content} - - ); -} - interface MarkdownListItemContentProps { contentStyle: ViewStyle; children: ReactNode; @@ -1718,8 +1590,8 @@ export const AssistantMessage = memo(function AssistantMessage({ }); const handleLinkPress = useCallback( - (source: AssistantFileLinkSource) => { - fileLinkResolver.open({ source }); + (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => { + fileLinkResolver.open({ source, disposition }); }, [fileLinkResolver], ); @@ -1731,7 +1603,7 @@ export const AssistantMessage = memo(function AssistantMessage({ ); const handleMarkdownLinkPress = useCallback( (url: string) => { - fileLinkResolver.open({ source: { href: url } }); + fileLinkResolver.open({ source: { href: url }, disposition: "main" }); // react-native-markdown-display opens the link itself when this returns true. // We already handled it above, so return false to avoid duplicate opens. return false; @@ -1815,7 +1687,7 @@ export const AssistantMessage = memo(function AssistantMessage({ if (shouldResolveInlinePath) { return ( - ); } @@ -1834,7 +1707,7 @@ export const AssistantMessage = memo(function AssistantMessage({ content, }); return ( - {content} - + ); } @@ -1916,12 +1790,13 @@ export const AssistantMessage = memo(function AssistantMessage({ ), link: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => ( - {Children.map(children, (child) => { if (!isValidElement(child)) return child; @@ -1930,7 +1805,7 @@ export const AssistantMessage = memo(function AssistantMessage({ style: [childProps.style, { color: styles.link.color }], } as Partial<{ style: StyleProp }>); })} - + ), image: ( node: ASTNode, diff --git a/packages/app/src/hooks/use-assistant-file-link-resolver.ts b/packages/app/src/hooks/use-assistant-file-link-resolver.ts index bad89efbf..48071fad6 100644 --- a/packages/app/src/hooks/use-assistant-file-link-resolver.ts +++ b/packages/app/src/hooks/use-assistant-file-link-resolver.ts @@ -4,22 +4,24 @@ import type { ToastApi } from "@/components/toast-host"; import { createAssistantFileLinkResolver, type AssistantFileLinkContext, - type AssistantFileLinkInteraction, + type AssistantFileLinkOpenInput, + type AssistantFileLinkPrefetchInput, } from "@/utils/assistant-file-link-resolver"; import type { InlinePathTarget } from "@/utils/inline-path"; +import type { OpenFileDisposition } from "@/utils/workspace-file-open"; import { openExternalUrl } from "@/utils/open-external-url"; export interface UseAssistantFileLinkResolverOptions { client?: DaemonClient | null; serverId?: string; workspaceRoot?: string; - onOpenWorkspaceFile?: (target: InlinePathTarget) => void; + onOpenWorkspaceFile?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void; toast?: ToastApi | null; } export interface AssistantFileLinkActions { - prefetch(input: Omit): void; - open(input: Omit): void; + prefetch(input: Omit): void; + open(input: Omit): void; } export function useAssistantFileLinkResolver({ @@ -53,8 +55,8 @@ export function useAssistantFileLinkResolver({ error: result.error, }; }, - openWorkspaceFile(target) { - onOpenWorkspaceFile?.(target); + openWorkspaceFile(target, disposition) { + onOpenWorkspaceFile?.(target, disposition); }, openExternalUrl, onUnresolvedFileCandidate(token) { diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index eb2726a30..bdcfaf7b2 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -59,6 +59,7 @@ import type { PendingPermission } from "@/types/shared"; import type { StreamItem } from "@/types/stream"; import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import type { OpenFileDisposition } from "@/utils/workspace-file-open"; import { mergePendingCreateImages } from "@/utils/pending-create-images"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; @@ -288,11 +289,11 @@ function AgentPanel() { const { isInteractive } = usePaneFocus(); invariant(target.kind === "agent", "AgentPanel requires agent target"); - function openWorkspaceFile(input: { filePath: string }) { - openFileInWorkspace(input.filePath); - } - - const handleOpenWorkspaceFile = useStableEvent(openWorkspaceFile); + const handleOpenWorkspaceFile = useStableEvent( + ({ filePath, disposition }: { filePath: string; disposition: OpenFileDisposition }) => { + openFileInWorkspace(filePath, disposition); + }, + ); return ( void; + onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void; }) { const resolvedAgentId = agentId.trim() || undefined; const resolvedServerId = serverId.trim() || undefined; @@ -427,7 +428,7 @@ function AgentPanelBody({ client: NonNullable>; isConnected: boolean; connectionStatus: HostRuntimeConnectionStatus; - onOpenWorkspaceFile?: (input: { filePath: string }) => void; + onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void; }) { const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent(); const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); @@ -592,7 +593,7 @@ function ChatAgentContent({ client: NonNullable>; isConnected: boolean; connectionStatus: HostRuntimeConnectionStatus; - onOpenWorkspaceFile?: (input: { filePath: string }) => void; + onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void; }) { const panelToast = useToastHost(); const { isArchivingAgent } = useArchiveAgent(); @@ -1041,7 +1042,7 @@ function AgentStreamSection({ routeBottomAnchorRequest: RouteBottomAnchorRequest; hasAppliedAuthoritativeHistory: boolean; toast: ReturnType["api"]; - onOpenWorkspaceFile?: (input: { filePath: string }) => void; + onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void; }) { const streamItemsRaw = useSessionStore((state) => agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined, diff --git a/packages/app/src/panels/draft-panel.tsx b/packages/app/src/panels/draft-panel.tsx index fd52edccc..b1dcfa97a 100644 --- a/packages/app/src/panels/draft-panel.tsx +++ b/packages/app/src/panels/draft-panel.tsx @@ -6,6 +6,7 @@ import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import type { PanelRegistration } from "@/panels/panel-registry"; import { useSessionStore } from "@/stores/session-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import type { OpenFileDisposition } from "@/utils/workspace-file-open"; function useDraftPanelDescriptor() { return { @@ -31,8 +32,8 @@ function DraftPanel() { invariant(target.kind === "draft", "DraftPanel requires draft target"); const handleOpenWorkspaceFile = useCallback( - ({ filePath }: { filePath: string }) => { - openFileInWorkspace(filePath); + ({ filePath, disposition }: { filePath: string; disposition: OpenFileDisposition }) => { + openFileInWorkspace(filePath, disposition); }, [openFileInWorkspace], ); diff --git a/packages/app/src/panels/pane-context.tsx b/packages/app/src/panels/pane-context.tsx index 34c5869f5..389dc2363 100644 --- a/packages/app/src/panels/pane-context.tsx +++ b/packages/app/src/panels/pane-context.tsx @@ -1,6 +1,7 @@ import React, { createContext, useContext, type ReactNode } from "react"; import invariant from "tiny-invariant"; import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { OpenFileDisposition } from "@/utils/workspace-file-open"; export interface PaneContextValue { serverId: string; @@ -10,7 +11,7 @@ export interface PaneContextValue { openTab: (target: WorkspaceTabTarget) => void; closeCurrentTab: () => void; retargetCurrentTab: (target: WorkspaceTabTarget) => void; - openFileInWorkspace: (filePath: string) => void; + openFileInWorkspace: (filePath: string, disposition: OpenFileDisposition) => void; openImportSheet: () => void; } diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index 568187a66..087d3e241 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -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; } diff --git a/packages/app/src/screens/workspace/workspace-pane-content.tsx b/packages/app/src/screens/workspace/workspace-pane-content.tsx index cef49ad59..c5a4a233f 100644 --- a/packages/app/src/screens/workspace/workspace-pane-content.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-content.tsx @@ -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; } diff --git a/packages/app/src/screens/workspace/workspace-pane-state.test.ts b/packages/app/src/screens/workspace/workspace-pane-state.test.ts index 80116f0f3..eb36f3b78 100644 --- a/packages/app/src/screens/workspace/workspace-pane-state.test.ts +++ b/packages/app/src/screens/workspace/workspace-pane-state.test.ts @@ -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" }); + }); }); diff --git a/packages/app/src/screens/workspace/workspace-pane-state.ts b/packages/app/src/screens/workspace/workspace-pane-state.ts index 70d092f51..c5beb1fa2 100644 --- a/packages/app/src/screens/workspace/workspace-pane-state.ts +++ b/packages/app/src/screens/workspace/workspace-pane-state.ts @@ -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; @@ -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 }; +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index d055770f5..09ff6d004 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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(null); const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState(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, diff --git a/packages/app/src/utils/assistant-file-link-resolver.test.ts b/packages/app/src/utils/assistant-file-link-resolver.test.ts index 0ac22cbaf..537f36142 100644 --- a/packages/app/src/utils/assistant-file-link-resolver.test.ts +++ b/packages/app/src/utils/assistant-file-link-resolver.test.ts @@ -46,7 +46,7 @@ describe("assistant file link resolver", () => { resolver.prefetch({ context: CONTEXT, source }), resolver.prefetch({ context: CONTEXT, source }), ]); - const result = await resolver.open({ context: CONTEXT, source }); + const result = await resolver.open({ context: CONTEXT, source, disposition: "main" }); expect(suggestions).toHaveBeenCalledTimes(1); expect(suggestions).toHaveBeenCalledWith({ @@ -56,12 +56,15 @@ describe("assistant file link resolver", () => { includeDirectories: false, limit: 1, }); - expect(openWorkspaceFile).toHaveBeenCalledWith({ - raw: "dumm.md", - path: "/Users/test/project/src/dumm.md", - lineStart: undefined, - lineEnd: undefined, - }); + expect(openWorkspaceFile).toHaveBeenCalledWith( + { + raw: "dumm.md", + path: "/Users/test/project/src/dumm.md", + lineStart: undefined, + lineEnd: undefined, + }, + "main", + ); expect(openExternalUrl).not.toHaveBeenCalled(); expect(result.opened).toBe(true); }); @@ -78,19 +81,22 @@ describe("assistant file link resolver", () => { const source = { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" }; const prefetch = resolver.prefetch({ context: CONTEXT, source }); - const opened = resolver.open({ context: CONTEXT, source }); + const opened = resolver.open({ context: CONTEXT, source, disposition: "main" }); deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }])); await prefetch; const result = await opened; expect(suggestions).toHaveBeenCalledTimes(1); - expect(openWorkspaceFile).toHaveBeenCalledWith({ - raw: "dumm.md", - path: "/Users/test/project/docs/dumm.md", - lineStart: undefined, - lineEnd: undefined, - }); + expect(openWorkspaceFile).toHaveBeenCalledWith( + { + raw: "dumm.md", + path: "/Users/test/project/docs/dumm.md", + lineStart: undefined, + lineEnd: undefined, + }, + "main", + ); expect(result.opened).toBe(true); }); @@ -109,19 +115,22 @@ describe("assistant file link resolver", () => { const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" }; const prefetchResult = await resolver.prefetch({ context: CONTEXT, source }); - const openResult = await resolver.open({ context: CONTEXT, source }); + const openResult = await resolver.open({ context: CONTEXT, source, disposition: "main" }); expect(prefetchResult).toEqual({ kind: "unresolvedFileCandidate", token: "dumm.md", }); expect(suggestions).toHaveBeenCalledTimes(2); - expect(openWorkspaceFile).toHaveBeenCalledWith({ - raw: "dumm.md", - path: "/Users/test/project/docs/dumm.md", - lineStart: undefined, - lineEnd: undefined, - }); + expect(openWorkspaceFile).toHaveBeenCalledWith( + { + raw: "dumm.md", + path: "/Users/test/project/docs/dumm.md", + lineStart: undefined, + lineEnd: undefined, + }, + "main", + ); expect(openExternalUrl).not.toHaveBeenCalled(); expect(openResult.opened).toBe(true); }); @@ -142,8 +151,8 @@ describe("assistant file link resolver", () => { }); const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" }; - const first = await resolver.open({ context: CONTEXT, source }); - const second = await resolver.open({ context: CONTEXT, source }); + const first = await resolver.open({ context: CONTEXT, source, disposition: "main" }); + const second = await resolver.open({ context: CONTEXT, source, disposition: "main" }); expect(first).toEqual({ kind: "unresolvedFileCandidate", @@ -152,12 +161,15 @@ describe("assistant file link resolver", () => { }); expect(second.opened).toBe(true); expect(suggestions).toHaveBeenCalledTimes(2); - expect(openWorkspaceFile).toHaveBeenCalledWith({ - raw: "dumm.md", - path: "/Users/test/project/docs/dumm.md", - lineStart: undefined, - lineEnd: undefined, - }); + expect(openWorkspaceFile).toHaveBeenCalledWith( + { + raw: "dumm.md", + path: "/Users/test/project/docs/dumm.md", + lineStart: undefined, + lineEnd: undefined, + }, + "main", + ); expect(openExternalUrl).not.toHaveBeenCalled(); expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1); }); @@ -175,19 +187,23 @@ describe("assistant file link resolver", () => { }); const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" }; - await resolver.open({ context: CONTEXT, source }); + await resolver.open({ context: CONTEXT, source, disposition: "main" }); await resolver.open({ context: { serverId: "server-1", workspaceRoot: "/Users/test/other" }, source, + disposition: "main", }); expect(suggestions).toHaveBeenCalledTimes(2); - expect(openWorkspaceFile).toHaveBeenLastCalledWith({ - raw: "dumm.md", - path: "/Users/test/other/two/dumm.md", - lineStart: undefined, - lineEnd: undefined, - }); + expect(openWorkspaceFile).toHaveBeenLastCalledWith( + { + raw: "dumm.md", + path: "/Users/test/other/two/dumm.md", + lineStart: undefined, + lineEnd: undefined, + }, + "main", + ); }); it("does not apply stale async results after the active context changes", async () => { @@ -204,6 +220,7 @@ describe("assistant file link resolver", () => { const opened = resolver.open({ context: CONTEXT, source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" }, + disposition: "main", }); isCurrent = false; deferred.resolve(resolvedSuggestions([{ path: "dumm.md", kind: "file" }])); @@ -226,15 +243,19 @@ describe("assistant file link resolver", () => { const result = await resolver.open({ context: CONTEXT, source: { href: "src/components/message.tsx#L33" }, + disposition: "main", }); expect(suggestions).not.toHaveBeenCalled(); - expect(openWorkspaceFile).toHaveBeenCalledWith({ - raw: "src/components/message.tsx#L33", - path: "/Users/test/project/src/components/message.tsx", - lineStart: 33, - lineEnd: undefined, - }); + expect(openWorkspaceFile).toHaveBeenCalledWith( + { + raw: "src/components/message.tsx#L33", + path: "/Users/test/project/src/components/message.tsx", + lineStart: 33, + lineEnd: undefined, + }, + "main", + ); expect(result.opened).toBe(true); }); @@ -258,6 +279,7 @@ describe("assistant file link resolver", () => { text: "workspace-git-service.ts:1553", sourceType: "inline-code", }, + disposition: "main", }); expect(suggestions).toHaveBeenCalledWith({ @@ -267,15 +289,43 @@ describe("assistant file link resolver", () => { includeDirectories: false, limit: 1, }); - expect(openWorkspaceFile).toHaveBeenCalledWith({ - raw: "workspace-git-service.ts:1553", - path: "/Users/test/project/packages/server/src/server/workspace-git-service.ts", - lineStart: 1553, - lineEnd: undefined, - }); + expect(openWorkspaceFile).toHaveBeenCalledWith( + { + raw: "workspace-git-service.ts:1553", + path: "/Users/test/project/packages/server/src/server/workspace-git-service.ts", + lineStart: 1553, + lineEnd: undefined, + }, + "main", + ); expect(result.opened).toBe(true); }); + it("passes side open disposition to workspace file links", async () => { + const openWorkspaceFile = vi.fn(); + const resolver = createAssistantFileLinkResolver({ + getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])), + openWorkspaceFile, + openExternalUrl: vi.fn(), + }); + + await resolver.open({ + context: CONTEXT, + source: { href: "src/components/message.tsx#L33" }, + disposition: "side", + }); + + expect(openWorkspaceFile).toHaveBeenCalledWith( + { + raw: "src/components/message.tsx#L33", + path: "/Users/test/project/src/components/message.tsx", + lineStart: 33, + lineEnd: undefined, + }, + "side", + ); + }); + it("keeps explicit external URLs external", async () => { const openExternalUrl = vi.fn(); const resolver = createAssistantFileLinkResolver({ @@ -287,6 +337,7 @@ describe("assistant file link resolver", () => { const result = await resolver.open({ context: CONTEXT, source: { href: "http://dumm.md", text: "dumm.md" }, + disposition: "main", }); expect(openExternalUrl).toHaveBeenCalledWith("http://dumm.md"); @@ -309,6 +360,7 @@ describe("assistant file link resolver", () => { const result = await resolver.open({ context: CONTEXT, source: { href: "http://google.com", text: "google.com", markup: "linkify" }, + disposition: "main", }); expect(suggestions).not.toHaveBeenCalled(); @@ -332,6 +384,7 @@ describe("assistant file link resolver", () => { const result = await resolver.open({ context: CONTEXT, source: { href: "http://openai.com/path", text: "openai.com/path", sourceInfo: "auto" }, + disposition: "main", }); expect(suggestions).not.toHaveBeenCalled(); @@ -361,6 +414,7 @@ describe("assistant file link resolver", () => { const result = await resolver.open({ context: CONTEXT, source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" }, + disposition: "main", }); expect(openWorkspaceFile).not.toHaveBeenCalled(); @@ -393,6 +447,7 @@ describe("assistant file link resolver", () => { const result = await resolver.open({ context: CONTEXT, source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" }, + disposition: "main", }); expect(openExternalUrl).not.toHaveBeenCalled(); diff --git a/packages/app/src/utils/assistant-file-link-resolver.ts b/packages/app/src/utils/assistant-file-link-resolver.ts index bd3dbd83b..91c1f07d7 100644 --- a/packages/app/src/utils/assistant-file-link-resolver.ts +++ b/packages/app/src/utils/assistant-file-link-resolver.ts @@ -4,6 +4,7 @@ import { type AssistantFileLinkClassification, type InlinePathTarget, } from "./inline-path"; +import type { OpenFileDisposition } from "./workspace-file-open"; export interface AssistantFileLinkContext { serverId?: string; @@ -36,22 +37,26 @@ export interface AssistantFileLinkResolverDependencies { includeDirectories: false; limit: number; }) => Promise; - openWorkspaceFile: (target: InlinePathTarget) => void; + openWorkspaceFile: (target: InlinePathTarget, disposition: OpenFileDisposition) => void; openExternalUrl: (url: string) => void | Promise; onUnresolvedFileCandidate?: (token: string) => void; isCurrentContext?: (context: AssistantFileLinkContext) => boolean; } export interface AssistantFileLinkResolver { - prefetch(input: AssistantFileLinkInteraction): Promise; - open(input: AssistantFileLinkInteraction): Promise; + prefetch(input: AssistantFileLinkPrefetchInput): Promise; + open(input: AssistantFileLinkOpenInput): Promise; } -export interface AssistantFileLinkInteraction { +export interface AssistantFileLinkPrefetchInput { context: AssistantFileLinkContext; source: AssistantFileLinkSource; } +export interface AssistantFileLinkOpenInput extends AssistantFileLinkPrefetchInput { + disposition: OpenFileDisposition; +} + export type ResolvedAssistantFileLink = | { kind: "external"; @@ -86,7 +91,9 @@ export function createAssistantFileLinkResolver( const cache = new Map(); const inFlight = new Map>(); - async function resolve(input: AssistantFileLinkInteraction): Promise { + async function resolve( + input: AssistantFileLinkPrefetchInput, + ): Promise { const parsed = parseInteraction(input); if (!parsed) { return { kind: "ignored" }; @@ -144,7 +151,7 @@ export function createAssistantFileLinkResolver( } if (resolved.kind === "file") { - dependencies.openWorkspaceFile(resolved.target); + dependencies.openWorkspaceFile(resolved.target, input.disposition); return { ...resolved, opened: true }; } @@ -174,7 +181,7 @@ export function getAssistantFileLinkToken(source: AssistantFileLinkSource): stri } function parseInteraction( - input: AssistantFileLinkInteraction, + input: AssistantFileLinkPrefetchInput, ): ParsedAssistantFileLinkInteraction | null { const token = getAssistantFileLinkToken(input.source).trim(); if (!token) { diff --git a/packages/app/src/utils/workspace-file-open.ts b/packages/app/src/utils/workspace-file-open.ts new file mode 100644 index 000000000..6dfd8a5d8 --- /dev/null +++ b/packages/app/src/utils/workspace-file-open.ts @@ -0,0 +1 @@ +export type OpenFileDisposition = "main" | "side";