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

View File

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

View File

@@ -16,11 +16,11 @@ import {
} 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 type { OpenFileDisposition } from "@/workspace/file-open";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { classifyAssistantFileLink, type InlinePathTarget } from "./parse";
import type { AssistantFileLinkSource } from "./resolver";
interface AssistantInlinePathLinkProps {
content: string;

View File

@@ -5,7 +5,7 @@ import {
parseAssistantFileLink,
parseFileProtocolUrl,
parseInlinePathToken,
} from "./inline-path";
} from "./parse";
describe("parseInlinePathToken", () => {
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", () => {
expect(parseInlinePathToken(":12")).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", () => {
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx")).toEqual({
raw: "file:///Users/test/project/src/app.tsx",

View File

@@ -1,4 +1,4 @@
import { isAbsolutePath } from "./path";
import { isAbsolutePath } from "@/utils/path";
export interface InlinePathTarget {
raw: string;
@@ -8,7 +8,10 @@ export interface InlinePathTarget {
}
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([
"astro",
"bash",
@@ -142,7 +145,10 @@ export function parseInlinePathToken(value: string): InlinePathTarget | 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) {
return null;
}

View File

@@ -4,7 +4,7 @@ import {
getAssistantFileLinkToken,
type AssistantFileLinkContext,
type DirectorySuggestionResult,
} from "./assistant-file-link-resolver";
} from "./resolver";
const CONTEXT: AssistantFileLinkContext = {
serverId: "server-1",
@@ -259,6 +259,31 @@ describe("assistant file link resolver", () => {
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 () => {
const suggestions = vi.fn(async () =>
resolvedSuggestions([

View File

@@ -3,8 +3,8 @@ import {
isFileLookingAssistantToken,
type AssistantFileLinkClassification,
type InlinePathTarget,
} from "./inline-path";
import type { OpenFileDisposition } from "./workspace-file-open";
} from "./parse";
import type { OpenFileDisposition } from "@/workspace/file-open";
export interface AssistantFileLinkContext {
serverId?: string;

View File

@@ -6,9 +6,9 @@ import {
type AssistantFileLinkContext,
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";
} from "./resolver";
import type { InlinePathTarget } from "./parse";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { openExternalUrl } from "@/utils/open-external-url";
export interface UseAssistantFileLinkResolverOptions {

View File

@@ -74,11 +74,15 @@ import {
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller";
import { normalizeInlinePathTarget } from "@/utils/inline-path";
import type { OpenFileDisposition } from "@/utils/workspace-file-open";
import { normalizeInlinePathTarget } from "@/assistant-file-links";
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
type OpenFileDisposition,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
@@ -243,7 +247,7 @@ export interface AgentStreamViewProps {
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean;
toast?: ToastApi | null;
onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}
const EMPTY_STREAM_HEAD: StreamItem[] = [];
@@ -303,12 +307,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
agentId,
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
// tracked in react-native-reanimated#8422.
const shouldDisableEntryExitAnimations = Platform.OS === "android";
@@ -336,8 +334,20 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}
if (normalized.file) {
const location = normalizeWorkspaceFileLocation({
path: normalized.file,
lineStart: target.lineStart,
lineEnd: target.lineEnd,
});
if (!location) {
return;
}
if (onOpenWorkspaceFile) {
openWorkspaceFile({ filePath: normalized.file, disposition });
onOpenWorkspaceFile({
location,
disposition,
});
return;
}
@@ -345,7 +355,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
navigateToPreparedWorkspaceTab({
serverId: resolvedServerId,
workspaceId,
target: { kind: "file", path: normalized.file },
target: createWorkspaceFileTabTarget(location),
});
}
return;
@@ -376,7 +386,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
requestDirectoryListing,
resolvedServerId,
setExplorerTabForCheckout,
openWorkspaceFile,
workspaceId,
],
);

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 type { FileReadResult } from "@server/client/daemon-client";
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 { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
interface CodeLineProps {
tokens: HighlightToken[];
lineNumber: number;
gutterWidth: number;
highlighted: boolean;
}
interface FilePreviewBodyProps {
@@ -38,7 +40,7 @@ interface FilePreviewBodyProps {
isLoading: boolean;
showDesktopWebScrollbar: boolean;
isMobile: boolean;
filePath: string;
location: WorkspaceFileLocation;
imagePreviewUri: string | null;
}
@@ -50,6 +52,11 @@ function trimNonEmpty(value: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : null;
}
interface FileLineSelection {
lineStart: number;
lineEnd: number;
}
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
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 lineStyle = useMemo(
() => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine],
[highlighted],
);
const keyedTokens = useMemo(
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
[tokens],
);
return (
<View style={codeLineStyles.line}>
<View style={lineStyle}>
<View style={gutterStyle}>
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
{String(lineNumber)}
@@ -126,6 +157,9 @@ const codeLineStyles = StyleSheet.create((theme) => ({
line: {
flexDirection: "row",
},
highlightedLine: {
backgroundColor: theme.colors.accentBorder,
},
gutter: {
alignItems: "flex-end",
paddingRight: theme.spacing[3],
@@ -152,13 +186,15 @@ function FilePreviewBody({
isLoading,
showDesktopWebScrollbar,
isMobile,
filePath,
location,
imagePreviewUri,
}: FilePreviewBodyProps) {
const { theme } = useUnistyles();
const filePath = location.path;
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
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 webScrollbarStyle = useWebScrollbarStyle();
@@ -178,12 +214,36 @@ function FilePreviewBody({
if (!highlightedLines) return 0;
return lineNumberGutterWidth(highlightedLines.length, 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(
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
[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) {
return (
<View style={styles.centerState}>
@@ -233,7 +293,17 @@ function FilePreviewBody({
const codeLines = (
<View>
{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>
);
@@ -312,18 +382,18 @@ function FilePreviewBody({
export function FilePane({
serverId,
workspaceRoot,
filePath,
location,
}: {
serverId: string;
workspaceRoot: string;
filePath: string;
location: WorkspaceFileLocation;
}) {
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = isWeb && !isMobile;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath]);
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
const query = useQuery({
queryKey: ["workspaceFile", serverId, normalizedWorkspaceRoot, normalizedFilePath],
@@ -366,7 +436,7 @@ export function FilePane({
isLoading={query.isFetching}
showDesktopWebScrollbar={showDesktopWebScrollbar}
isMobile={isMobile}
filePath={filePath}
location={location}
imagePreviewUri={imagePreviewUri}
/>
</View>

View File

@@ -68,11 +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 { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
import type { OpenFileDisposition } from "@/utils/workspace-file-open";
import type { OpenFileDisposition } from "@/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";
import type { ToastApi } from "@/components/toast-host";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
@@ -95,15 +92,19 @@ import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details";
import {
AssistantInlineCodePathLink,
classifyAssistantFileLink,
type AssistantFileLinkSource,
AssistantMarkdownCodeLink,
AssistantMarkdownLink,
} from "./assistant-file-link";
type InlinePathTarget,
useAssistantFileLinkResolver,
} from "@/assistant-file-links";
import { getCompactionMarkerLabel } from "./message-compaction-label";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
import type { DaemonClient } from "@server/client/daemon-client";
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 }>;
@@ -1683,8 +1684,12 @@ export const AssistantMessage = memo(function AssistantMessage({
) => {
const content = node.content ?? "";
const isLinkedInlineCode = nodeHasParentType(parent, "link");
const inlineCodeFileLink = classifyAssistantFileLink(content, { workspaceRoot });
const shouldResolveInlinePath =
onInlinePathPress && !isLinkedInlineCode && parseInlinePathToken(content);
onInlinePathPress &&
!isLinkedInlineCode &&
inlineCodeFileLink &&
inlineCodeFileLink.kind !== "external";
if (shouldResolveInlinePath) {
return (

View File

@@ -67,7 +67,7 @@ import {
type WorkspaceLayout,
} from "@/stores/workspace-layout-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";
interface SplitContainerProps {

View File

@@ -32,7 +32,6 @@ import {
} from "@/hooks/use-agent-screen-state-machine";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useStableEvent } from "@/hooks/use-stable-event";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import {
@@ -59,7 +58,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 type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
import { mergePendingCreateImages } from "@/utils/pending-create-images";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
@@ -289,18 +288,12 @@ function AgentPanel() {
const { isInteractive } = usePaneFocus();
invariant(target.kind === "agent", "AgentPanel requires agent target");
const handleOpenWorkspaceFile = useStableEvent(
({ filePath, disposition }: { filePath: string; disposition: OpenFileDisposition }) => {
openFileInWorkspace(filePath, disposition);
},
);
return (
<AgentPanelContent
serverId={serverId}
agentId={target.agentId}
isPaneFocused={isInteractive}
onOpenWorkspaceFile={handleOpenWorkspaceFile}
onOpenWorkspaceFile={openFileInWorkspace}
/>
);
}
@@ -366,7 +359,7 @@ function AgentPanelContent({
serverId: string;
agentId: string;
isPaneFocused: boolean;
onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const resolvedAgentId = agentId.trim() || undefined;
const resolvedServerId = serverId.trim() || undefined;
@@ -428,7 +421,7 @@ function AgentPanelBody({
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
isConnected: boolean;
connectionStatus: HostRuntimeConnectionStatus;
onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent();
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
@@ -593,7 +586,7 @@ function ChatAgentContent({
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
isConnected: boolean;
connectionStatus: HostRuntimeConnectionStatus;
onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const panelToast = useToastHost();
const { isArchivingAgent } = useArchiveAgent();
@@ -1042,7 +1035,7 @@ function AgentStreamSection({
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
toast: ReturnType<typeof useToastHost>["api"];
onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const streamItemsRaw = useSessionStore((state) =>
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,

View File

@@ -6,7 +6,6 @@ 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,13 +30,6 @@ function DraftPanel() {
const { isInteractive } = usePaneFocus();
invariant(target.kind === "draft", "DraftPanel requires draft target");
const handleOpenWorkspaceFile = useCallback(
({ filePath, disposition }: { filePath: string; disposition: OpenFileDisposition }) => {
openFileInWorkspace(filePath, disposition);
},
[openFileInWorkspace],
);
const handleCreated = useCallback(
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[0]) => {
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
@@ -59,7 +51,7 @@ function DraftPanel() {
draftId={target.draftId}
initialSetup={target.setup}
isPaneFocused={isInteractive}
onOpenWorkspaceFile={handleOpenWorkspaceFile}
onOpenWorkspaceFile={openFileInWorkspace}
onCreated={handleCreated}
onOpenImportSheet={openImportSheet}
/>

View File

@@ -38,7 +38,7 @@ function FilePanel() {
</View>
);
}
return <FilePane serverId={serverId} workspaceRoot={workspaceDirectory} filePath={target.path} />;
return <FilePane serverId={serverId} workspaceRoot={workspaceDirectory} location={target} />;
}
export const filePanelRegistration: PanelRegistration<"file"> = {

View File

@@ -1,7 +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";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
export interface PaneContextValue {
serverId: string;
@@ -11,7 +11,7 @@ export interface PaneContextValue {
openTab: (target: WorkspaceTabTarget) => void;
closeCurrentTab: () => void;
retargetCurrentTab: (target: WorkspaceTabTarget) => void;
openFileInWorkspace: (filePath: string, disposition: OpenFileDisposition) => void;
openFileInWorkspace: (request: WorkspaceFileOpenRequest) => void;
openImportSheet: () => void;
}

View File

@@ -20,7 +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 type { WorkspaceFileOpenRequest } from "@/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";
@@ -319,7 +319,7 @@ interface WorkspaceDraftAgentTabProps {
initialSetup?: WorkspaceDraftTabSetup;
isPaneFocused: boolean;
onCreated: (snapshot: AgentSnapshotPayload) => void;
onOpenWorkspaceFile: (input: { filePath: string; disposition: OpenFileDisposition }) => void;
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void;
onOpenImportSheet?: () => void;
}

View File

@@ -9,7 +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";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
export interface WorkspacePaneContentModel {
key: string;
@@ -24,7 +24,7 @@ export interface BuildWorkspacePaneContentModelInput {
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
onCloseCurrentTab: () => void;
onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void;
onOpenWorkspaceFile: (filePath: string, disposition: OpenFileDisposition) => void;
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void;
onOpenImportSheet: () => void;
}

View File

@@ -130,6 +130,30 @@ describe("workspace-pane-state", () => {
).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", () => {
const layout: WorkspaceLayout = {
root: {

View File

@@ -9,7 +9,7 @@ import {
buildDeterministicWorkspaceTabId,
normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual,
} from "@/utils/workspace-tab-identity";
} from "@/workspace-tabs/identity";
import { findAdjacentPane } from "@/utils/split-navigation";
export interface WorkspaceDerivedTab {
@@ -209,7 +209,10 @@ export function resolveSideFileOpenPlacement(input: {
tabs: WorkspaceTab[];
target: WorkspaceTabTarget;
}): 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) {
return { kind: "open-in-source" };
}

View File

@@ -77,10 +77,7 @@ import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-s
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import {
normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual,
} from "@/utils/workspace-tab-identity";
import { normalizeWorkspaceTabTarget, workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
import {
getHostRuntimeStore,
useHostRuntimeClient,
@@ -161,6 +158,12 @@ import { useContainerWidthBelow } from "@/hooks/use-container-width";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state";
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 EMPTY_UI_TABS: WorkspaceTab[] = [];
@@ -2023,7 +2026,11 @@ function WorkspaceScreenContent({
if (!persistenceKey) {
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) {
navigateToTabId(tabId);
}
@@ -2032,28 +2039,40 @@ function WorkspaceScreenContent({
);
const handleOpenFileFromChat = useCallback(
(filePath: string) => {
const normalizedFilePath = filePath.trim();
if (!normalizedFilePath) {
(location: WorkspaceFileLocation) => {
const normalizedLocation = normalizeWorkspaceFileLocation(location);
if (!normalizedLocation) {
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(
(input: { filePath: string; sourcePaneId?: string }) => {
const normalizedFilePath = input.filePath.trim();
if (!normalizedFilePath) {
(input: { location: WorkspaceFileLocation; sourcePaneId?: string }) => {
const location = normalizeWorkspaceFileLocation(input.location);
if (!location) {
return;
}
if (!persistenceKey || isMobile || !input.sourcePaneId) {
handleOpenFileFromExplorer(normalizedFilePath);
handleOpenFileFromChat(location);
return;
}
const target: WorkspaceTabTarget = { kind: "file", path: normalizedFilePath };
const target: WorkspaceTabTarget = createWorkspaceFileTabTarget(location);
const placement = resolveSideFileOpenPlacement({
layout: workspaceLayout,
sourcePaneId: input.sourcePaneId,
@@ -2075,7 +2094,7 @@ function WorkspaceScreenContent({
}
},
[
handleOpenFileFromExplorer,
handleOpenFileFromChat,
isMobile,
focusWorkspacePane,
navigateToTabId,
@@ -2755,18 +2774,18 @@ function WorkspaceScreenContent({
}
retargetWorkspaceTab(persistenceKey, input.tab.tabId, target);
},
onOpenWorkspaceFile: (filePath, disposition) => {
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => {
if (input.focusPaneBeforeOpen && input.paneId && persistenceKey) {
focusWorkspacePane(persistenceKey, input.paneId);
}
if (disposition === "side") {
if (request.disposition === "side") {
handleOpenFileFromChatInSidePane({
filePath,
location: request.location,
sourcePaneId: input.paneId ?? undefined,
});
return;
}
handleOpenFileFromChat(filePath);
handleOpenFileFromChat(request.location);
},
onOpenImportSheet: openImportSheet,
}),

View File

@@ -3,7 +3,7 @@ import {
deriveWorkspacePaneState,
type WorkspaceDerivedTab,
} from "@/screens/workspace/workspace-pane-state";
import { buildDeterministicWorkspaceTabId } from "@/utils/workspace-tab-identity";
import { buildDeterministicWorkspaceTabId } from "@/workspace-tabs/identity";
export interface WorkspaceTabModel {
tabs: WorkspaceDerivedTab[];

View File

@@ -6,7 +6,7 @@ import {
buildDeterministicWorkspaceTabId,
normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual,
} from "@/utils/workspace-tab-identity";
} from "@/workspace-tabs/identity";
export interface SplitPane {
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 {
const layout = asInternalLayout(input.layout);
const existingTab = collectAllTabs(layout.root).find((tab) =>
workspaceTabTargetsEqual(tab.target, input.target),
);
const existingTab = findExistingTabForTarget(layout.root, input.target);
if (existingTab) {
const nextLayout = updateExistingTabTarget(layout, existingTab, input.target);
return {
tabId: existingTab.tabId,
layout:
focusTabInLayout({
layout,
layout: nextLayout,
tabId: existingTab.tabId,
}) ?? input.layout,
}) ?? nextLayout,
};
}
@@ -1072,11 +1098,12 @@ export function openTabInLayoutFocused(input: OpenTabInLayoutInput): OpenTabInLa
export function openTabInLayoutBackground(input: OpenTabInLayoutInput): OpenTabInLayoutResult {
const layout = asInternalLayout(input.layout);
const existingTab = collectAllTabs(layout.root).find((tab) =>
workspaceTabTargetsEqual(tab.target, input.target),
);
const existingTab = findExistingTabForTarget(layout.root, input.target);
if (existingTab) {
return { tabId: existingTab.tabId, layout: input.layout };
return {
tabId: existingTab.tabId,
layout: updateExistingTabTarget(layout, existingTab, input.target),
};
}
return insertNewTabIntoFocusedPane({ ...input, focus: false });

View File

@@ -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", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();

View File

@@ -44,7 +44,7 @@ import {
type WorkspaceTabSnapshot,
type WorkspaceLayout,
} from "@/stores/workspace-layout-actions";
import { normalizeWorkspaceTabTarget } from "@/utils/workspace-tab-identity";
import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity";
export { buildWorkspaceTabPersistenceKey };
export {

View File

@@ -7,7 +7,8 @@ import {
normalizeWorkspaceDraftTabSetup,
normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual,
} from "@/utils/workspace-tab-identity";
} from "@/workspace-tabs/identity";
import type { WorkspaceFileTabTarget } from "@/workspace/file-open";
export interface WorkspaceDraftTabSetup {
provider: AgentProvider;
@@ -23,7 +24,7 @@ export type WorkspaceTabTarget =
| { kind: "agent"; agentId: string }
| { kind: "terminal"; terminalId: string }
| { kind: "browser"; browserId: string }
| { kind: "file"; path: string }
| WorkspaceFileTabTarget
| { kind: "setup"; workspaceId: string };
export interface WorkspaceTab {
@@ -168,7 +169,12 @@ function coerceWorkspaceTabTarget(raw: Record<string, unknown>): WorkspaceTabTar
return normalizeWorkspaceTabTarget({ kind: "browser", browserId: raw.browserId });
}
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") {
return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId });

View File

@@ -1 +0,0 @@
export type OpenFileDisposition = "main" | "side";

View File

@@ -1,4 +1,5 @@
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { normalizeWorkspaceFileLocation, workspaceFileLocationsEqual } from "@/workspace/file-open";
type WorkspaceDraftTabSetup = NonNullable<Extract<WorkspaceTabTarget, { kind: "draft" }>["setup"]>;
@@ -29,8 +30,7 @@ export function normalizeWorkspaceTabTarget(
return browserId ? { kind: "browser", browserId } : null;
}
if (value.kind === "file") {
const path = trimNonEmpty(value.path);
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
return normalizeFileTabTarget(value);
}
if (value.kind === "setup") {
const workspaceId = trimNonEmpty(value.workspaceId);
@@ -83,7 +83,7 @@ export function workspaceTabTargetsEqual(
return left.browserId === right.browserId;
}
if (left.kind === "file" && right.kind === "file") {
return left.path === right.path;
return workspaceFileLocationsEqual(left, right);
}
if (left.kind === "setup" && right.kind === "setup") {
return left.workspaceId === right.workspaceId;
@@ -151,6 +151,13 @@ function trimNonEmpty(value: string | null | undefined): string | 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 {
return value == null ? null : trimNonEmpty(value);
}

View File

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

View File

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