Support line ranges in assistant file links (#1067)

This commit is contained in:
Mohamed Boudra
2026-05-17 22:55:46 +08:00
committed by GitHub
parent efc9c2d345
commit ee44b536f5
29 changed files with 504 additions and 115 deletions
@@ -0,0 +1,13 @@
export {
AssistantInlineCodePathLink,
AssistantInlinePathLink,
AssistantMarkdownCodeLink,
AssistantMarkdownLink,
} from "./link";
export {
classifyAssistantFileLink,
normalizeInlinePathTarget,
type InlinePathTarget,
} from "./parse";
export type { AssistantFileLinkSource } from "./resolver";
export { useAssistantFileLinkResolver } from "./use-resolver";
@@ -16,11 +16,11 @@ import {
} from "react-native"; } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { isNative, isWeb } from "@/constants/platform"; import { isNative, isWeb } from "@/constants/platform";
import { classifyAssistantFileLink, type InlinePathTarget } from "@/utils/inline-path"; import type { OpenFileDisposition } from "@/workspace/file-open";
import type { AssistantFileLinkSource } from "@/utils/assistant-file-link-resolver";
import type { OpenFileDisposition } from "@/utils/workspace-file-open";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { classifyAssistantFileLink, type InlinePathTarget } from "./parse";
import type { AssistantFileLinkSource } from "./resolver";
interface AssistantInlinePathLinkProps { interface AssistantInlinePathLinkProps {
content: string; content: string;
@@ -5,7 +5,7 @@ import {
parseAssistantFileLink, parseAssistantFileLink,
parseFileProtocolUrl, parseFileProtocolUrl,
parseInlinePathToken, parseInlinePathToken,
} from "./inline-path"; } from "./parse";
describe("parseInlinePathToken", () => { describe("parseInlinePathToken", () => {
it("returns null for plain paths without a line number", () => { it("returns null for plain paths without a line number", () => {
@@ -31,6 +31,33 @@ describe("parseInlinePathToken", () => {
}); });
}); });
it("parses filename:line:column as a line target", () => {
expect(parseInlinePathToken("src/app.ts:12:4")).toEqual({
raw: "src/app.ts:12:4",
path: "src/app.ts",
lineStart: 12,
lineEnd: undefined,
});
});
it("parses filename(line,column) as a line target", () => {
expect(parseInlinePathToken("src/app.ts(12,4)")).toEqual({
raw: "src/app.ts(12,4)",
path: "src/app.ts",
lineStart: 12,
lineEnd: undefined,
});
});
it("parses filename lines lineStart-lineEnd", () => {
expect(parseInlinePathToken("src/app.ts lines 12-20")).toEqual({
raw: "src/app.ts lines 12-20",
path: "src/app.ts",
lineStart: 12,
lineEnd: 20,
});
});
it("rejects range-only :line tokens", () => { it("rejects range-only :line tokens", () => {
expect(parseInlinePathToken(":12")).toBeNull(); expect(parseInlinePathToken(":12")).toBeNull();
expect(parseInlinePathToken(":12-20")).toBeNull(); expect(parseInlinePathToken(":12-20")).toBeNull();
@@ -47,6 +74,15 @@ describe("parseFileProtocolUrl", () => {
}); });
}); });
it("parses file URLs with line-column fragments", () => {
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L81C5-L83C2")).toEqual({
raw: "file:///Users/test/project/src/app.tsx#L81C5-L83C2",
path: "/Users/test/project/src/app.tsx",
lineStart: 81,
lineEnd: 83,
});
});
it("parses file URLs without line fragments", () => { it("parses file URLs without line fragments", () => {
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx")).toEqual({ expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx")).toEqual({
raw: "file:///Users/test/project/src/app.tsx", raw: "file:///Users/test/project/src/app.tsx",
@@ -1,4 +1,4 @@
import { isAbsolutePath } from "./path"; import { isAbsolutePath } from "@/utils/path";
export interface InlinePathTarget { export interface InlinePathTarget {
raw: string; raw: string;
@@ -8,7 +8,10 @@ export interface InlinePathTarget {
} }
const FILE_PROTOCOL = "file:"; const FILE_PROTOCOL = "file:";
const INLINE_LINE_FRAGMENT = /^L([0-9]+)(?:-L?([0-9]+))?$/i; const INLINE_LINE_FRAGMENT = /^L([0-9]+)(?:C[0-9]+)?(?:-L?([0-9]+)(?:C[0-9]+)?)?$/i;
const INLINE_COLON_LINE_SUFFIX = /^(.+?):([0-9]+)(?::[0-9]+)?(?:-([0-9]+)(?::[0-9]+)?)?$/;
const INLINE_PAREN_LINE_SUFFIX = /^(.+?)\(([0-9]+)(?:,[0-9]+)?(?:-([0-9]+)(?:,[0-9]+)?)?\)$/;
const INLINE_WORD_LINE_SUFFIX = /^(.+?)\s+lines?\s+([0-9]+)(?:-([0-9]+))?$/i;
const ASSISTANT_FILE_EXTENSIONS = new Set([ const ASSISTANT_FILE_EXTENSIONS = new Set([
"astro", "astro",
"bash", "bash",
@@ -142,7 +145,10 @@ export function parseInlinePathToken(value: string): InlinePathTarget | null {
return null; return null;
} }
const match = trimmed.match(/^(.+?):([0-9]+)(?:-([0-9]+))?$/); const match =
trimmed.match(INLINE_COLON_LINE_SUFFIX) ??
trimmed.match(INLINE_PAREN_LINE_SUFFIX) ??
trimmed.match(INLINE_WORD_LINE_SUFFIX);
if (!match) { if (!match) {
return null; return null;
} }
@@ -4,7 +4,7 @@ import {
getAssistantFileLinkToken, getAssistantFileLinkToken,
type AssistantFileLinkContext, type AssistantFileLinkContext,
type DirectorySuggestionResult, type DirectorySuggestionResult,
} from "./assistant-file-link-resolver"; } from "./resolver";
const CONTEXT: AssistantFileLinkContext = { const CONTEXT: AssistantFileLinkContext = {
serverId: "server-1", serverId: "server-1",
@@ -259,6 +259,31 @@ describe("assistant file link resolver", () => {
expect(result.opened).toBe(true); expect(result.opened).toBe(true);
}); });
it("preserves direct workspace file line ranges", 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:33-40" },
disposition: "main",
});
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "src/components/message.tsx:33-40",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: 40,
},
"main",
);
});
it("resolves inline-code basename line refs through directory suggestions", async () => { it("resolves inline-code basename line refs through directory suggestions", async () => {
const suggestions = vi.fn(async () => const suggestions = vi.fn(async () =>
resolvedSuggestions([ resolvedSuggestions([
@@ -3,8 +3,8 @@ import {
isFileLookingAssistantToken, isFileLookingAssistantToken,
type AssistantFileLinkClassification, type AssistantFileLinkClassification,
type InlinePathTarget, type InlinePathTarget,
} from "./inline-path"; } from "./parse";
import type { OpenFileDisposition } from "./workspace-file-open"; import type { OpenFileDisposition } from "@/workspace/file-open";
export interface AssistantFileLinkContext { export interface AssistantFileLinkContext {
serverId?: string; serverId?: string;
@@ -6,9 +6,9 @@ import {
type AssistantFileLinkContext, type AssistantFileLinkContext,
type AssistantFileLinkOpenInput, type AssistantFileLinkOpenInput,
type AssistantFileLinkPrefetchInput, type AssistantFileLinkPrefetchInput,
} from "@/utils/assistant-file-link-resolver"; } from "./resolver";
import type { InlinePathTarget } from "@/utils/inline-path"; import type { InlinePathTarget } from "./parse";
import type { OpenFileDisposition } from "@/utils/workspace-file-open"; import type { OpenFileDisposition } from "@/workspace/file-open";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
export interface UseAssistantFileLinkResolverOptions { export interface UseAssistantFileLinkResolverOptions {
@@ -74,11 +74,15 @@ import {
type BottomAnchorLocalRequest, type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest, type BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller"; } from "./use-bottom-anchor-controller";
import { normalizeInlinePathTarget } from "@/utils/inline-path"; import { normalizeInlinePathTarget } from "@/assistant-file-links";
import type { OpenFileDisposition } from "@/utils/workspace-file-open"; import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
type OpenFileDisposition,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
@@ -243,7 +247,7 @@ export interface AgentStreamViewProps {
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null; routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean; isAuthoritativeHistoryReady?: boolean;
toast?: ToastApi | null; toast?: ToastApi | null;
onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
} }
const EMPTY_STREAM_HEAD: StreamItem[] = []; const EMPTY_STREAM_HEAD: StreamItem[] = [];
@@ -303,12 +307,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
agentId, agentId,
toast, toast,
}); });
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
filePath: string;
disposition: OpenFileDisposition;
}) {
onOpenWorkspaceFile?.(input);
});
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes // Keep entry/exit animations off on Android due to RN dispatchDraw crashes
// tracked in react-native-reanimated#8422. // tracked in react-native-reanimated#8422.
const shouldDisableEntryExitAnimations = Platform.OS === "android"; const shouldDisableEntryExitAnimations = Platform.OS === "android";
@@ -336,8 +334,20 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
} }
if (normalized.file) { if (normalized.file) {
const location = normalizeWorkspaceFileLocation({
path: normalized.file,
lineStart: target.lineStart,
lineEnd: target.lineEnd,
});
if (!location) {
return;
}
if (onOpenWorkspaceFile) { if (onOpenWorkspaceFile) {
openWorkspaceFile({ filePath: normalized.file, disposition }); onOpenWorkspaceFile({
location,
disposition,
});
return; return;
} }
@@ -345,7 +355,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
navigateToPreparedWorkspaceTab({ navigateToPreparedWorkspaceTab({
serverId: resolvedServerId, serverId: resolvedServerId,
workspaceId, workspaceId,
target: { kind: "file", path: normalized.file }, target: createWorkspaceFileTabTarget(location),
}); });
} }
return; return;
@@ -376,7 +386,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
requestDirectoryListing, requestDirectoryListing,
resolvedServerId, resolvedServerId,
setExplorerTabForCheckout, setExplorerTabForCheckout,
openWorkspaceFile,
workspaceId, workspaceId,
], ],
); );
+81 -11
View File
@@ -1,4 +1,4 @@
import React, { useMemo, useRef } from "react"; import React, { useEffect, useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { FileReadResult } from "@server/client/daemon-client"; import type { FileReadResult } from "@server/client/daemon-client";
import Markdown, { MarkdownIt } from "react-native-markdown-display"; import Markdown, { MarkdownIt } from "react-native-markdown-display";
@@ -26,11 +26,13 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur
import { persistAttachmentFromBytes } from "@/attachments/service"; import { persistAttachmentFromBytes } from "@/attachments/service";
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils"; import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
import { explorerFileFromReadResult } from "@/file-explorer/read-result"; import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
interface CodeLineProps { interface CodeLineProps {
tokens: HighlightToken[]; tokens: HighlightToken[];
lineNumber: number; lineNumber: number;
gutterWidth: number; gutterWidth: number;
highlighted: boolean;
} }
interface FilePreviewBodyProps { interface FilePreviewBodyProps {
@@ -38,7 +40,7 @@ interface FilePreviewBodyProps {
isLoading: boolean; isLoading: boolean;
showDesktopWebScrollbar: boolean; showDesktopWebScrollbar: boolean;
isMobile: boolean; isMobile: boolean;
filePath: string; location: WorkspaceFileLocation;
imagePreviewUri: string | null; imagePreviewUri: string | null;
} }
@@ -50,6 +52,11 @@ function trimNonEmpty(value: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : null; return trimmed.length > 0 ? trimmed : null;
} }
interface FileLineSelection {
lineStart: number;
lineEnd: number;
}
function formatFileSize({ size }: { size: number }): string { function formatFileSize({ size }: { size: number }): string {
if (size < 1024) { if (size < 1024) {
return `${size} B`; return `${size} B`;
@@ -92,14 +99,38 @@ async function createFilePanePreview(file: FileReadResult | null): Promise<{
}; };
} }
const CodeLine = React.memo(function CodeLine({ tokens, lineNumber, gutterWidth }: CodeLineProps) { function clampLineSelection(input: {
lineStart?: number;
lineEnd?: number;
lineCount: number;
}): FileLineSelection | null {
if (!input.lineStart || input.lineStart <= 0 || input.lineCount <= 0) {
return null;
}
const lineStart = Math.min(Math.floor(input.lineStart), input.lineCount);
const rawLineEnd =
input.lineEnd && input.lineEnd >= input.lineStart ? input.lineEnd : input.lineStart;
const lineEnd = Math.min(Math.floor(rawLineEnd), input.lineCount);
return { lineStart, lineEnd: Math.max(lineStart, lineEnd) };
}
const CodeLine = React.memo(function CodeLine({
tokens,
lineNumber,
gutterWidth,
highlighted,
}: CodeLineProps) {
const gutterStyle = useMemo(() => [codeLineStyles.gutter, { width: gutterWidth }], [gutterWidth]); const gutterStyle = useMemo(() => [codeLineStyles.gutter, { width: gutterWidth }], [gutterWidth]);
const lineStyle = useMemo(
() => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine],
[highlighted],
);
const keyedTokens = useMemo( const keyedTokens = useMemo(
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })), () => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
[tokens], [tokens],
); );
return ( return (
<View style={codeLineStyles.line}> <View style={lineStyle}>
<View style={gutterStyle}> <View style={gutterStyle}>
<Text numberOfLines={1} style={codeLineStyles.gutterText}> <Text numberOfLines={1} style={codeLineStyles.gutterText}>
{String(lineNumber)} {String(lineNumber)}
@@ -126,6 +157,9 @@ const codeLineStyles = StyleSheet.create((theme) => ({
line: { line: {
flexDirection: "row", flexDirection: "row",
}, },
highlightedLine: {
backgroundColor: theme.colors.accentBorder,
},
gutter: { gutter: {
alignItems: "flex-end", alignItems: "flex-end",
paddingRight: theme.spacing[3], paddingRight: theme.spacing[3],
@@ -152,13 +186,15 @@ function FilePreviewBody({
isLoading, isLoading,
showDesktopWebScrollbar, showDesktopWebScrollbar,
isMobile, isMobile,
filePath, location,
imagePreviewUri, imagePreviewUri,
}: FilePreviewBodyProps) { }: FilePreviewBodyProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const filePath = location.path;
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []); const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath); const isMarkdownFile =
preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart;
const previewScrollRef = useRef<RNScrollView>(null); const previewScrollRef = useRef<RNScrollView>(null);
const webScrollbarStyle = useWebScrollbarStyle(); const webScrollbarStyle = useWebScrollbarStyle();
@@ -178,12 +214,36 @@ function FilePreviewBody({
if (!highlightedLines) return 0; if (!highlightedLines) return 0;
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.sm); return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.sm);
}, [highlightedLines, theme.fontSize.sm]); }, [highlightedLines, theme.fontSize.sm]);
const lineHeight = theme.fontSize.sm * 1.45;
const lineSelection = useMemo(() => {
if (!highlightedLines) {
return null;
}
return clampLineSelection({
lineStart: location.lineStart,
lineEnd: location.lineEnd,
lineCount: highlightedLines.length,
});
}, [highlightedLines, location.lineEnd, location.lineStart]);
const imageSource = useMemo( const imageSource = useMemo(
() => (imagePreviewUri ? { uri: imagePreviewUri } : null), () => (imagePreviewUri ? { uri: imagePreviewUri } : null),
[imagePreviewUri], [imagePreviewUri],
); );
useEffect(() => {
if (!lineSelection) {
return;
}
const timeout = setTimeout(() => {
previewScrollRef.current?.scrollTo({
y: Math.max(0, (lineSelection.lineStart - 1) * lineHeight),
animated: false,
});
}, 0);
return () => clearTimeout(timeout);
}, [lineHeight, lineSelection]);
if (isLoading && !preview) { if (isLoading && !preview) {
return ( return (
<View style={styles.centerState}> <View style={styles.centerState}>
@@ -233,7 +293,17 @@ function FilePreviewBody({
const codeLines = ( const codeLines = (
<View> <View>
{keyedLines.map(({ key, tokens, lineNumber }) => ( {keyedLines.map(({ key, tokens, lineNumber }) => (
<CodeLine key={key} tokens={tokens} lineNumber={lineNumber} gutterWidth={gutterWidth} /> <CodeLine
key={key}
tokens={tokens}
lineNumber={lineNumber}
gutterWidth={gutterWidth}
highlighted={
Boolean(lineSelection) &&
lineNumber >= (lineSelection?.lineStart ?? 0) &&
lineNumber <= (lineSelection?.lineEnd ?? 0)
}
/>
))} ))}
</View> </View>
); );
@@ -312,18 +382,18 @@ function FilePreviewBody({
export function FilePane({ export function FilePane({
serverId, serverId,
workspaceRoot, workspaceRoot,
filePath, location,
}: { }: {
serverId: string; serverId: string;
workspaceRoot: string; workspaceRoot: string;
filePath: string; location: WorkspaceFileLocation;
}) { }) {
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = isWeb && !isMobile; const showDesktopWebScrollbar = isWeb && !isMobile;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]); const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath]); const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
const query = useQuery({ const query = useQuery({
queryKey: ["workspaceFile", serverId, normalizedWorkspaceRoot, normalizedFilePath], queryKey: ["workspaceFile", serverId, normalizedWorkspaceRoot, normalizedFilePath],
@@ -366,7 +436,7 @@ export function FilePane({
isLoading={query.isFetching} isLoading={query.isFetching}
showDesktopWebScrollbar={showDesktopWebScrollbar} showDesktopWebScrollbar={showDesktopWebScrollbar}
isMobile={isMobile} isMobile={isMobile}
filePath={filePath} location={location}
imagePreviewUri={imagePreviewUri} imagePreviewUri={imagePreviewUri}
/> />
</View> </View>
+12 -7
View File
@@ -68,11 +68,8 @@ import type { AgentAttachment } from "@server/shared/messages";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import { buildToolCallPresentation } from "@/tool-calls/presentation"; import { buildToolCallPresentation } from "@/tool-calls/presentation";
import { resolveToolCallIcon } from "@/utils/tool-call-icon"; import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path"; import type { OpenFileDisposition } from "@/workspace/file-open";
import type { OpenFileDisposition } from "@/utils/workspace-file-open";
import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markdown-list"; 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";
import type { ToastApi } from "@/components/toast-host"; import type { ToastApi } from "@/components/toast-host";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
@@ -95,15 +92,19 @@ import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details"; import { ToolCallDetailsContent } from "./tool-call-details";
import { import {
AssistantInlineCodePathLink, AssistantInlineCodePathLink,
classifyAssistantFileLink,
type AssistantFileLinkSource,
AssistantMarkdownCodeLink, AssistantMarkdownCodeLink,
AssistantMarkdownLink, AssistantMarkdownLink,
} from "./assistant-file-link"; type InlinePathTarget,
useAssistantFileLinkResolver,
} from "@/assistant-file-links";
import { getCompactionMarkerLabel } from "./message-compaction-label"; import { getCompactionMarkerLabel } from "./message-compaction-label";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service"; import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
import type { DaemonClient } from "@server/client/daemon-client"; import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb, isNative } from "@/constants/platform"; import { isWeb, isNative } from "@/constants/platform";
export type { InlinePathTarget } from "@/utils/inline-path"; export type { InlinePathTarget } from "@/assistant-file-links";
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>; type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
@@ -1683,8 +1684,12 @@ export const AssistantMessage = memo(function AssistantMessage({
) => { ) => {
const content = node.content ?? ""; const content = node.content ?? "";
const isLinkedInlineCode = nodeHasParentType(parent, "link"); const isLinkedInlineCode = nodeHasParentType(parent, "link");
const inlineCodeFileLink = classifyAssistantFileLink(content, { workspaceRoot });
const shouldResolveInlinePath = const shouldResolveInlinePath =
onInlinePathPress && !isLinkedInlineCode && parseInlinePathToken(content); onInlinePathPress &&
!isLinkedInlineCode &&
inlineCodeFileLink &&
inlineCodeFileLink.kind !== "external";
if (shouldResolveInlinePath) { if (shouldResolveInlinePath) {
return ( return (
@@ -67,7 +67,7 @@ import {
type WorkspaceLayout, type WorkspaceLayout,
} from "@/stores/workspace-layout-store"; } from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import { workspaceTabTargetsEqual } from "@/utils/workspace-tab-identity"; import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
interface SplitContainerProps { interface SplitContainerProps {
+6 -13
View File
@@ -32,7 +32,6 @@ import {
} from "@/hooks/use-agent-screen-state-machine"; } from "@/hooks/use-agent-screen-state-machine";
import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useStableEvent } from "@/hooks/use-stable-event";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { import {
@@ -59,7 +58,7 @@ import type { PendingPermission } from "@/types/shared";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import type { OpenFileDisposition } from "@/utils/workspace-file-open"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
import { mergePendingCreateImages } from "@/utils/pending-create-images"; import { mergePendingCreateImages } from "@/utils/pending-create-images";
import { navigateToAgent } from "@/utils/navigate-to-agent"; import { navigateToAgent } from "@/utils/navigate-to-agent";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
@@ -289,18 +288,12 @@ function AgentPanel() {
const { isInteractive } = usePaneFocus(); const { isInteractive } = usePaneFocus();
invariant(target.kind === "agent", "AgentPanel requires agent target"); invariant(target.kind === "agent", "AgentPanel requires agent target");
const handleOpenWorkspaceFile = useStableEvent(
({ filePath, disposition }: { filePath: string; disposition: OpenFileDisposition }) => {
openFileInWorkspace(filePath, disposition);
},
);
return ( return (
<AgentPanelContent <AgentPanelContent
serverId={serverId} serverId={serverId}
agentId={target.agentId} agentId={target.agentId}
isPaneFocused={isInteractive} isPaneFocused={isInteractive}
onOpenWorkspaceFile={handleOpenWorkspaceFile} onOpenWorkspaceFile={openFileInWorkspace}
/> />
); );
} }
@@ -366,7 +359,7 @@ function AgentPanelContent({
serverId: string; serverId: string;
agentId: string; agentId: string;
isPaneFocused: boolean; isPaneFocused: boolean;
onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) { }) {
const resolvedAgentId = agentId.trim() || undefined; const resolvedAgentId = agentId.trim() || undefined;
const resolvedServerId = serverId.trim() || undefined; const resolvedServerId = serverId.trim() || undefined;
@@ -428,7 +421,7 @@ function AgentPanelBody({
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>; client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
isConnected: boolean; isConnected: boolean;
connectionStatus: HostRuntimeConnectionStatus; connectionStatus: HostRuntimeConnectionStatus;
onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) { }) {
const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent(); const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent();
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
@@ -593,7 +586,7 @@ function ChatAgentContent({
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>; client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
isConnected: boolean; isConnected: boolean;
connectionStatus: HostRuntimeConnectionStatus; connectionStatus: HostRuntimeConnectionStatus;
onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) { }) {
const panelToast = useToastHost(); const panelToast = useToastHost();
const { isArchivingAgent } = useArchiveAgent(); const { isArchivingAgent } = useArchiveAgent();
@@ -1042,7 +1035,7 @@ function AgentStreamSection({
routeBottomAnchorRequest: RouteBottomAnchorRequest; routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean; hasAppliedAuthoritativeHistory: boolean;
toast: ReturnType<typeof useToastHost>["api"]; toast: ReturnType<typeof useToastHost>["api"];
onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) { }) {
const streamItemsRaw = useSessionStore((state) => const streamItemsRaw = useSessionStore((state) =>
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined, agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
+1 -9
View File
@@ -6,7 +6,6 @@ import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelRegistration } from "@/panels/panel-registry"; import type { PanelRegistration } from "@/panels/panel-registry";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import type { OpenFileDisposition } from "@/utils/workspace-file-open";
function useDraftPanelDescriptor() { function useDraftPanelDescriptor() {
return { return {
@@ -31,13 +30,6 @@ function DraftPanel() {
const { isInteractive } = usePaneFocus(); const { isInteractive } = usePaneFocus();
invariant(target.kind === "draft", "DraftPanel requires draft target"); invariant(target.kind === "draft", "DraftPanel requires draft target");
const handleOpenWorkspaceFile = useCallback(
({ filePath, disposition }: { filePath: string; disposition: OpenFileDisposition }) => {
openFileInWorkspace(filePath, disposition);
},
[openFileInWorkspace],
);
const handleCreated = useCallback( const handleCreated = useCallback(
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[0]) => { (agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[0]) => {
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId); const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
@@ -59,7 +51,7 @@ function DraftPanel() {
draftId={target.draftId} draftId={target.draftId}
initialSetup={target.setup} initialSetup={target.setup}
isPaneFocused={isInteractive} isPaneFocused={isInteractive}
onOpenWorkspaceFile={handleOpenWorkspaceFile} onOpenWorkspaceFile={openFileInWorkspace}
onCreated={handleCreated} onCreated={handleCreated}
onOpenImportSheet={openImportSheet} onOpenImportSheet={openImportSheet}
/> />
+1 -1
View File
@@ -38,7 +38,7 @@ function FilePanel() {
</View> </View>
); );
} }
return <FilePane serverId={serverId} workspaceRoot={workspaceDirectory} filePath={target.path} />; return <FilePane serverId={serverId} workspaceRoot={workspaceDirectory} location={target} />;
} }
export const filePanelRegistration: PanelRegistration<"file"> = { export const filePanelRegistration: PanelRegistration<"file"> = {
+2 -2
View File
@@ -1,7 +1,7 @@
import React, { createContext, useContext, type ReactNode } from "react"; import React, { createContext, useContext, type ReactNode } from "react";
import invariant from "tiny-invariant"; import invariant from "tiny-invariant";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import type { OpenFileDisposition } from "@/utils/workspace-file-open"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
export interface PaneContextValue { export interface PaneContextValue {
serverId: string; serverId: string;
@@ -11,7 +11,7 @@ export interface PaneContextValue {
openTab: (target: WorkspaceTabTarget) => void; openTab: (target: WorkspaceTabTarget) => void;
closeCurrentTab: () => void; closeCurrentTab: () => void;
retargetCurrentTab: (target: WorkspaceTabTarget) => void; retargetCurrentTab: (target: WorkspaceTabTarget) => void;
openFileInWorkspace: (filePath: string, disposition: OpenFileDisposition) => void; openFileInWorkspace: (request: WorkspaceFileOpenRequest) => void;
openImportSheet: () => void; openImportSheet: () => void;
} }
@@ -20,7 +20,7 @@ import type { Agent } from "@/stores/session-store";
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks"; import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { encodeImages } from "@/utils/encode-images"; import { encodeImages } from "@/utils/encode-images";
import type { OpenFileDisposition } from "@/utils/workspace-file-open"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus"; import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types"; import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
import type { AgentSnapshotPayload } from "@server/shared/messages"; import type { AgentSnapshotPayload } from "@server/shared/messages";
@@ -319,7 +319,7 @@ interface WorkspaceDraftAgentTabProps {
initialSetup?: WorkspaceDraftTabSetup; initialSetup?: WorkspaceDraftTabSetup;
isPaneFocused: boolean; isPaneFocused: boolean;
onCreated: (snapshot: AgentSnapshotPayload) => void; onCreated: (snapshot: AgentSnapshotPayload) => void;
onOpenWorkspaceFile: (input: { filePath: string; disposition: OpenFileDisposition }) => void; onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void;
onOpenImportSheet?: () => void; onOpenImportSheet?: () => void;
} }
@@ -9,7 +9,7 @@ import {
import { getPanelRegistration } from "@/panels/panel-registry"; import { getPanelRegistration } from "@/panels/panel-registry";
import { ensurePanelsRegistered } from "@/panels/register-panels"; import { ensurePanelsRegistered } from "@/panels/register-panels";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { OpenFileDisposition } from "@/utils/workspace-file-open"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
export interface WorkspacePaneContentModel { export interface WorkspacePaneContentModel {
key: string; key: string;
@@ -24,7 +24,7 @@ export interface BuildWorkspacePaneContentModelInput {
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void; onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
onCloseCurrentTab: () => void; onCloseCurrentTab: () => void;
onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void; onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void;
onOpenWorkspaceFile: (filePath: string, disposition: OpenFileDisposition) => void; onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void;
onOpenImportSheet: () => void; onOpenImportSheet: () => void;
} }
@@ -130,6 +130,30 @@ describe("workspace-pane-state", () => {
).toEqual({ kind: "open-in-source" }); ).toEqual({ kind: "open-in-source" });
}); });
it("resolves side file opens to an existing file tab when only the line range differs", () => {
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", lineStart: 12, lineEnd: 20 },
}),
).toEqual({ kind: "open-in-source" });
});
it("resolves side file opens to an existing right pane", () => { it("resolves side file opens to an existing right pane", () => {
const layout: WorkspaceLayout = { const layout: WorkspaceLayout = {
root: { root: {
@@ -9,7 +9,7 @@ import {
buildDeterministicWorkspaceTabId, buildDeterministicWorkspaceTabId,
normalizeWorkspaceTabTarget, normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual, workspaceTabTargetsEqual,
} from "@/utils/workspace-tab-identity"; } from "@/workspace-tabs/identity";
import { findAdjacentPane } from "@/utils/split-navigation"; import { findAdjacentPane } from "@/utils/split-navigation";
export interface WorkspaceDerivedTab { export interface WorkspaceDerivedTab {
@@ -209,7 +209,10 @@ export function resolveSideFileOpenPlacement(input: {
tabs: WorkspaceTab[]; tabs: WorkspaceTab[];
target: WorkspaceTabTarget; target: WorkspaceTabTarget;
}): WorkspaceSideFileOpenPlacement { }): WorkspaceSideFileOpenPlacement {
const existingTab = input.tabs.find((tab) => workspaceTabTargetsEqual(tab.target, input.target)); const targetTabId = buildDeterministicWorkspaceTabId(input.target);
const existingTab = input.tabs.find(
(tab) => tab.tabId === targetTabId || workspaceTabTargetsEqual(tab.target, input.target),
);
if (existingTab) { if (existingTab) {
return { kind: "open-in-source" }; return { kind: "open-in-source" };
} }
@@ -77,10 +77,7 @@ import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-s
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useCreateFlowStore } from "@/stores/create-flow-store";
import { import { normalizeWorkspaceTabTarget, workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual,
} from "@/utils/workspace-tab-identity";
import { import {
getHostRuntimeStore, getHostRuntimeStore,
useHostRuntimeClient, useHostRuntimeClient,
@@ -161,6 +158,12 @@ import { useContainerWidthBelow } from "@/hooks/use-container-width";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state"; import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state";
import { useWorkspaceTerminals } from "@/screens/workspace/terminals/use-workspace-terminals"; import { useWorkspaceTerminals } from "@/screens/workspace/terminals/use-workspace-terminals";
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
type WorkspaceFileLocation,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000; const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
const EMPTY_UI_TABS: WorkspaceTab[] = []; const EMPTY_UI_TABS: WorkspaceTab[] = [];
@@ -2023,7 +2026,11 @@ function WorkspaceScreenContent({
if (!persistenceKey) { if (!persistenceKey) {
return; return;
} }
const tabId = openWorkspaceTabFocused(persistenceKey, { kind: "file", path: filePath }); const location = normalizeWorkspaceFileLocation({ path: filePath });
if (!location) {
return;
}
const tabId = openWorkspaceTabFocused(persistenceKey, createWorkspaceFileTabTarget(location));
if (tabId) { if (tabId) {
navigateToTabId(tabId); navigateToTabId(tabId);
} }
@@ -2032,28 +2039,40 @@ function WorkspaceScreenContent({
); );
const handleOpenFileFromChat = useCallback( const handleOpenFileFromChat = useCallback(
(filePath: string) => { (location: WorkspaceFileLocation) => {
const normalizedFilePath = filePath.trim(); const normalizedLocation = normalizeWorkspaceFileLocation(location);
if (!normalizedFilePath) { if (!normalizedLocation) {
return; return;
} }
handleOpenFileFromExplorer(normalizedFilePath); if (isMobile) {
showMobileAgent();
}
if (!persistenceKey) {
return;
}
const tabId = openWorkspaceTabFocused(
persistenceKey,
createWorkspaceFileTabTarget(normalizedLocation),
);
if (tabId) {
navigateToTabId(tabId);
}
}, },
[handleOpenFileFromExplorer], [isMobile, navigateToTabId, openWorkspaceTabFocused, persistenceKey, showMobileAgent],
); );
const handleOpenFileFromChatInSidePane = useCallback( const handleOpenFileFromChatInSidePane = useCallback(
(input: { filePath: string; sourcePaneId?: string }) => { (input: { location: WorkspaceFileLocation; sourcePaneId?: string }) => {
const normalizedFilePath = input.filePath.trim(); const location = normalizeWorkspaceFileLocation(input.location);
if (!normalizedFilePath) { if (!location) {
return; return;
} }
if (!persistenceKey || isMobile || !input.sourcePaneId) { if (!persistenceKey || isMobile || !input.sourcePaneId) {
handleOpenFileFromExplorer(normalizedFilePath); handleOpenFileFromChat(location);
return; return;
} }
const target: WorkspaceTabTarget = { kind: "file", path: normalizedFilePath }; const target: WorkspaceTabTarget = createWorkspaceFileTabTarget(location);
const placement = resolveSideFileOpenPlacement({ const placement = resolveSideFileOpenPlacement({
layout: workspaceLayout, layout: workspaceLayout,
sourcePaneId: input.sourcePaneId, sourcePaneId: input.sourcePaneId,
@@ -2075,7 +2094,7 @@ function WorkspaceScreenContent({
} }
}, },
[ [
handleOpenFileFromExplorer, handleOpenFileFromChat,
isMobile, isMobile,
focusWorkspacePane, focusWorkspacePane,
navigateToTabId, navigateToTabId,
@@ -2755,18 +2774,18 @@ function WorkspaceScreenContent({
} }
retargetWorkspaceTab(persistenceKey, input.tab.tabId, target); retargetWorkspaceTab(persistenceKey, input.tab.tabId, target);
}, },
onOpenWorkspaceFile: (filePath, disposition) => { onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => {
if (input.focusPaneBeforeOpen && input.paneId && persistenceKey) { if (input.focusPaneBeforeOpen && input.paneId && persistenceKey) {
focusWorkspacePane(persistenceKey, input.paneId); focusWorkspacePane(persistenceKey, input.paneId);
} }
if (disposition === "side") { if (request.disposition === "side") {
handleOpenFileFromChatInSidePane({ handleOpenFileFromChatInSidePane({
filePath, location: request.location,
sourcePaneId: input.paneId ?? undefined, sourcePaneId: input.paneId ?? undefined,
}); });
return; return;
} }
handleOpenFileFromChat(filePath); handleOpenFileFromChat(request.location);
}, },
onOpenImportSheet: openImportSheet, onOpenImportSheet: openImportSheet,
}), }),
@@ -3,7 +3,7 @@ import {
deriveWorkspacePaneState, deriveWorkspacePaneState,
type WorkspaceDerivedTab, type WorkspaceDerivedTab,
} from "@/screens/workspace/workspace-pane-state"; } from "@/screens/workspace/workspace-pane-state";
import { buildDeterministicWorkspaceTabId } from "@/utils/workspace-tab-identity"; import { buildDeterministicWorkspaceTabId } from "@/workspace-tabs/identity";
export interface WorkspaceTabModel { export interface WorkspaceTabModel {
tabs: WorkspaceDerivedTab[]; tabs: WorkspaceDerivedTab[];
@@ -6,7 +6,7 @@ import {
buildDeterministicWorkspaceTabId, buildDeterministicWorkspaceTabId,
normalizeWorkspaceTabTarget, normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual, workspaceTabTargetsEqual,
} from "@/utils/workspace-tab-identity"; } from "@/workspace-tabs/identity";
export interface SplitPane { export interface SplitPane {
id: string; id: string;
@@ -1051,19 +1051,45 @@ function insertNewTabIntoFocusedPane(input: {
}; };
} }
function findExistingTabForTarget(root: SplitNodeInternal, target: WorkspaceTabTarget) {
const targetTabId = buildDeterministicWorkspaceTabId(target);
return (
collectAllTabs(root).find(
(tab) => tab.tabId === targetTabId || workspaceTabTargetsEqual(tab.target, target),
) ?? null
);
}
function updateExistingTabTarget(
layout: { root: SplitNodeInternal; focusedPaneId: string | null },
tab: WorkspaceTab,
target: WorkspaceTabTarget,
): { root: SplitNodeInternal; focusedPaneId: string | null } {
if (workspaceTabTargetsEqual(tab.target, target)) {
return layout;
}
return {
...layout,
root: replaceTabInTree(layout.root, {
tabId: tab.tabId,
nextTabId: tab.tabId,
target,
}),
};
}
export function openTabInLayoutFocused(input: OpenTabInLayoutInput): OpenTabInLayoutResult { export function openTabInLayoutFocused(input: OpenTabInLayoutInput): OpenTabInLayoutResult {
const layout = asInternalLayout(input.layout); const layout = asInternalLayout(input.layout);
const existingTab = collectAllTabs(layout.root).find((tab) => const existingTab = findExistingTabForTarget(layout.root, input.target);
workspaceTabTargetsEqual(tab.target, input.target),
);
if (existingTab) { if (existingTab) {
const nextLayout = updateExistingTabTarget(layout, existingTab, input.target);
return { return {
tabId: existingTab.tabId, tabId: existingTab.tabId,
layout: layout:
focusTabInLayout({ focusTabInLayout({
layout, layout: nextLayout,
tabId: existingTab.tabId, tabId: existingTab.tabId,
}) ?? input.layout, }) ?? nextLayout,
}; };
} }
@@ -1072,11 +1098,12 @@ export function openTabInLayoutFocused(input: OpenTabInLayoutInput): OpenTabInLa
export function openTabInLayoutBackground(input: OpenTabInLayoutInput): OpenTabInLayoutResult { export function openTabInLayoutBackground(input: OpenTabInLayoutInput): OpenTabInLayoutResult {
const layout = asInternalLayout(input.layout); const layout = asInternalLayout(input.layout);
const existingTab = collectAllTabs(layout.root).find((tab) => const existingTab = findExistingTabForTarget(layout.root, input.target);
workspaceTabTargetsEqual(tab.target, input.target),
);
if (existingTab) { if (existingTab) {
return { tabId: existingTab.tabId, layout: input.layout }; return {
tabId: existingTab.tabId,
layout: updateExistingTabTarget(layout, existingTab, input.target),
};
} }
return insertNewTabIntoFocusedPane({ ...input, focus: false }); return insertNewTabIntoFocusedPane({ ...input, focus: false });
@@ -353,6 +353,39 @@ describe("workspace-layout-store actions", () => {
]); ]);
}); });
it("updates an existing file tab when opening the same path at a new line range", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, {
kind: "file",
path: "/repo/worktree/a.ts",
lineStart: 5,
});
const secondTabId = store.openTabFocused(workspaceKey, {
kind: "file",
path: "/repo/worktree/a.ts",
lineStart: 10,
lineEnd: 12,
});
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(firstTabId).toBe("file_/repo/worktree/a.ts");
expect(secondTabId).toBe(firstTabId);
expect(collectAllTabs(layout.root)).toEqual([
{
tabId: "file_/repo/worktree/a.ts",
target: {
kind: "file",
path: "/repo/worktree/a.ts",
lineStart: 10,
lineEnd: 12,
},
createdAt: expect.any(Number),
},
]);
});
it("openTabInBackground inserts a tab without stealing focus", () => { it("openTabInBackground inserts a tab without stealing focus", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState(); const store = workspaceLayoutStore.getState();
@@ -44,7 +44,7 @@ import {
type WorkspaceTabSnapshot, type WorkspaceTabSnapshot,
type WorkspaceLayout, type WorkspaceLayout,
} from "@/stores/workspace-layout-actions"; } from "@/stores/workspace-layout-actions";
import { normalizeWorkspaceTabTarget } from "@/utils/workspace-tab-identity"; import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity";
export { buildWorkspaceTabPersistenceKey }; export { buildWorkspaceTabPersistenceKey };
export { export {
@@ -7,7 +7,8 @@ import {
normalizeWorkspaceDraftTabSetup, normalizeWorkspaceDraftTabSetup,
normalizeWorkspaceTabTarget, normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual, workspaceTabTargetsEqual,
} from "@/utils/workspace-tab-identity"; } from "@/workspace-tabs/identity";
import type { WorkspaceFileTabTarget } from "@/workspace/file-open";
export interface WorkspaceDraftTabSetup { export interface WorkspaceDraftTabSetup {
provider: AgentProvider; provider: AgentProvider;
@@ -23,7 +24,7 @@ export type WorkspaceTabTarget =
| { kind: "agent"; agentId: string } | { kind: "agent"; agentId: string }
| { kind: "terminal"; terminalId: string } | { kind: "terminal"; terminalId: string }
| { kind: "browser"; browserId: string } | { kind: "browser"; browserId: string }
| { kind: "file"; path: string } | WorkspaceFileTabTarget
| { kind: "setup"; workspaceId: string }; | { kind: "setup"; workspaceId: string };
export interface WorkspaceTab { export interface WorkspaceTab {
@@ -168,7 +169,12 @@ function coerceWorkspaceTabTarget(raw: Record<string, unknown>): WorkspaceTabTar
return normalizeWorkspaceTabTarget({ kind: "browser", browserId: raw.browserId }); return normalizeWorkspaceTabTarget({ kind: "browser", browserId: raw.browserId });
} }
if (kind === "file" && typeof raw.path === "string") { if (kind === "file" && typeof raw.path === "string") {
return normalizeWorkspaceTabTarget({ kind: "file", path: raw.path }); return normalizeWorkspaceTabTarget({
kind: "file",
path: raw.path,
lineStart: typeof raw.lineStart === "number" ? raw.lineStart : undefined,
lineEnd: typeof raw.lineEnd === "number" ? raw.lineEnd : undefined,
});
} }
if (kind === "setup" && typeof raw.workspaceId === "string") { if (kind === "setup" && typeof raw.workspaceId === "string") {
return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId }); return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId });
@@ -1 +0,0 @@
export type OpenFileDisposition = "main" | "side";
@@ -1,4 +1,5 @@
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { normalizeWorkspaceFileLocation, workspaceFileLocationsEqual } from "@/workspace/file-open";
type WorkspaceDraftTabSetup = NonNullable<Extract<WorkspaceTabTarget, { kind: "draft" }>["setup"]>; type WorkspaceDraftTabSetup = NonNullable<Extract<WorkspaceTabTarget, { kind: "draft" }>["setup"]>;
@@ -29,8 +30,7 @@ export function normalizeWorkspaceTabTarget(
return browserId ? { kind: "browser", browserId } : null; return browserId ? { kind: "browser", browserId } : null;
} }
if (value.kind === "file") { if (value.kind === "file") {
const path = trimNonEmpty(value.path); return normalizeFileTabTarget(value);
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
} }
if (value.kind === "setup") { if (value.kind === "setup") {
const workspaceId = trimNonEmpty(value.workspaceId); const workspaceId = trimNonEmpty(value.workspaceId);
@@ -83,7 +83,7 @@ export function workspaceTabTargetsEqual(
return left.browserId === right.browserId; return left.browserId === right.browserId;
} }
if (left.kind === "file" && right.kind === "file") { if (left.kind === "file" && right.kind === "file") {
return left.path === right.path; return workspaceFileLocationsEqual(left, right);
} }
if (left.kind === "setup" && right.kind === "setup") { if (left.kind === "setup" && right.kind === "setup") {
return left.workspaceId === right.workspaceId; return left.workspaceId === right.workspaceId;
@@ -151,6 +151,13 @@ function trimNonEmpty(value: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : null; return trimmed.length > 0 ? trimmed : null;
} }
function normalizeFileTabTarget(
value: Extract<WorkspaceTabTarget, { kind: "file" }>,
): WorkspaceTabTarget | null {
const location = normalizeWorkspaceFileLocation(value);
return location ? { kind: "file", ...location } : null;
}
function trimOptionalString(value: string | null | undefined): string | null { function trimOptionalString(value: string | null | undefined): string | null {
return value == null ? null : trimNonEmpty(value); return value == null ? null : trimNonEmpty(value);
} }
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
workspaceFileLocationsEqual,
} from ".";
describe("normalizeWorkspaceFileLocation", () => {
it("normalizes paths and valid line ranges", () => {
expect(
normalizeWorkspaceFileLocation({
path: "src\\app.ts",
lineStart: 12.8,
lineEnd: 20.2,
}),
).toEqual({
path: "src/app.ts",
lineStart: 12,
lineEnd: 20,
});
});
it("drops invalid or backwards line ranges", () => {
expect(normalizeWorkspaceFileLocation({ path: "src/app.ts", lineStart: -1 })).toEqual({
path: "src/app.ts",
});
expect(
normalizeWorkspaceFileLocation({ path: "src/app.ts", lineStart: 20, lineEnd: 12 }),
).toEqual({
path: "src/app.ts",
lineStart: 20,
});
});
it("rejects empty paths", () => {
expect(normalizeWorkspaceFileLocation({ path: " " })).toBeNull();
});
});
describe("workspace file tab targets", () => {
it("keeps file tab identity separate from line selection", () => {
expect(createWorkspaceFileTabTarget({ path: "src/app.ts", lineStart: 12 })).toEqual({
kind: "file",
path: "src/app.ts",
lineStart: 12,
});
});
it("compares full location equality", () => {
expect(
workspaceFileLocationsEqual(
{ path: "src/app.ts", lineStart: 12 },
{ path: "src/app.ts", lineStart: 12 },
),
).toBe(true);
expect(
workspaceFileLocationsEqual(
{ path: "src/app.ts", lineStart: 12 },
{ path: "src/app.ts", lineStart: 13 },
),
).toBe(false);
});
});
@@ -0,0 +1,59 @@
export type OpenFileDisposition = "main" | "side";
export interface WorkspaceFileLocation {
path: string;
lineStart?: number;
lineEnd?: number;
}
export type WorkspaceFileTabTarget = { kind: "file" } & WorkspaceFileLocation;
export interface WorkspaceFileOpenRequest {
location: WorkspaceFileLocation;
disposition: OpenFileDisposition;
}
export function normalizeWorkspaceFileLocation(
location: WorkspaceFileLocation | null | undefined,
): WorkspaceFileLocation | null {
if (!location) {
return null;
}
const path = location.path.trim().replace(/\\/g, "/");
if (!path) {
return null;
}
const lineStart = normalizeLineNumber(location.lineStart);
const lineEnd = normalizeLineNumber(location.lineEnd);
return {
path,
...(lineStart ? { lineStart } : {}),
...(lineStart && lineEnd && lineEnd >= lineStart ? { lineEnd } : {}),
};
}
export function workspaceFileLocationsEqual(
left: WorkspaceFileLocation,
right: WorkspaceFileLocation,
): boolean {
return (
left.path === right.path && left.lineStart === right.lineStart && left.lineEnd === right.lineEnd
);
}
export function createWorkspaceFileTabTarget(
location: WorkspaceFileLocation,
): WorkspaceFileTabTarget {
return {
kind: "file",
...location,
};
}
function normalizeLineNumber(value: number | null | undefined): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.floor(value)
: undefined;
}