From bca7ca07ea95b5580ad95e9fd9eebae99646483a Mon Sep 17 00:00:00 2001 From: Rahat Hameed Date: Fri, 25 Sep 2026 14:19:35 +0500 Subject: [PATCH 1/6] fix: multi-repo file links open the file in its own repo In a multi-repo project whose workspace root is a parent folder, agents quote paths the way git prints them, relative to the repo they ran in. The chat resolved those paths against the workspace root, so a chip like `.graft/config.json` failed with "Failed to read workspace file". The same relative path can also exist in several repos. Sessions that span more than one repo root now get a mandatory rule to write every path as an absolute path, built from a `/` template, with no shortened repeat mentions and a check before sending. All six adapters pass the flag. Chat chips now resolve against the repo roots, including the two fallback resolver calls, and pass the owning root to the file panel. A path with an owning root skips the workspace-wide basename lookup. The file tree maps a chat selection onto its repo key, so folder links in a non-anchor repo are revealed. Fixes #302 Co-Authored-By: Claude Opus 5.5 --- .../provider/CodexDeveloperInstructions.ts | 1 + .../src/provider/Layers/AntigravityAdapter.ts | 9 +++++- .../src/provider/Layers/ClaudeAdapter.test.ts | 23 ++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 5 +++- .../Layers/CodexSessionRuntime.test.ts | 18 +++++++++++ .../provider/Layers/CodexSessionRuntime.ts | 7 ++++- .../src/provider/Layers/CursorAdapter.ts | 9 +++++- .../server/src/provider/Layers/GrokAdapter.ts | 4 +++ .../src/provider/Layers/OpenCodeAdapter.ts | 4 +++ .../src/provider/RuntimeInstructions.test.ts | 7 +++++ .../src/provider/RuntimeInstructions.ts | 30 ++++++++++++++++++- apps/web/src/components/ChatMarkdown.tsx | 24 ++++++++++----- .../src/components/files/FileBrowserPanel.tsx | 13 +++++++- .../src/components/files/FilePreviewPanel.tsx | 1 + 14 files changed, 141 insertions(+), 14 deletions(-) diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 6a7fee351bce..01d336a3193c 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -195,6 +195,7 @@ ${browserToolInstructions(browserToolsAvailable)} export interface CodexRuntimeInfo { readonly model: string; readonly reasoningEffort: string; + readonly multiRepo?: boolean | undefined; } export function buildCodexDeveloperInstructions( diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index 61a9b3c3a645..138077f353f4 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -192,6 +192,8 @@ interface TurnIntent { interface SessionContext { readonly threadId: ThreadId; readonly cwd: string; + /** The session spans several repository roots. */ + readonly multiRepo: boolean; readonly nativeSessionId: string; readonly scope: Scope.Closeable; readonly runtime: Runtime; @@ -865,6 +867,7 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi context = { threadId: input.threadId, cwd, + multiRepo: (input.additionalRoots?.length ?? 0) > 0, nativeSessionId: started.sessionId, scope: sessionScope, runtime, @@ -1085,7 +1088,11 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi ...prompt, { type: "text", - text: buildRuntimeInstructions({ harness: "Antigravity", model }), + text: buildRuntimeInstructions({ + harness: "Antigravity", + model, + multiRepo: context.multiRepo, + }), }, ], }, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index ca596e6501cf..1f167fe6dd04 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -461,6 +461,29 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("asks a multi-repo session for absolute file paths", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + cwd: "/workspace", + additionalRoots: ["/workspace/api", "/workspace/web"], + runtimeMode: "full-access", + }); + + assert.deepEqual(harness.getLastCreateQueryInput()?.options.systemPrompt, { + type: "preset", + preset: "claude_code", + append: buildRuntimeInstructions({ harness: "Claude Code", multiRepo: true }), + }); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("derives auto permission mode from auto runtime policy without skip flag", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 382a340e36d3..83e785a149ce 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -4925,7 +4925,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( type: "preset", preset: "claude_code", // Model and effort can change after this session-level prompt is set. - append: buildRuntimeInstructions({ harness: "Claude Code" }), + append: buildRuntimeInstructions({ + harness: "Claude Code", + multiRepo: (input.additionalRoots?.length ?? 0) > 0, + }), }, settingSources: [...CLAUDE_SETTING_SOURCES], // `ultracode` is a Claude Code setting, not an API effort level. It is diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index ec113ab7c521..a9c59a45103a 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -287,6 +287,24 @@ describe("buildTurnStartParams", () => { }); }); + it.effect("asks a multi-repo session for absolute file paths", () => + Effect.gen(function* () { + const instructions = function* (multiRepo: boolean) { + const params = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: "Go", + interactionMode: "default", + multiRepo, + }); + return params.collaborationMode?.settings.developer_instructions ?? ""; + }; + + NodeAssert.match(yield* instructions(true), //); + NodeAssert.doesNotMatch(yield* instructions(false), //); + }), + ); + it("reports the same fallback model and effort in settings and instructions", () => { const params = Effect.runSync( buildTurnStartParams({ diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 64ac8efda9ea..4188124c7808 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -590,6 +590,7 @@ function buildCodexCollaborationMode(input: { readonly model?: string; readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; readonly browserToolsAvailable?: boolean | T3CodeToolAvailability; + readonly multiRepo?: boolean; }): EffectCodexSchema.V2TurnStartParams__CollaborationMode | undefined { if (input.interactionMode === undefined) { return undefined; @@ -603,7 +604,7 @@ function buildCodexCollaborationMode(input: { reasoning_effort: reasoningEffort, developer_instructions: buildCodexDeveloperInstructions( input.interactionMode, - { model, reasoningEffort }, + { model, reasoningEffort, multiRepo: input.multiRepo }, input.browserToolsAvailable ?? true, ), }, @@ -628,6 +629,8 @@ export function buildTurnStartParams(input: { readonly interactionMode?: ProviderInteractionMode; /** Defaults to true so callers that predate the agent-access gate are unchanged. */ readonly browserToolsAvailable?: boolean | T3CodeToolAvailability; + /** The session spans several repository roots. */ + readonly multiRepo?: boolean; }): Effect.Effect< CodexTurnStartParamsWithCollaborationMode, CodexErrors.CodexAppServerProtocolParseError @@ -649,6 +652,7 @@ export function buildTurnStartParams(input: { ...(input.model ? { model: input.model } : {}), ...(input.effort ? { effort: input.effort } : {}), browserToolsAvailable: input.browserToolsAvailable ?? true, + ...(input.multiRepo ? { multiRepo: true } : {}), }); return decodeCodexTurnStartParamsWithCollaborationMode({ @@ -2552,6 +2556,7 @@ export const makeCodexSessionRuntime = ( options.appServerArgs, options.mcpCapabilities, ), + multiRepo: (options.additionalRoots?.length ?? 0) > 0, }); const rawResponse = yield* client.raw.request("turn/start", params); const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index e0fe5016326d..0dfc6470a72c 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -138,6 +138,8 @@ interface CursorSessionContext { session: ProviderSession; readonly scope: Scope.Closeable; readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + /** The session spans several repository roots. */ + readonly multiRepo: boolean; notificationFiber: Fiber.Fiber | undefined; readonly pendingApprovals: Map; readonly pendingUserInputs: Map; @@ -793,6 +795,7 @@ export function makeCursorAdapter( session, scope: sessionScope, acp, + multiRepo: (input.additionalRoots?.length ?? 0) > 0, notificationFiber: undefined, pendingApprovals, pendingUserInputs, @@ -1097,7 +1100,11 @@ export function makeCursorAdapter( ...promptParts, { type: "text", - text: buildRuntimeInstructions({ harness: "Cursor", model: resolvedModel }), + text: buildRuntimeInstructions({ + harness: "Cursor", + model: resolvedModel, + multiRepo: ctx.multiRepo, + }), }, ], }) diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index bec0fc39faa0..d8bd22c5ef0e 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -138,6 +138,8 @@ interface GrokSessionContext { session: ProviderSession; readonly scope: Scope.Closeable; readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + /** The session spans several repository roots. */ + readonly multiRepo: boolean; notificationFiber: Fiber.Fiber | undefined; readonly pendingApprovals: Map; readonly pendingUserInputs: Map; @@ -1291,6 +1293,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte session, scope: sessionScope, acp, + multiRepo: (input.additionalRoots?.length ?? 0) > 0, notificationFiber: undefined, pendingApprovals, pendingUserInputs, @@ -1647,6 +1650,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte harness: "Grok", model: displayModel, reasoningEffort: normalizeGrokReasoningEffort(requestedTurnReasoningEffort), + multiRepo: ctx.multiRepo, }); for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { yield* Effect.yieldNow; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 41bf634c0d3b..d9462b8f62d9 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -341,6 +341,8 @@ interface OpenCodeSessionContext { readonly client: OpencodeClient; readonly server: OpenCodeServerConnection; readonly directory: string; + /** The session spans several repository roots. */ + readonly multiRepo: boolean; openCodeSessionId: string; readonly relatedSessionIds: Set; readonly resolvedRequestIds: Set; @@ -3007,6 +3009,7 @@ export function makeOpenCodeAdapter( client: started.client, server: started.server, directory, + multiRepo: (input.additionalRoots?.length ?? 0) > 0, openCodeSessionId: started.openCodeSession.id, relatedSessionIds: new Set([started.openCodeSession.id]), resolvedRequestIds: new Set(), @@ -3283,6 +3286,7 @@ export function makeOpenCodeAdapter( system: buildRuntimeInstructions({ harness: "OpenCode", model: `${parsedModel.providerID}/${parsedModel.modelID}`, + multiRepo: context.multiRepo, }), parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], }, diff --git a/apps/server/src/provider/RuntimeInstructions.test.ts b/apps/server/src/provider/RuntimeInstructions.test.ts index e73c50adfd6d..91d812e13d6c 100644 --- a/apps/server/src/provider/RuntimeInstructions.test.ts +++ b/apps/server/src/provider/RuntimeInstructions.test.ts @@ -25,4 +25,11 @@ describe("buildRuntimeInstructions", () => { expect(instructions).toContain("through the Cursor harness."); expect(instructions).not.toContain("reasoning effort"); }); + + it("asks for absolute file paths only when the session spans several repos", () => { + expect(buildRuntimeInstructions({ harness: "Codex", multiRepo: true })).toContain( + "", + ); + expect(buildRuntimeInstructions({ harness: "Codex" })).not.toContain(""); + }); }); diff --git a/apps/server/src/provider/RuntimeInstructions.ts b/apps/server/src/provider/RuntimeInstructions.ts index 5e72586062e5..1408419327c2 100644 --- a/apps/server/src/provider/RuntimeInstructions.ts +++ b/apps/server/src/provider/RuntimeInstructions.ts @@ -2,18 +2,46 @@ const PULL_REQUEST_LINKING_INSTRUCTIONS = ` When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. `; +const MULTI_REPO_FILE_PATH_INSTRUCTIONS = ` +MANDATORY RULE: In this workspace, every file or directory path that you write in a response MUST be a full absolute path. There are no exceptions. + +Why: this workspace contains more than one git repository. The same relative path, for example .graft/config.json, can exist in several of these repositories. A relative path, a shortened path, or a bare file name does not identify one file. The user's client opens it against the workspace root, so it opens the wrong file or no file. + +The rule applies: +- to every mention of a path, in every part of the response: prose, inline code, links, lists, tables, headings, recommendations, and summaries. +- to each mention separately. If you wrote the absolute path of a file earlier in the same response, write the full absolute path again. You are not permitted to shorten it. +- to paths that you copy from tool output. git status, git diff, grep, rg, ls, and find print relative paths. Convert each path to an absolute path before you write it. + +Do NOT write: +- a relative path, for example \`.graft/config.json\` or \`src/index.ts\`. +- a path that starts with "./" or "../". +- a bare file or directory name that stands for a specific file or directory, for example \`config.json\` or \`.graft\`. +- "~" or an environment variable in place of the start of a path. + +Build each absolute path from this template: +/ +- is the path as the tool printed it. +- is the absolute path of the directory that the relative path starts from. For git diff and git show, it is the root of the repository that the command ran in. For git status, grep, rg, ls, and find, it is the directory that the command ran in: its working directory, or the directory given to cd or git -C. +- Example: the command git -C status prints .graft/config.json. Write \`/.graft/config.json\`, with the real absolute path of that repository in place of . +- If you do not know which repository or directory a path belongs to, find out with a tool before you write the path. For example, run git rev-parse --show-toplevel in that directory. Do not guess. + +MANDATORY CHECK before you send each response: find every file and directory path in the response. If a path does not start with "/" (or a drive letter on Windows), replace it with the full absolute path. Do this check again for paths in the last part of the response, because shortened paths occur most often there. +`; + /** Shared runtime context; omit model and effort when the harness manages them dynamically. */ export function buildRuntimeInstructions(runtime: { readonly harness: string; readonly model?: string | undefined; readonly reasoningEffort?: string | undefined; + /** True when the session spans more than one repository root. */ + readonly multiRepo?: boolean | undefined; }): string { const harness = toSingleLine(runtime.harness); const model = toSingleLine(runtime.model ?? ""); const effort = toSingleLine(runtime.reasoningEffort ?? ""); const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${model}` : ""; const effortInfo = effort ? ` with ${effort} reasoning effort` : ""; - return `In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}`; + return `In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}${runtime.multiRepo ? `\n\n${MULTI_REPO_FILE_PATH_INSTRUCTIONS}` : ""}`; } function toSingleLine(value: string): string { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 1265f7a473ff..a503fc06db71 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -2365,8 +2365,11 @@ function useChatMarkdownState({ const diffThemeName = resolveDiffThemeName(resolvedTheme); // Stable dep for the roots array: NUL-joined (paths never contain NUL). const repoRootsKey = repoRoots ? repoRoots.join("\0") : ""; + const roots = useMemo( + () => (repoRootsKey ? repoRootsKey.split("\0") : undefined), + [repoRootsKey], + ); const markdownFileLinkMetaByHref = useMemo(() => { - const roots = repoRootsKey ? repoRootsKey.split("\0") : undefined; const metaByHref = new Map< string, NonNullable> @@ -2381,18 +2384,18 @@ function useChatMarkdownState({ } } return metaByHref; - }, [cwd, imageBaseDir, repoRootsKey, text]); + }, [cwd, imageBaseDir, roots, text]); const inlineCodeFileLinkMetaByText = useMemo(() => { const metaByText = new Map(); for (const span of extractInlineCodeSpans(text)) { if (metaByText.has(span)) continue; - const meta = resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd); + const meta = resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd, roots); if (meta) { metaByText.set(span, meta); } } return metaByText; - }, [cwd, imageBaseDir, text]); + }, [cwd, imageBaseDir, roots, text]); const fileLinkParentSuffixByPath = useMemo(() => { const filePaths = [ ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath), @@ -2544,7 +2547,8 @@ function useChatMarkdownState({ const isLatestLookup = claimWorkspaceBasenameLookup(); const openAt = (path: string) => useRightPanelStore.getState().openFile(threadRef, path, line, root); - if (!cwd || !needsWorkspaceBasenameLookup(panelPath)) { + // A path with an owning repo root is already exact within that repo. + if (!cwd || root || !needsWorkspaceBasenameLookup(panelPath)) { openAt(panelPath); return; } @@ -2600,6 +2604,7 @@ function useChatMarkdownState({ displayPath={fileLinkMeta.displayPath} panelPath={panelPath} line={fileLinkMeta.line} + fileRoot={fileLinkMeta.fileRoot} label={labelParts.join(" ยท ")} copyMarkdown={copyMarkdown} theme={resolvedTheme} @@ -2668,6 +2673,7 @@ function useChatMarkdownState({ linkedThreadPullRequestFor, resolveThreadPullRequest, resolvedTheme, + roots, serverConfig, skills, text, @@ -2698,6 +2704,7 @@ function useChatMarkdownState({ linkedThreadPullRequestFor, resolveThreadPullRequest, resolvedTheme, + roots, serverConfig, skills, text, @@ -2845,6 +2852,7 @@ const CHAT_MARKDOWN_COMPONENTS = { updateThreadPullRequestLink, fileLinkChip, renderContextReference, + roots, } = use(ChatMarkdownRendererContext); const citation = href ? parseAssistantCitationHref(href) : null; if (citation) return ; @@ -2860,7 +2868,7 @@ const CHAT_MARKDOWN_COMPONENTS = { const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; const fileLinkMeta = normalizedHref ? (markdownFileLinkMetaByHref.get(normalizedHref) ?? - resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd)) + resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd, roots)) : null; if (!fileLinkMeta) { const faviconHost = resolveExternalWebLinkHost(href); @@ -3063,14 +3071,14 @@ const CHAT_MARKDOWN_COMPONENTS = { ); }, code: function MarkdownCode({ node, children, className, ...props }) { - const { cwd, imageBaseDir, inlineCodeFileLinkMetaByText, fileLinkChip } = use( + const { cwd, imageBaseDir, inlineCodeFileLinkMetaByText, fileLinkChip, roots } = use( ChatMarkdownRendererContext, ); if (node?.properties?.dataInlineCode != null) { const codeText = nodeToPlainText(children); const fileLinkMeta = inlineCodeFileLinkMetaByText.get(codeText.trim()) ?? - resolveInlineCodeFileLinkMeta(codeText, cwd, imageBaseDir ?? cwd); + resolveInlineCodeFileLinkMeta(codeText, cwd, imageBaseDir ?? cwd, roots); if (fileLinkMeta) { return fileLinkChip( fileLinkMeta, diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 5032b3aa3ae4..8cf3e1e7b497 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -34,6 +34,8 @@ interface FileBrowserPanelProps { projectName: string; /** Entry currently open in the surface; revealed and selected in the tree. A directory is expanded. */ selectedPath: string | null; + /** Repo root that `selectedPath` is relative to, when it is not the workspace root. */ + selectedRoot?: string | undefined; /** Bumped when the same path should be revealed again (e.g. re-opened from search). */ selectedPathRevealId: number; // Multi-repo workspaces (#923): when set, list the union of these repo roots @@ -154,7 +156,8 @@ export default function FileBrowserPanel({ environmentId, cwd, projectName, - selectedPath, + selectedPath: selectedRelativePath, + selectedRoot, selectedPathRevealId, repoRoots, onOpenFile, @@ -179,6 +182,14 @@ export default function FileBrowserPanel({ searchRoots: roots, }; }, [multiRepoRootsKey]); + // Tree paths sit under their repo's label, so an open from outside the tree + // (a chat link, the file picker) maps onto that key to be found and revealed. + const selectedLabel = + rootLabels && selectedRoot ? labelForRoot(rootLabels, selectedRoot) : undefined; + const selectedPath = + selectedRelativePath && selectedLabel !== undefined + ? `${selectedLabel}/${selectedRelativePath}` + : selectedRelativePath; const { entries: directoryEntries, load, diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index d3f8db1cfef9..a3eef74160fc 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -1315,6 +1315,7 @@ export default function FilePreviewPanel({ cwd={cwd} projectName={projectName} selectedPath={relativePath} + selectedRoot={fileRoot ?? undefined} selectedPathRevealId={revealRequestId} repoRoots={repoRoots} onOpenFile={onOpenFile} From 3e82d847b5d423a4a1a9c37ae4e10f02228ab109 Mon Sep 17 00:00:00 2001 From: Rahat Hameed Date: Fri, 25 Sep 2026 14:19:36 +0500 Subject: [PATCH 2/6] fix(web): show the owning repo in multi-repo file breadcrumbs A file in a repo other than the workspace root showed " > .graft > config.json", hiding which repo it came from, and the crumb menus listed the workspace root instead of the repo. Breadcrumbs now read " > > path", and crumbs from the repo down browse and open files within that repo. Refs #302 Co-Authored-By: Claude Opus 5.5 --- .../src/components/files/FileBreadcrumbs.tsx | 31 ++++++++++++++++--- .../src/components/files/FilePreviewPanel.tsx | 1 + 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/files/FileBreadcrumbs.tsx b/apps/web/src/components/files/FileBreadcrumbs.tsx index a98b4dca69d2..d31cc5b07d41 100644 --- a/apps/web/src/components/files/FileBreadcrumbs.tsx +++ b/apps/web/src/components/files/FileBreadcrumbs.tsx @@ -32,9 +32,11 @@ import { useProjectEntriesQuery } from "./projectFilesQueryState"; interface FileBreadcrumbsProps { readonly cwd: string; readonly environmentId: EnvironmentId; - readonly onOpenFile: (relativePath: string) => void; + readonly onOpenFile: (relativePath: string, root?: string) => void; readonly projectName: string; readonly relativePath: string; + /** Repo root that `relativePath` is relative to, when it is not `cwd` (multi-repo). */ + readonly root?: string | undefined; readonly workspaceMutationId: string | null; } @@ -267,14 +269,33 @@ function DirectoryBreadcrumb(props: FileBreadcrumbsProps & { readonly crumb: Fil export function FileBreadcrumbs(props: FileBreadcrumbsProps) { const hostPath = isAbsolutePath(props.relativePath); + // A file in another repo of a multi-repo project reads "project > repo > path", + // and every crumb from the repo down browses and opens within that repo. + const repoRoot = props.root && props.root !== props.cwd ? props.root : undefined; + const repoName = repoRoot ? (repoRoot.split(/[\\/]/).findLast(Boolean) ?? repoRoot) : undefined; const breadcrumbs = useMemo( - () => fileBreadcrumbs(props.projectName, props.relativePath), - [props.projectName, props.relativePath], + () => + repoName === undefined + ? fileBreadcrumbs(props.projectName, props.relativePath) + : [ + { label: props.projectName, path: "", kind: "project" as const }, + ...fileBreadcrumbs(repoName, props.relativePath), + ], + [props.projectName, props.relativePath, repoName], ); + const repoProps: FileBreadcrumbsProps = + repoRoot && repoName !== undefined + ? { + ...props, + cwd: repoRoot, + projectName: repoName, + onOpenFile: (relativePath) => props.onOpenFile(relativePath, repoRoot), + } + : props; return breadcrumbs.map((crumb, index) => (
@@ -288,7 +309,7 @@ export function FileBreadcrumbs(props: FileBreadcrumbsProps) { ) : hostPath ? ( ) : ( - + )}
)); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index a3eef74160fc..d0c78319ca16 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -1119,6 +1119,7 @@ export default function FilePreviewPanel({ onOpenFile={onOpenFile} projectName={projectName} relativePath={relativePath} + root={fileRoot ?? undefined} workspaceMutationId={workspaceMutationId} /> From da13dd07c352568dc4f012eb576967953a8d93e9 Mon Sep 17 00:00:00 2001 From: Rahat Hameed Date: Fri, 25 Sep 2026 16:06:12 +0500 Subject: [PATCH 3/6] fix: tighten the multi-repo path rule and reveal files in their own repo The multi-repo instruction block is shorter and exempts code blocks and text meant to be pasted elsewhere. It asks for every file and directory path in inline code as an absolute path, explains why, gives one example, and lists the ways replies slipped in testing. "Reveal in file manager" skips the workspace basename lookup when a chip has an owning repo root, like opening in the panel does. Refs #302 Co-Authored-By: Claude Opus 5.5 --- .../src/provider/RuntimeInstructions.ts | 30 +++++++------------ apps/web/src/components/ChatMarkdown.tsx | 8 +++-- 2 files changed, 15 insertions(+), 23 deletions(-) diff --git a/apps/server/src/provider/RuntimeInstructions.ts b/apps/server/src/provider/RuntimeInstructions.ts index 1408419327c2..fd01909953cd 100644 --- a/apps/server/src/provider/RuntimeInstructions.ts +++ b/apps/server/src/provider/RuntimeInstructions.ts @@ -3,29 +3,19 @@ When the t3-code MCP server exposes link_pull_request, you must use it to regist `; const MULTI_REPO_FILE_PATH_INSTRUCTIONS = ` -MANDATORY RULE: In this workspace, every file or directory path that you write in a response MUST be a full absolute path. There are no exceptions. +This workspace contains more than one git repository, and the same relative path, such as src/index.ts, can exist in several of them. The user's client turns a path written in inline code, such as \`/abs/path/file.ts\`, into a link that opens the file. It opens a relative path against the workspace root, so that link opens the wrong file or none. Write every file and directory path in inline code as a full absolute path that starts with "/" (or a drive letter on Windows), each time you mention it, in prose, tables, lists, and summaries alike. Code blocks and text meant to be pasted elsewhere keep their usual paths. -Why: this workspace contains more than one git repository. The same relative path, for example .graft/config.json, can exist in several of these repositories. A relative path, a shortened path, or a bare file name does not identify one file. The user's client opens it against the workspace root, so it opens the wrong file or no file. +Tools print relative paths: git status and grep print them relative to the directory they ran in, and git diff, git show, git log, and git stash show print them relative to the repository root. Put that directory's absolute path in front of each one. -The rule applies: -- to every mention of a path, in every part of the response: prose, inline code, links, lists, tables, headings, recommendations, and summaries. -- to each mention separately. If you wrote the absolute path of a file earlier in the same response, write the full absolute path again. You are not permitted to shorten it. -- to paths that you copy from tool output. git status, git diff, grep, rg, ls, and find print relative paths. Convert each path to an absolute path before you write it. + +git -C stash show lists dir/file.py. Write \`/dir/file.py\`, with the real absolute path of that repository in place of . + -Do NOT write: -- a relative path, for example \`.graft/config.json\` or \`src/index.ts\`. -- a path that starts with "./" or "../". -- a bare file or directory name that stands for a specific file or directory, for example \`config.json\` or \`.graft\`. -- "~" or an environment variable in place of the start of a path. - -Build each absolute path from this template: -/ -- is the path as the tool printed it. -- is the absolute path of the directory that the relative path starts from. For git diff and git show, it is the root of the repository that the command ran in. For git status, grep, rg, ls, and find, it is the directory that the command ran in: its working directory, or the directory given to cd or git -C. -- Example: the command git -C status prints .graft/config.json. Write \`/.graft/config.json\`, with the real absolute path of that repository in place of . -- If you do not know which repository or directory a path belongs to, find out with a tool before you write the path. For example, run git rev-parse --show-toplevel in that directory. Do not guess. - -MANDATORY CHECK before you send each response: find every file and directory path in the response. If a path does not start with "/" (or a drive letter on Windows), replace it with the full absolute path. Do this check again for paths in the last part of the response, because shortened paths occur most often there. +Replies in this workspace have slipped in these ways. Before you send a response, check it for each one: +- a path written in full once, then shortened on a later mention or in the closing summary +- a list of files copied from git output, such as the files in a stash, commit, or diff +- a submodule path, or a repository written as \`~/name\` +- a bare file name, such as \`README.md\`, that stands for a specific file `; /** Shared runtime context; omit model and effort when the harness manages them dynamically. */ diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a503fc06db71..961444398f15 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -2563,9 +2563,11 @@ function useChatMarkdownState({ const revealMarkdownFileInFileManager = useCallback( async (fileLinkMeta: MarkdownFileLinkMeta) => { const workspaceRelativePath = fileLinkMeta.workspaceRelativePath; - const match = workspaceRelativePath - ? await findWorkspaceBasenameMatch(workspaceRelativePath) - : null; + // A path with an owning repo root is already exact within that repo. + const match = + workspaceRelativePath && !fileLinkMeta.fileRoot + ? await findWorkspaceBasenameMatch(workspaceRelativePath) + : null; const filePath = match && cwd ? resolvePathLinkTarget(match, cwd) : fileLinkMeta.filePath; return revealFileInFileManager(filePath); }, From 731f9f1ba4b25c8221cdb1ebc4a2c4e6f0e25cc0 Mon Sep 17 00:00:00 2001 From: Rahat Hameed Date: Fri, 25 Sep 2026 16:06:13 +0500 Subject: [PATCH 4/6] fix(web): label the repo crumb like the file tree Two repos with the same folder name showed the same crumb. The crumb now uses the file tree's root labels, which grow by parent folders until they differ. Refs #302 Co-Authored-By: Claude Opus 5.5 --- .../src/components/files/FileBreadcrumbs.tsx | 13 ++++- .../src/components/files/FileBrowserPanel.tsx | 49 +----------------- .../src/components/files/FilePreviewPanel.tsx | 1 + .../web/src/components/files/filePath.test.ts | 17 ++++++- apps/web/src/components/files/filePath.ts | 51 +++++++++++++++++++ 5 files changed, 81 insertions(+), 50 deletions(-) diff --git a/apps/web/src/components/files/FileBreadcrumbs.tsx b/apps/web/src/components/files/FileBreadcrumbs.tsx index d31cc5b07d41..e04b40a5634d 100644 --- a/apps/web/src/components/files/FileBreadcrumbs.tsx +++ b/apps/web/src/components/files/FileBreadcrumbs.tsx @@ -22,10 +22,12 @@ import { cn } from "~/lib/utils"; import { isAbsolutePath } from "~/terminal-links"; import { + buildRootLabels, type FileBreadcrumb, fileBreadcrumbChildren, fileBreadcrumbParent, fileBreadcrumbs, + labelForRoot, } from "./filePath"; import { useProjectEntriesQuery } from "./projectFilesQueryState"; @@ -35,6 +37,8 @@ interface FileBreadcrumbsProps { readonly onOpenFile: (relativePath: string, root?: string) => void; readonly projectName: string; readonly relativePath: string; + /** Every repo root of a multi-repo project, so the repo crumb matches its file tree label. */ + readonly repoRoots?: readonly string[] | undefined; /** Repo root that `relativePath` is relative to, when it is not `cwd` (multi-repo). */ readonly root?: string | undefined; readonly workspaceMutationId: string | null; @@ -272,7 +276,14 @@ export function FileBreadcrumbs(props: FileBreadcrumbsProps) { // A file in another repo of a multi-repo project reads "project > repo > path", // and every crumb from the repo down browses and opens within that repo. const repoRoot = props.root && props.root !== props.cwd ? props.root : undefined; - const repoName = repoRoot ? (repoRoot.split(/[\\/]/).findLast(Boolean) ?? repoRoot) : undefined; + const repoRootsKey = props.repoRoots?.join("\0") ?? ""; + const repoName = useMemo(() => { + if (!repoRoot) return undefined; + const label = repoRootsKey + ? labelForRoot(buildRootLabels(repoRootsKey.split("\0")), repoRoot) + : undefined; + return label ?? repoRoot.split(/[\\/]/).findLast(Boolean) ?? repoRoot; + }, [repoRoot, repoRootsKey]); const breadcrumbs = useMemo( () => repoName === undefined diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 8cf3e1e7b497..565b2cccd49d 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -22,6 +22,7 @@ import { readLocalApi } from "~/localApi"; import { T3_PIERRE_ICONS } from "~/pierre-icons"; import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; +import { buildRootLabels, labelForRoot } from "./filePath"; import { createFileTreeDragMentionController } from "./fileTreeDragMention"; import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; import { buildFileTreePathUpdates } from "./fileTreePathReconciliation"; @@ -55,54 +56,6 @@ function treePath(entry: ProjectEntry): string { return entry.kind === "directory" ? `${entry.path}/` : entry.path; } -/** Label for a root, tolerating the server's normalized form (no trailing separator). */ -function labelForRoot(labels: ReadonlyMap, root: string): string | undefined { - const exact = labels.get(root); - if (exact !== undefined) return exact; - const trimmed = root.replace(/[\\/]+$/, ""); - for (const [candidate, label] of labels) { - if (candidate.replace(/[\\/]+$/, "") === trimmed) return label; - } - return undefined; -} - -/** - * Assign each repo root a unique, human-readable label for the tree's top-level - * grouping. Prefer the folder basename (matching the per-repo git controls); - * when two roots share a basename, grow the label by parent segments until the - * labels are distinct. - */ -function buildRootLabels(roots: readonly string[]): Map { - const segments = new Map(); - for (const root of roots) { - segments.set( - root, - root - .replaceAll("\\", "/") - .replace(/\/+$/, "") - .split("/") - .filter((segment) => segment.length > 0), - ); - } - - const labels = new Map(); - for (const root of roots) { - const parts = segments.get(root) ?? []; - let depth = 1; - let label = parts.slice(-depth).join("/") || root; - const collidesAtDepth = () => - roots.some( - (other) => other !== root && (segments.get(other) ?? []).slice(-depth).join("/") === label, - ); - while (collidesAtDepth() && depth < parts.length) { - depth += 1; - label = parts.slice(-depth).join("/"); - } - labels.set(root, label); - } - return labels; -} - function RefreshFilesButton(props: { isPending: boolean; onRefresh: () => void }) { return ( diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index d0c78319ca16..e15042490c27 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -1119,6 +1119,7 @@ export default function FilePreviewPanel({ onOpenFile={onOpenFile} projectName={projectName} relativePath={relativePath} + repoRoots={repoRoots} root={fileRoot ?? undefined} workspaceMutationId={workspaceMutationId} /> diff --git a/apps/web/src/components/files/filePath.test.ts b/apps/web/src/components/files/filePath.test.ts index b501562fee00..f8b6bbed0726 100644 --- a/apps/web/src/components/files/filePath.test.ts +++ b/apps/web/src/components/files/filePath.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { fileBreadcrumbChildren, fileBreadcrumbParent, fileBreadcrumbs } from "./filePath"; +import { + buildRootLabels, + fileBreadcrumbChildren, + fileBreadcrumbParent, + fileBreadcrumbs, + labelForRoot, +} from "./filePath"; describe("fileBreadcrumbs", () => { it("builds project, directory, and file crumbs", () => { @@ -95,3 +101,12 @@ describe("fileBreadcrumbParent", () => { expect(fileBreadcrumbParent(path)).toBe(expected); }); }); + +describe("buildRootLabels", () => { + it("labels roots by folder name and grows the label when names collide", () => { + const labels = buildRootLabels(["/work/app", "/a/shared", "/b/shared"]); + expect(labelForRoot(labels, "/work/app")).toBe("app"); + expect(labelForRoot(labels, "/a/shared")).toBe("a/shared"); + expect(labelForRoot(labels, "/b/shared/")).toBe("b/shared"); + }); +}); diff --git a/apps/web/src/components/files/filePath.ts b/apps/web/src/components/files/filePath.ts index e819315310b8..5a9a2da39851 100644 --- a/apps/web/src/components/files/filePath.ts +++ b/apps/web/src/components/files/filePath.ts @@ -60,3 +60,54 @@ export function fileBreadcrumbParent(directoryPath: string): string | null { const separatorIndex = directoryPath.lastIndexOf("/"); return separatorIndex === -1 ? "" : directoryPath.slice(0, separatorIndex); } + +/** Label for a root, tolerating the server's normalized form (no trailing separator). */ +export function labelForRoot( + labels: ReadonlyMap, + root: string, +): string | undefined { + const exact = labels.get(root); + if (exact !== undefined) return exact; + const trimmed = root.replace(/[\\/]+$/, ""); + for (const [candidate, label] of labels) { + if (candidate.replace(/[\\/]+$/, "") === trimmed) return label; + } + return undefined; +} + +/** + * Assign each repo root a unique, human-readable label for the tree's top-level + * grouping. Prefer the folder basename (matching the per-repo git controls); + * when two roots share a basename, grow the label by parent segments until the + * labels are distinct. + */ +export function buildRootLabels(roots: readonly string[]): Map { + const segments = new Map(); + for (const root of roots) { + segments.set( + root, + root + .replaceAll("\\", "/") + .replace(/\/+$/, "") + .split("/") + .filter((segment) => segment.length > 0), + ); + } + + const labels = new Map(); + for (const root of roots) { + const parts = segments.get(root) ?? []; + let depth = 1; + let label = parts.slice(-depth).join("/") || root; + const collidesAtDepth = () => + roots.some( + (other) => other !== root && (segments.get(other) ?? []).slice(-depth).join("/") === label, + ); + while (collidesAtDepth() && depth < parts.length) { + depth += 1; + label = parts.slice(-depth).join("/"); + } + labels.set(root, label); + } + return labels; +} From 50790603e8f80a508baa7a45ecbd1467f5d44338 Mon Sep 17 00:00:00 2001 From: Rahat Hameed Date: Fri, 25 Sep 2026 18:24:30 +0500 Subject: [PATCH 5/6] fix(web): open a repo root outside the workspace root as a folder A link to the root folder of a repo that lives outside the workspace root failed with "Failed to read workspace file". The resolver only matches paths strictly inside a root, so the root itself arrived as a host path, and the panel keeps the read error for a host folder. The panel now opens a host path that is one of the project's repo roots as a folder, and the file tree selects that repo's top-level node. Refs #302 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/files/FileBrowserPanel.tsx | 8 ++++++-- apps/web/src/components/files/FilePreviewPanel.tsx | 8 ++++++-- apps/web/src/components/files/filePath.test.ts | 11 +++++++++++ apps/web/src/components/files/filePath.ts | 6 ++++++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 565b2cccd49d..24a04e676e33 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -137,12 +137,16 @@ export default function FileBrowserPanel({ }, [multiRepoRootsKey]); // Tree paths sit under their repo's label, so an open from outside the tree // (a chat link, the file picker) maps onto that key to be found and revealed. + // A repo root linked by its absolute path selects that repo's top-level node. const selectedLabel = rootLabels && selectedRoot ? labelForRoot(rootLabels, selectedRoot) : undefined; + const selectedRootLabel = + rootLabels && selectedRelativePath ? labelForRoot(rootLabels, selectedRelativePath) : undefined; const selectedPath = - selectedRelativePath && selectedLabel !== undefined + selectedRootLabel ?? + (selectedRelativePath && selectedLabel !== undefined ? `${selectedLabel}/${selectedRelativePath}` - : selectedRelativePath; + : selectedRelativePath); const { entries: directoryEntries, load, diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index e15042490c27..470046c6f5f2 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -57,6 +57,7 @@ import { DelimitedTablePreview } from "./DelimitedTablePreview"; import FileBrowserPanel from "./FileBrowserPanel"; import { FileBreadcrumbs } from "./FileBreadcrumbs"; import { FileMarkdownPreview } from "./FileMarkdownPreview"; +import { isRepoRootPath } from "./filePath"; import { type FileCommentAnnotationEntry, type FileCommentAnnotationGroup, @@ -965,8 +966,11 @@ export default function FilePreviewPanel({ // a file surface and the read fails. Keep the breadcrumbs, drop the preview // pane, and let the tree fill the surface with the folder revealed. Mutation // refresh stays on so the surface notices if the path becomes a file. A host - // path cannot be revealed in the workspace tree, so it keeps the read error. - const isDirectory = file.isNotFile && !isHostFile; + // path cannot be revealed in the workspace tree, so it keeps the read error, + // unless it is a repo root outside the workspace root, which has a tree node. + const isDirectory = + file.isNotFile && + (!isHostFile || (relativePath !== null && isRepoRootPath(repoRoots, relativePath))); // Everything preview-related keys off previewPath; a folder has no preview. const previewPath = isDirectory ? null : relativePath; const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); diff --git a/apps/web/src/components/files/filePath.test.ts b/apps/web/src/components/files/filePath.test.ts index f8b6bbed0726..c44f9b72ef37 100644 --- a/apps/web/src/components/files/filePath.test.ts +++ b/apps/web/src/components/files/filePath.test.ts @@ -5,6 +5,7 @@ import { fileBreadcrumbChildren, fileBreadcrumbParent, fileBreadcrumbs, + isRepoRootPath, labelForRoot, } from "./filePath"; @@ -110,3 +111,13 @@ describe("buildRootLabels", () => { expect(labelForRoot(labels, "/b/shared/")).toBe("b/shared"); }); }); + +describe("isRepoRootPath", () => { + it("matches a repo root itself, not a path inside it", () => { + const roots = ["/work/app", "/downloads/outlier/"]; + expect(isRepoRootPath(roots, "/downloads/outlier")).toBe(true); + expect(isRepoRootPath(roots, "/work/app/")).toBe(true); + expect(isRepoRootPath(roots, "/work/app/src")).toBe(false); + expect(isRepoRootPath(undefined, "/work/app")).toBe(false); + }); +}); diff --git a/apps/web/src/components/files/filePath.ts b/apps/web/src/components/files/filePath.ts index 5a9a2da39851..b3a1aa44442d 100644 --- a/apps/web/src/components/files/filePath.ts +++ b/apps/web/src/components/files/filePath.ts @@ -61,6 +61,12 @@ export function fileBreadcrumbParent(directoryPath: string): string | null { return separatorIndex === -1 ? "" : directoryPath.slice(0, separatorIndex); } +/** Whether `path` is one of `roots`, tolerating a trailing separator on either. */ +export function isRepoRootPath(roots: readonly string[] | undefined, path: string): boolean { + const trimmed = path.replace(/[\\/]+$/, ""); + return roots?.some((root) => root.replace(/[\\/]+$/, "") === trimmed) ?? false; +} + /** Label for a root, tolerating the server's normalized form (no trailing separator). */ export function labelForRoot( labels: ReadonlyMap, From 9ed154f57ac1c5e24029c1c444cf53db69bf2dd3 Mon Sep 17 00:00:00 2001 From: Rahat Hameed Date: Fri, 25 Sep 2026 18:54:29 +0500 Subject: [PATCH 6/6] fix(web): open the workspace root as a folder A link to the workspace root itself failed with "Failed to read workspace file", in single-repo and multi-repo projects alike. It arrived as a host path, like a repo root outside the workspace root did. The panel now opens the workspace root as a folder too, and the file tree shows the whole workspace with nothing selected. Refs #302 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/files/FileBrowserPanel.tsx | 11 +++++++---- apps/web/src/components/files/FilePreviewPanel.tsx | 7 ++++--- apps/web/src/components/files/filePath.test.ts | 14 +++++++------- apps/web/src/components/files/filePath.ts | 2 +- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 24a04e676e33..72becbdf543e 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -22,7 +22,7 @@ import { readLocalApi } from "~/localApi"; import { T3_PIERRE_ICONS } from "~/pierre-icons"; import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; -import { buildRootLabels, labelForRoot } from "./filePath"; +import { buildRootLabels, isRootPath, labelForRoot } from "./filePath"; import { createFileTreeDragMentionController } from "./fileTreeDragMention"; import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; import { buildFileTreePathUpdates } from "./fileTreePathReconciliation"; @@ -138,15 +138,18 @@ export default function FileBrowserPanel({ // Tree paths sit under their repo's label, so an open from outside the tree // (a chat link, the file picker) maps onto that key to be found and revealed. // A repo root linked by its absolute path selects that repo's top-level node. + // The workspace root is the whole tree, so it selects nothing. const selectedLabel = rootLabels && selectedRoot ? labelForRoot(rootLabels, selectedRoot) : undefined; const selectedRootLabel = rootLabels && selectedRelativePath ? labelForRoot(rootLabels, selectedRelativePath) : undefined; const selectedPath = selectedRootLabel ?? - (selectedRelativePath && selectedLabel !== undefined - ? `${selectedLabel}/${selectedRelativePath}` - : selectedRelativePath); + (selectedRelativePath && isRootPath([cwd], selectedRelativePath) + ? null + : selectedRelativePath && selectedLabel !== undefined + ? `${selectedLabel}/${selectedRelativePath}` + : selectedRelativePath); const { entries: directoryEntries, load, diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 470046c6f5f2..3e68520a1c48 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -57,7 +57,7 @@ import { DelimitedTablePreview } from "./DelimitedTablePreview"; import FileBrowserPanel from "./FileBrowserPanel"; import { FileBreadcrumbs } from "./FileBreadcrumbs"; import { FileMarkdownPreview } from "./FileMarkdownPreview"; -import { isRepoRootPath } from "./filePath"; +import { isRootPath } from "./filePath"; import { type FileCommentAnnotationEntry, type FileCommentAnnotationGroup, @@ -967,10 +967,11 @@ export default function FilePreviewPanel({ // pane, and let the tree fill the surface with the folder revealed. Mutation // refresh stays on so the surface notices if the path becomes a file. A host // path cannot be revealed in the workspace tree, so it keeps the read error, - // unless it is a repo root outside the workspace root, which has a tree node. + // unless it is the workspace root or a repo root, which the tree shows. const isDirectory = file.isNotFile && - (!isHostFile || (relativePath !== null && isRepoRootPath(repoRoots, relativePath))); + (!isHostFile || + (relativePath !== null && isRootPath([cwd, ...(repoRoots ?? [])], relativePath))); // Everything preview-related keys off previewPath; a folder has no preview. const previewPath = isDirectory ? null : relativePath; const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); diff --git a/apps/web/src/components/files/filePath.test.ts b/apps/web/src/components/files/filePath.test.ts index c44f9b72ef37..b1ebd09a79dd 100644 --- a/apps/web/src/components/files/filePath.test.ts +++ b/apps/web/src/components/files/filePath.test.ts @@ -5,7 +5,7 @@ import { fileBreadcrumbChildren, fileBreadcrumbParent, fileBreadcrumbs, - isRepoRootPath, + isRootPath, labelForRoot, } from "./filePath"; @@ -112,12 +112,12 @@ describe("buildRootLabels", () => { }); }); -describe("isRepoRootPath", () => { - it("matches a repo root itself, not a path inside it", () => { +describe("isRootPath", () => { + it("matches a root itself, not a path inside it", () => { const roots = ["/work/app", "/downloads/outlier/"]; - expect(isRepoRootPath(roots, "/downloads/outlier")).toBe(true); - expect(isRepoRootPath(roots, "/work/app/")).toBe(true); - expect(isRepoRootPath(roots, "/work/app/src")).toBe(false); - expect(isRepoRootPath(undefined, "/work/app")).toBe(false); + expect(isRootPath(roots, "/downloads/outlier")).toBe(true); + expect(isRootPath(roots, "/work/app/")).toBe(true); + expect(isRootPath(roots, "/work/app/src")).toBe(false); + expect(isRootPath(undefined, "/work/app")).toBe(false); }); }); diff --git a/apps/web/src/components/files/filePath.ts b/apps/web/src/components/files/filePath.ts index b3a1aa44442d..b09f56d56131 100644 --- a/apps/web/src/components/files/filePath.ts +++ b/apps/web/src/components/files/filePath.ts @@ -62,7 +62,7 @@ export function fileBreadcrumbParent(directoryPath: string): string | null { } /** Whether `path` is one of `roots`, tolerating a trailing separator on either. */ -export function isRepoRootPath(roots: readonly string[] | undefined, path: string): boolean { +export function isRootPath(roots: readonly string[] | undefined, path: string): boolean { const trimmed = path.replace(/[\\/]+$/, ""); return roots?.some((root) => root.replace(/[\\/]+$/, "") === trimmed) ?? false; }