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: ["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/workspace/api", "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/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..fd01909953cd 100644 --- a/apps/server/src/provider/RuntimeInstructions.ts +++ b/apps/server/src/provider/RuntimeInstructions.ts @@ -2,18 +2,36 @@ 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 = ` +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. + +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. + + +git -C stash show lists dir/file.py. Write \`/dir/file.py\`, with the real absolute path of that repository in place of . + + +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. */ 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..961444398f15 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; } @@ -2559,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); }, @@ -2600,6 +2606,7 @@ function useChatMarkdownState({ displayPath={fileLinkMeta.displayPath} panelPath={panelPath} line={fileLinkMeta.line} + fileRoot={fileLinkMeta.fileRoot} label={labelParts.join(" ยท ")} copyMarkdown={copyMarkdown} theme={resolvedTheme} @@ -2668,6 +2675,7 @@ function useChatMarkdownState({ linkedThreadPullRequestFor, resolveThreadPullRequest, resolvedTheme, + roots, serverConfig, skills, text, @@ -2698,6 +2706,7 @@ function useChatMarkdownState({ linkedThreadPullRequestFor, resolveThreadPullRequest, resolvedTheme, + roots, serverConfig, skills, text, @@ -2845,6 +2854,7 @@ const CHAT_MARKDOWN_COMPONENTS = { updateThreadPullRequestLink, fileLinkChip, renderContextReference, + roots, } = use(ChatMarkdownRendererContext); const citation = href ? parseAssistantCitationHref(href) : null; if (citation) return ; @@ -2860,7 +2870,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 +3073,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/FileBreadcrumbs.tsx b/apps/web/src/components/files/FileBreadcrumbs.tsx index a98b4dca69d2..e04b40a5634d 100644 --- a/apps/web/src/components/files/FileBreadcrumbs.tsx +++ b/apps/web/src/components/files/FileBreadcrumbs.tsx @@ -22,19 +22,25 @@ import { cn } from "~/lib/utils"; import { isAbsolutePath } from "~/terminal-links"; import { + buildRootLabels, type FileBreadcrumb, fileBreadcrumbChildren, fileBreadcrumbParent, fileBreadcrumbs, + labelForRoot, } from "./filePath"; 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; + /** 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; } @@ -267,14 +273,40 @@ 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 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( - () => 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 +320,7 @@ export function FileBreadcrumbs(props: FileBreadcrumbsProps) { ) : hostPath ? ( ) : ( - + )}
)); diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 5032b3aa3ae4..72becbdf543e 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, isRootPath, labelForRoot } from "./filePath"; import { createFileTreeDragMentionController } from "./fileTreeDragMention"; import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; import { buildFileTreePathUpdates } from "./fileTreePathReconciliation"; @@ -34,6 +35,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 @@ -53,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 ( @@ -154,7 +109,8 @@ export default function FileBrowserPanel({ environmentId, cwd, projectName, - selectedPath, + selectedPath: selectedRelativePath, + selectedRoot, selectedPathRevealId, repoRoots, onOpenFile, @@ -179,6 +135,21 @@ 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. + // 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 && 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 d3f8db1cfef9..3e68520a1c48 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 { isRootPath } from "./filePath"; import { type FileCommentAnnotationEntry, type FileCommentAnnotationGroup, @@ -965,8 +966,12 @@ 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 the workspace root or a repo root, which the tree shows. + const isDirectory = + file.isNotFile && + (!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); @@ -1119,6 +1124,8 @@ export default function FilePreviewPanel({ onOpenFile={onOpenFile} projectName={projectName} relativePath={relativePath} + repoRoots={repoRoots} + root={fileRoot ?? undefined} workspaceMutationId={workspaceMutationId} /> @@ -1315,6 +1322,7 @@ export default function FilePreviewPanel({ cwd={cwd} projectName={projectName} selectedPath={relativePath} + selectedRoot={fileRoot ?? undefined} selectedPathRevealId={revealRequestId} repoRoots={repoRoots} onOpenFile={onOpenFile} diff --git a/apps/web/src/components/files/filePath.test.ts b/apps/web/src/components/files/filePath.test.ts index b501562fee00..b1ebd09a79dd 100644 --- a/apps/web/src/components/files/filePath.test.ts +++ b/apps/web/src/components/files/filePath.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vite-plus/test"; -import { fileBreadcrumbChildren, fileBreadcrumbParent, fileBreadcrumbs } from "./filePath"; +import { + buildRootLabels, + fileBreadcrumbChildren, + fileBreadcrumbParent, + fileBreadcrumbs, + isRootPath, + labelForRoot, +} from "./filePath"; describe("fileBreadcrumbs", () => { it("builds project, directory, and file crumbs", () => { @@ -95,3 +102,22 @@ 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(["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/work/app", "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/a/shared", "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/b/shared"]); + expect(labelForRoot(labels, "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/work/app")).toBe("app"); + expect(labelForRoot(labels, "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/a/shared")).toBe("a/shared"); + expect(labelForRoot(labels, "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/b/shared/")).toBe("b/shared"); + }); +}); + +describe("isRootPath", () => { + it("matches a root itself, not a path inside it", () => { + const roots = ["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/work/app", "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/downloads/outlier/"]; + expect(isRootPath(roots, "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/downloads/outlier")).toBe(true); + expect(isRootPath(roots, "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/work/app/")).toBe(true); + expect(isRootPath(roots, "/work/app/src")).toBe(false); + expect(isRootPath(undefined, "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/work/app")).toBe(false); + }); +}); diff --git a/apps/web/src/components/files/filePath.ts b/apps/web/src/components/files/filePath.ts index e819315310b8..b09f56d56131 100644 --- a/apps/web/src/components/files/filePath.ts +++ b/apps/web/src/components/files/filePath.ts @@ -60,3 +60,60 @@ export function fileBreadcrumbParent(directoryPath: string): string | null { const separatorIndex = directoryPath.lastIndexOf("/"); return separatorIndex === -1 ? "" : directoryPath.slice(0, separatorIndex); } + +/** Whether `path` is one of `roots`, tolerating a trailing separator on either. */ +export function isRootPath(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, + 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; +}