From 32db7e191b690f62579e9d74170a9e7906c23c8b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 19 Oct 2026 00:27:51 +0000 Subject: [PATCH 1/6] fix(mobile): open transcript file links at the resolved path --- .../features/files/ThreadFilesRouteScreen.tsx | 98 +++++++++++--- .../src/features/files/filePath.test.ts | 27 ++++ apps/mobile/src/features/files/filePath.ts | 23 +++- .../src/features/threads/ThreadFeed.tsx | 125 ++++++++++++++---- .../src/features/threads/fileChipMenu.test.ts | 13 ++ .../src/features/threads/fileChipMenu.ts | 9 +- apps/web/src/components/ChatMarkdown.tsx | 2 +- apps/web/src/workspaceBasenameLookup.test.ts | 2 +- packages/client-runtime/package.json | 4 + .../src/workspaceBasenameLookup.ts | 0 10 files changed, 250 insertions(+), 53 deletions(-) rename {apps/web => packages/client-runtime}/src/workspaceBasenameLookup.ts (100%) diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index a95f8ea8e997..0c1261b8dc5d 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -58,6 +58,8 @@ import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; import { basename, fileHeaderSubtitle, + fileRoutePathSegments, + resolveWorkspaceFilePath, isAudioPreviewFile, isMarkdownPreviewFile, isSvgImagePreviewFile, @@ -585,6 +587,32 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( props.route.params, ); + const fileAccessSession = useEnvironmentQuery( + environmentId !== null ? environmentSession.sessionStateAtom(environmentId) : null, + ); + const fileEnvironment = useEnvironmentPresentation(environmentId); + const fileAccess = resolveFilesystemReadAccess({ + isCatalogReady: fileEnvironment.isReady, + connection: fileEnvironment.presentation?.connection ?? null, + session: fileAccessSession.data, + sessionError: fileAccessSession.error, + }); + const { canReadFiles } = fileAccess; + const targetPath = + cwd !== null && relativePath !== null ? resolveWorkspaceFilePath(cwd, relativePath) : null; + const metadata = useEnvironmentQuery( + canReadFiles && environmentId !== null && targetPath !== null + ? projectEnvironment.fileMetadata({ environmentId, input: { path: targetPath } }) + : null, + ); + const metadataPending = !metadata.isSuccess && metadata.error === null; + const directoryPath = metadata.data?.kind === "directory" ? targetPath : null; + const [searchQuery, setSearchQuery] = useState(""); + const entriesQuery = useFileTreeEntries({ + environmentId, + cwd: canReadFiles ? directoryPath : null, + searchQuery, + }); const [modeOverride, setModeOverride] = useState<{ readonly path: string; readonly mode: FileViewMode; @@ -612,7 +640,11 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { const resolvedActiveMode = isVideoFile || isAudioFile ? "preview" : canPreview ? activeMode : "source"; const assetPreviewPath = - isBrowserFile || isImageFile || isVideoFile || isAudioFile ? relativePath : null; + !metadataPending && + directoryPath === null && + (isBrowserFile || isImageFile || isVideoFile || isAudioFile) + ? relativePath + : null; const assetPreview = useWorkspaceFileAssetUrlState({ cwd, environmentId, @@ -663,21 +695,12 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { void assetPreview.refresh().finally(() => setPreviewRevision((current) => current + 1)); }; const needsFileContents = + !metadataPending && + directoryPath === null && relativePath !== null && !isVideoFile && !isAudioFile && (resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath)); - const fileAccessSession = useEnvironmentQuery( - environmentId !== null ? environmentSession.sessionStateAtom(environmentId) : null, - ); - const fileEnvironment = useEnvironmentPresentation(environmentId); - const fileAccess = resolveFilesystemReadAccess({ - isCatalogReady: fileEnvironment.isReady, - connection: fileEnvironment.presentation?.connection ?? null, - session: fileAccessSession.data, - sessionError: fileAccessSession.error, - }); - const { canReadFiles } = fileAccess; const fileQuery = useEnvironmentQuery( canReadFiles && environmentId !== null && @@ -693,7 +716,9 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { const fileData = fileQuery.data as ProjectReadFileResult | null; const handleSelectFile = (path: string) => { - const segments = path.split("/").filter(Boolean); + const segments = fileRoutePathSegments( + directoryPath === null ? path : resolveWorkspaceFilePath(directoryPath, path), + ); // A draft has no thread. `ThreadFile` would stringify null and then wait forever for a // thread to resolve, so a draft stays on its own route and carries its workspace along. if (threadId === null) { @@ -707,20 +732,22 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { ); return; } - navigation.navigate("ThreadFile", { + const destination = { environmentId: String(environmentId), threadId: String(threadId), path: segments, - }); + }; + if (directoryPath !== null) navigation.dispatch(StackActions.push("ThreadFile", destination)); + else navigation.navigate("ThreadFile", destination); }; const renderInspector = (headerInset: number) => fileInspector.supported && environmentId !== null && cwd !== null ? ( ) : undefined; @@ -877,6 +904,41 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { ); } + if (metadataPending) { + return ; + } + if (directoryPath !== null) { + return ( + + + + + + + + ); + } + const headerSubtitle = fileHeaderSubtitle(projectName, relativePath); return ( diff --git a/apps/mobile/src/features/files/filePath.test.ts b/apps/mobile/src/features/files/filePath.test.ts index 507c5c6a45d5..cca65a7d373b 100644 --- a/apps/mobile/src/features/files/filePath.test.ts +++ b/apps/mobile/src/features/files/filePath.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { fileRoutePathSegments, + resolveFileLinkPath, isSvgImagePreviewFile, resolveWorkspaceRelativeFilePath, fileHeaderSubtitle, @@ -16,6 +17,23 @@ describe("fileRoutePathSegments", () => { }); }); +describe("resolveFileLinkPath", () => { + it.each([ + ["/home/maria/project", "~/.agents/AGENTS.md", "/home/maria/.agents/AGENTS.md"], + ["/Users/maria/project", "~/notes.md", "/Users/maria/notes.md"], + ["C:\\Users\\maria\\project", "~\\.agents\\AGENTS.md", "C:\\Users\\maria\\.agents\\AGENTS.md"], + ])("expands a home link from %s", (cwd, path, expected) => { + expect(resolveFileLinkPath(cwd, path)).toBe(expected); + expect(fileRoutePathSegments(expected).join("/")).toBe(expected); + }); + + it("does not turn an unresolved home into a workspace path", () => { + expect(resolveFileLinkPath("/repo", "~/notes.md")).toBeNull(); + expect(resolveFileLinkPath(null, "~/notes.md")).toBeNull(); + expect(resolveFileLinkPath("/repo", "../notes.md")).toBeNull(); + }); +}); + describe("resolveWorkspaceRelativeFilePath", () => { it("keeps normalized workspace-relative paths", () => { expect(resolveWorkspaceRelativeFilePath("/repo", "./src/../src/main.ts")).toBe("src/main.ts"); @@ -30,6 +48,15 @@ describe("resolveWorkspaceRelativeFilePath", () => { ); }); + it("recognizes home links inside the workspace", () => { + expect(resolveWorkspaceRelativeFilePath("/home/maria/project", "~/project/src/main.ts")).toBe( + "src/main.ts", + ); + expect( + resolveWorkspaceRelativeFilePath("/home/maria/project", "~/.agents/AGENTS.md"), + ).toBeNull(); + }); + it("rejects paths outside the workspace", () => { expect(resolveWorkspaceRelativeFilePath("/repo", "/other/main.ts")).toBeNull(); expect(resolveWorkspaceRelativeFilePath("/repo", "../other/main.ts")).toBeNull(); diff --git a/apps/mobile/src/features/files/filePath.ts b/apps/mobile/src/features/files/filePath.ts index 654a668cb2c3..aa6a01cdb17a 100644 --- a/apps/mobile/src/features/files/filePath.ts +++ b/apps/mobile/src/features/files/filePath.ts @@ -1,3 +1,4 @@ +import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; import { isWorkspaceAudioPreviewPath, isWorkspaceVideoPreviewPath, @@ -66,16 +67,26 @@ function normalizeRelativePath(value: string): string | null { return segments.length > 0 ? segments.join("/") : null; } -export function resolveWorkspaceRelativeFilePath( +export function resolveFileLinkPath( workspaceRoot: string | null | undefined, targetPath: string, ): string | null { - if (!isAbsolutePath(targetPath)) { - if (targetPath.startsWith("~/") || targetPath.startsWith("~\\")) { - return null; - } - return normalizeRelativePath(targetPath); + if (targetPath.startsWith("~/") || targetPath.startsWith("~\\")) { + if (!workspaceRoot) return null; + const expanded = resolvePathLinkTarget(targetPath, workspaceRoot); + return isAbsolutePath(expanded) ? expanded : null; } + return isAbsolutePath(targetPath) ? targetPath : normalizeRelativePath(targetPath); +} + +export function resolveWorkspaceRelativeFilePath( + workspaceRoot: string | null | undefined, + targetPath: string, +): string | null { + const resolved = resolveFileLinkPath(workspaceRoot, targetPath); + if (resolved === null) return null; + targetPath = resolved; + if (!isAbsolutePath(targetPath)) return targetPath; if (!workspaceRoot) { return null; } diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 73b8e5d8ae0e..257a07107fa7 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -21,6 +21,7 @@ import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-mes import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; import type { WorkflowOpening } from "@t3tools/client-runtime/state/subagentRuntime"; import { + AuthFilesystemReadScope, type OrchestrationMessageContext, ThreadId, type ChatAttachment, @@ -50,7 +51,11 @@ import { import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; -import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + executeAtomQuery, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { classifyMarkdownImageSource, markdownImageSourceFragment, @@ -211,7 +216,13 @@ import { useRefreshAssetUrl, } from "../../state/assets"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; -import { usePreparedConnection } from "../../state/session"; +import { readEnvironmentScope, usePreparedConnection } from "../../state/session"; +import { projectEnvironment } from "../../state/projects"; +import { + needsWorkspaceBasenameLookup, + pickWorkspaceBasenameMatch, + WORKSPACE_BASENAME_LOOKUP_LIMIT, +} from "@t3tools/client-runtime/workspace-basename-lookup"; import { useLiveThreadLinkLabels } from "../../state/entities"; import { useThreadSelection } from "../../state/use-thread-selection"; import { composerDocumentAttachmentRecord } from "../../lib/composerContext"; @@ -223,7 +234,7 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { useV2ItemSupport } from "../../state/v2-item-support"; import { fileRoutePathSegments, - isAbsolutePath, + resolveFileLinkPath, resolveWorkspaceRelativeFilePath, } from "../files/filePath"; import { waitForThreadShellReady } from "./threadForkNavigation"; @@ -1741,6 +1752,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; const [expandedFile, setExpandedFile] = useState(null); const [expandedVideo, setExpandedVideo] = useState(null); + const markdownLinkRequest = useRef(null); + useFocusEffect(useCallback(() => () => markdownLinkRequest.current?.abort(), [])); + const markdownLinkScope = JSON.stringify([ + props.environmentId, + props.threadId, + props.contentPresentation.kind, + ]); + const previousMarkdownLinkScope = useRef(markdownLinkScope); + useLayoutEffect(() => { + if (previousMarkdownLinkScope.current === markdownLinkScope) return; + previousMarkdownLinkScope.current = markdownLinkScope; + markdownLinkRequest.current?.abort(); + }, [markdownLinkScope]); const fileShareSourceIdentifier = useId(); const shareFileChip = useFileChipShare( props.environmentId, @@ -1790,6 +1814,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const screenColor = theme["--color-screen"]; const userBubbleColor = theme["--color-user-bubble"]; const onMarkdownLinkPress = (href: string, imageEmbed = false) => { + markdownLinkRequest.current?.abort(); + const request = new AbortController(); + markdownLinkRequest.current = request; const linkedThreadId = parseThreadLinkHref(href); if (linkedThreadId) { navigation.navigate("Thread", { @@ -1806,41 +1833,89 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }); if (media) { void Haptics.selectionAsync(); - if (media.kind === "video") { - setExpandedVideo((current) => current ?? media.source); - } else { - setExpandedFile((current) => current ?? media.source); - } + const openPreview = () => { + if (request.signal.aborted) return; + if (media.kind === "video") { + setExpandedVideo((current) => current ?? media.source); + } else { + setExpandedFile((current) => current ?? media.source); + } + }; + const resource = "resource" in media.source ? media.source.resource : null; + const mediaPath = resource && "path" in resource ? resource.path : null; + if ( + typeof mediaPath === "string" && + readEnvironmentScope(props.environmentId, AuthFilesystemReadScope) + ) { + void executeAtomQuery( + appAtomRegistry, + projectEnvironment.fileMetadata({ + environmentId: props.environmentId, + input: { path: mediaPath }, + }), + { signal: request.signal, reportFailure: false, reportDefect: false }, + ).then((result) => { + if (request.signal.aborted) return; + if (result._tag === "Success" && result.value?.kind === "directory") { + navigation.navigate("ThreadFile", { + environmentId: String(props.environmentId), + threadId: String(props.threadId), + path: fileRoutePathSegments(mediaPath), + }); + } else openPreview(); + }, openPreview); + } else openPreview(); return; } const presentation = resolveMarkdownLinkPresentation(href); if (presentation.kind === "file") { - const relativePath = resolveWorkspaceRelativeFilePath(props.workspaceRoot, presentation.path); - if (relativePath) { + const path = resolveFileLinkPath(props.workspaceRoot, presentation.path); + if (path === null) return; + const relativePath = resolveWorkspaceRelativeFilePath(props.workspaceRoot, path); + const openAt = (target: string) => { + if (request.signal.aborted) return; void Haptics.selectionAsync(); navigation.navigate("ThreadFile", { environmentId: String(props.environmentId), threadId: String(props.threadId), - path: fileRoutePathSegments(relativePath), + path: fileRoutePathSegments(target), ...(presentation.line ? { line: String(presentation.line) } : {}), }); - return; - } - } - - if (presentation.kind === "file" && isAbsolutePath(presentation.path)) { - void Haptics.selectionAsync(); - navigation.navigate("ThreadFile", { - environmentId: String(props.environmentId), - threadId: String(props.threadId), - path: fileRoutePathSegments(presentation.path), - ...(presentation.line ? { line: String(presentation.line) } : {}), - }); + }; + if ( + props.workspaceRoot && + relativePath && + needsWorkspaceBasenameLookup(relativePath) && + readEnvironmentScope(props.environmentId, AuthFilesystemReadScope) + ) { + void executeAtomQuery( + appAtomRegistry, + projectEnvironment.searchEntries({ + environmentId: props.environmentId, + input: { + cwd: props.workspaceRoot, + query: relativePath, + kind: "file", + limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, + }, + }), + { signal: request.signal, reportFailure: false, reportDefect: false }, + ).then( + (result) => { + const match = + result._tag === "Success" + ? pickWorkspaceBasenameMatch(relativePath, result.value.entries) + : null; + openAt(match ?? relativePath); + }, + () => openAt(relativePath), + ); + } else openAt(relativePath ?? path); return; } - if (presentation.kind !== "file" && presentation.href) { + if (presentation.href) { if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) { setExpandedFile( (current) => current ?? { kind: "pdf", uri: presentation.href!, name: "Document.pdf" }, @@ -2291,9 +2366,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }; const onPressPreview = (source: FilePreviewSource) => { + markdownLinkRequest.current?.abort(); setExpandedFile((current) => current ?? source); }; const onPressVideo = (attachment: ChatFileAttachment, sourceIdentifier: string) => { + markdownLinkRequest.current?.abort(); setExpandedVideo( (current) => current ?? attachmentVideoPreviewSource(props.environmentId, attachment, sourceIdentifier), diff --git a/apps/mobile/src/features/threads/fileChipMenu.test.ts b/apps/mobile/src/features/threads/fileChipMenu.test.ts index 1627a72da5de..a05aa6a6c503 100644 --- a/apps/mobile/src/features/threads/fileChipMenu.test.ts +++ b/apps/mobile/src/features/threads/fileChipMenu.test.ts @@ -21,6 +21,19 @@ describe("resolveFileChipTarget", () => { expect(resolveFileChipTarget("src/app.ts", null)).toEqual({ relativePath: "src/app.ts" }); }); + it("resolves home links for navigation and menu actions", () => { + expect(resolveFileChipTarget("~/.agents/AGENTS.md:314", "/home/maria/repo")).toEqual({ + fullPath: "/home/maria/.agents/AGENTS.md", + }); + expect(resolveFileChipTarget("~/repo/src/main.ts:12", "/home/maria/repo")).toEqual({ + fullPath: "/home/maria/repo/src/main.ts", + relativePath: "src/main.ts", + }); + expect(resolveFileChipTarget("~/.agents/AGENTS.md", "C:\\Users\\maria\\repo")).toEqual({ + fullPath: "C:\\Users\\maria\\.agents\\AGENTS.md", + }); + }); + it("ignores links that are not files or cannot be opened", () => { expect(resolveFileChipTarget("https://example.com/app.ts", "/repo")).toBeNull(); expect(resolveFileChipTarget("~/report.md", "/repo")).toBeNull(); diff --git a/apps/mobile/src/features/threads/fileChipMenu.ts b/apps/mobile/src/features/threads/fileChipMenu.ts index d55d3e4b5964..83a1a841261f 100644 --- a/apps/mobile/src/features/threads/fileChipMenu.ts +++ b/apps/mobile/src/features/threads/fileChipMenu.ts @@ -6,6 +6,7 @@ import { hostPreviewMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import { isAbsolutePath, + resolveFileLinkPath, resolveWorkspaceFilePath, resolveWorkspaceRelativeFilePath, } from "../files/filePath"; @@ -23,9 +24,11 @@ export function resolveFileChipTarget( ): FileChipTarget | null { const presentation = resolveMarkdownLinkPresentation(href); if (presentation.kind !== "file") return null; - const relativePath = resolveWorkspaceRelativeFilePath(workspaceRoot, presentation.path); - const fullPath = isAbsolutePath(presentation.path) - ? presentation.path + const path = resolveFileLinkPath(workspaceRoot, presentation.path); + if (path === null) return null; + const relativePath = resolveWorkspaceRelativeFilePath(workspaceRoot, path); + const fullPath = isAbsolutePath(path) + ? path : workspaceRoot && relativePath ? resolveWorkspaceFilePath(workspaceRoot, relativePath) : undefined; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a608ecb0298b..54b660797e29 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -199,7 +199,7 @@ import { needsWorkspaceBasenameLookup, pickWorkspaceBasenameMatch, WORKSPACE_BASENAME_LOOKUP_LIMIT, -} from "../workspaceBasenameLookup"; +} from "@t3tools/client-runtime/workspace-basename-lookup"; import { parseChangeRequestUrl, pullRequestCandidateUrlFromReferenceAutolink, diff --git a/apps/web/src/workspaceBasenameLookup.test.ts b/apps/web/src/workspaceBasenameLookup.test.ts index 23aeadc09cc8..ebc2b4723752 100644 --- a/apps/web/src/workspaceBasenameLookup.test.ts +++ b/apps/web/src/workspaceBasenameLookup.test.ts @@ -4,7 +4,7 @@ import { claimWorkspaceBasenameLookup, needsWorkspaceBasenameLookup, pickWorkspaceBasenameMatch, -} from "./workspaceBasenameLookup"; +} from "@t3tools/client-runtime/workspace-basename-lookup"; describe("needsWorkspaceBasenameLookup", () => { it("flags bare filenames", () => { diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 9eed1acbb883..5e90f49975d4 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./workspace-basename-lookup": { + "types": "./src/workspaceBasenameLookup.ts", + "default": "./src/workspaceBasenameLookup.ts" + }, "./composerThreadItems": { "types": "./src/composerThreadItems.ts", "default": "./src/composerThreadItems.ts" diff --git a/apps/web/src/workspaceBasenameLookup.ts b/packages/client-runtime/src/workspaceBasenameLookup.ts similarity index 100% rename from apps/web/src/workspaceBasenameLookup.ts rename to packages/client-runtime/src/workspaceBasenameLookup.ts From 4d8bf49d5c7fc80adc1145a0510522725baf670b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 19 Oct 2026 04:09:19 +0000 Subject: [PATCH 2/6] fix(mobile): recognize relative directory links --- apps/mobile/src/lib/markdownLinks.test.ts | 11 +++++++++++ apps/mobile/src/lib/markdownLinks.ts | 6 +++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/lib/markdownLinks.test.ts b/apps/mobile/src/lib/markdownLinks.test.ts index bf34d7cdc37a..30103d5518ec 100644 --- a/apps/mobile/src/lib/markdownLinks.test.ts +++ b/apps/mobile/src/lib/markdownLinks.test.ts @@ -14,6 +14,17 @@ describe("resolveMarkdownLinkIcon", () => { }); describe("resolveMarkdownLinkPresentation", () => { + it("opens relative directory links as workspace paths", () => { + expect(resolveMarkdownLinkPresentation("src/")).toMatchObject({ + kind: "file", + path: "src/", + }); + expect(resolveMarkdownLinkPresentation("docs/My Folder/")).toMatchObject({ + kind: "file", + path: "docs/My Folder/", + }); + }); + it("keeps thread-link hrefs for in-app navigation", () => { const href = "t3-thread://v1/thread-1"; expect(resolveMarkdownLinkPresentation(href)).toEqual({ kind: "link", href }); diff --git a/apps/mobile/src/lib/markdownLinks.ts b/apps/mobile/src/lib/markdownLinks.ts index e18fef748a97..481682cd955a 100644 --- a/apps/mobile/src/lib/markdownLinks.ts +++ b/apps/mobile/src/lib/markdownLinks.ts @@ -273,7 +273,11 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese } } catch {} - const target = parseMarkdownFileLink(normalized); + const target = + parseMarkdownFileLink(normalized) ?? + (/^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+$/.test(normalized) + ? { path: normalized } + : null); if (target) { return { kind: "file", From f2af9c416c19cd96aaa16028e858edfcf3007a50 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 20 Oct 2026 18:20:02 +0000 Subject: [PATCH 3/6] fix(mobile): directory links skip the file basename lookup --- apps/mobile/src/features/threads/ThreadFeed.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 4587c6c862a7..013aabb62cb0 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1903,6 +1903,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if ( props.workspaceRoot && relativePath && + !/[\\/]$/.test(presentation.path) && needsWorkspaceBasenameLookup(relativePath) && readEnvironmentScope(props.environmentId, AuthFilesystemReadScope) ) { From e965c48017a19ebaaa1294a8a618631685d27831 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 20 Oct 2026 18:40:06 +0000 Subject: [PATCH 4/6] fix(mobile): keep directory selections workspace relative --- apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 1c322c21ae78..82de8974654e 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -62,6 +62,7 @@ import { fileHeaderSubtitle, fileRoutePathSegments, resolveWorkspaceFilePath, + resolveWorkspaceRelativeFilePath, isAudioPreviewFile, isMarkdownPreviewFile, isSvgImagePreviewFile, @@ -718,8 +719,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { const fileData = fileQuery.data as ProjectReadFileResult | null; const handleSelectFile = (path: string) => { + const targetPath = + directoryPath === null ? path : resolveWorkspaceFilePath(directoryPath, path); const segments = fileRoutePathSegments( - directoryPath === null ? path : resolveWorkspaceFilePath(directoryPath, path), + resolveWorkspaceRelativeFilePath(cwd, targetPath) ?? targetPath, ); // A draft has no thread. `ThreadFile` would stringify null and then wait forever for a // thread to resolve, so a draft stays on its own route and carries its workspace along. From 1c1d2e42475d61ce386a58ee942bc61479495c20 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 20 Oct 2026 18:40:24 +0000 Subject: [PATCH 5/6] fix(mobile): preserve directories during basename lookup --- .../src/features/threads/ThreadFeed.tsx | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 013aabb62cb0..b697441edf53 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -241,6 +241,7 @@ import { useV2ItemSupport } from "../../state/v2-item-support"; import { fileRoutePathSegments, resolveFileLinkPath, + resolveWorkspaceFilePath, resolveWorkspaceRelativeFilePath, } from "../files/filePath"; import { waitForThreadShellReady } from "./threadForkNavigation"; @@ -1907,6 +1908,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { needsWorkspaceBasenameLookup(relativePath) && readEnvironmentScope(props.environmentId, AuthFilesystemReadScope) ) { + const originalPath = resolveWorkspaceFilePath(props.workspaceRoot, relativePath); void executeAtomQuery( appAtomRegistry, projectEnvironment.searchEntries({ @@ -1925,7 +1927,27 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { result._tag === "Success" ? pickWorkspaceBasenameMatch(relativePath, result.value.entries) : null; - openAt(match ?? relativePath); + if (!match || match === relativePath) { + openAt(relativePath); + return; + } + if (request.signal.aborted) return; + void executeAtomQuery( + appAtomRegistry, + projectEnvironment.fileMetadata({ + environmentId: props.environmentId, + input: { path: originalPath }, + }), + { signal: request.signal, reportFailure: false, reportDefect: false }, + ).then( + (metadata) => + openAt( + metadata._tag === "Success" && metadata.value?.kind !== "directory" + ? match + : relativePath, + ), + () => openAt(relativePath), + ); }, () => openAt(relativePath), ); From ab5bb4095eb27389adeef8de7f064bd93e2b13f1 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 20 Oct 2026 18:40:36 +0000 Subject: [PATCH 6/6] fix(mobile): cancel link lookups when previewing images --- apps/mobile/src/features/threads/ThreadFeed.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index b697441edf53..6a0adbb7f80c 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -2064,7 +2064,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { unavailable={false} alt={image.alt} actionsSource={media?.source.actionsSource} - onPressPreview={(source) => setExpandedFile((current) => current ?? source)} + onPressPreview={onPressPreview} /> ); } @@ -2082,7 +2082,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { alt={image.alt} srcFragment={markdownImageSourceFragment(image.href)} actionsSource={media?.source.actionsSource} - onPressPreview={(source) => setExpandedFile((current) => current ?? source)} + onPressPreview={onPressPreview} /> ); }; @@ -2111,7 +2111,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ? { ...actionsSource, resource: viewedImage.resource } : undefined } - onPressPreview={(source) => setExpandedFile((current) => current ?? source)} + onPressPreview={onPressPreview} /> ) : null; };