diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
index bd9105e8b849..82de8974654e 100644
--- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
+++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
@@ -60,6 +60,9 @@ import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
import {
basename,
fileHeaderSubtitle,
+ fileRoutePathSegments,
+ resolveWorkspaceFilePath,
+ resolveWorkspaceRelativeFilePath,
isAudioPreviewFile,
isMarkdownPreviewFile,
isSvgImagePreviewFile,
@@ -587,6 +590,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;
@@ -614,7 +643,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,
@@ -665,21 +698,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 &&
@@ -695,7 +719,11 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
const fileData = fileQuery.data as ProjectReadFileResult | null;
const handleSelectFile = (path: string) => {
- const segments = path.split("/").filter(Boolean);
+ const targetPath =
+ directoryPath === null ? path : resolveWorkspaceFilePath(directoryPath, path);
+ const segments = fileRoutePathSegments(
+ 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.
if (threadId === null) {
@@ -709,20 +737,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;
@@ -885,6 +915,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 d733b751ace6..6a0adbb7f80c 100644
--- a/apps/mobile/src/features/threads/ThreadFeed.tsx
+++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
@@ -27,6 +27,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,
@@ -56,7 +57,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,
@@ -217,7 +222,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";
@@ -229,7 +240,8 @@ import { useAtomCommand } from "../../state/use-atom-command";
import { useV2ItemSupport } from "../../state/v2-item-support";
import {
fileRoutePathSegments,
- isAbsolutePath,
+ resolveFileLinkPath,
+ resolveWorkspaceFilePath,
resolveWorkspaceRelativeFilePath,
} from "../files/filePath";
import { waitForThreadShellReady } from "./threadForkNavigation";
@@ -1735,6 +1747,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,
@@ -1801,6 +1826,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
);
};
const onMarkdownLinkPress = (href: string, imageEmbed = false) => {
+ markdownLinkRequest.current?.abort();
+ const request = new AbortController();
+ markdownLinkRequest.current = request;
const skill = enrichedSkillReference(href, props.skills);
if (skill) {
const instructionsPath = enrichedSkillInstructionsPath(skill);
@@ -1823,41 +1851,111 @@ 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 &&
+ !/[\\/]$/.test(presentation.path) &&
+ needsWorkspaceBasenameLookup(relativePath) &&
+ readEnvironmentScope(props.environmentId, AuthFilesystemReadScope)
+ ) {
+ const originalPath = resolveWorkspaceFilePath(props.workspaceRoot, relativePath);
+ 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;
+ 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),
+ );
+ } 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" },
@@ -1966,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}
/>
);
}
@@ -1984,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}
/>
);
};
@@ -2013,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;
};
@@ -2348,9 +2446,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 999c403964b3..fa003a41b05c 100644
--- a/apps/mobile/src/features/threads/fileChipMenu.test.ts
+++ b/apps/mobile/src/features/threads/fileChipMenu.test.ts
@@ -37,6 +37,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 d020e5f542a3..8ab0d31c4b4b 100644
--- a/apps/mobile/src/features/threads/fileChipMenu.ts
+++ b/apps/mobile/src/features/threads/fileChipMenu.ts
@@ -7,6 +7,7 @@ import { hostPreviewMimeTypeFromExtension } from "@t3tools/shared/filePreview";
import {
isAbsolutePath,
+ resolveFileLinkPath,
resolveWorkspaceFilePath,
resolveWorkspaceRelativeFilePath,
} from "../files/filePath";
@@ -30,9 +31,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/mobile/src/lib/markdownLinks.test.ts b/apps/mobile/src/lib/markdownLinks.test.ts
index 0f1783a5dcac..3beffb305736 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 7eb9fec8cd7c..f68add2bf7f9 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",
diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx
index baf23d2b4cec..5f2db7bd6cc8 100644
--- a/apps/web/src/components/ChatMarkdown.tsx
+++ b/apps/web/src/components/ChatMarkdown.tsx
@@ -219,7 +219,7 @@ import {
pickWorkspaceBasenameMatch,
stripRepeatedWorkspacePrefix,
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