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";