Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 84 additions & 16 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import type {
ServerProviderSkill,
ThreadPullRequestKey,
} from "@t3tools/contracts";
import { ProjectReadFileError } from "@t3tools/contracts";
import { scopedThreadKey } from "@t3tools/client-runtime/environment";
import { faviconUrlForOrigin } from "@t3tools/shared/favicon";
import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia";
import {
Expand All @@ -54,6 +56,7 @@ import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-li
import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference";
import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
import * as Cause from "effect/Cause";
import * as Schema from "effect/Schema";
import { AsyncResult } from "effect/unstable/reactivity";
import React, {
Children,
Expand Down Expand Up @@ -174,7 +177,8 @@ import { useAtomQueryRunner } from "../state/use-atom-query-runner";
import { projectEnvironment } from "../state/projects";
import {
claimWorkspaceBasenameLookup,
needsWorkspaceBasenameLookup,
needsLiteralWorkspaceFileCheck,
normalizeWorkspaceLookupPath,
pickWorkspaceBasenameMatch,
WORKSPACE_BASENAME_LOOKUP_LIMIT,
} from "../workspaceBasenameLookup";
Expand Down Expand Up @@ -277,6 +281,8 @@ export function shouldUseMarkdownFileBrowserPrimaryAction(input: {
const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
const EMPTY_REMARK_PLUGINS: NonNullable<ReactMarkdownOptions["remarkPlugins"]> = [];

const isProjectReadFileError = Schema.is(ProjectReadFileError);

const ARTIFACT_TEMPLATE_ICON_BY_KIND = {
document: FileTextIcon,
presentation: PresentationIcon,
Expand Down Expand Up @@ -2321,6 +2327,10 @@ function useChatMarkdownState({
const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, {
reportFailure: false,
});
const readWorkspaceFile = useAtomQueryRunner(projectEnvironment.readFile, {
reportFailure: false,
refresh: true,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const openPreview = useAtomCommand(previewEnvironment.open, {
reportFailure: false,
});
Expand Down Expand Up @@ -2557,37 +2567,67 @@ function useChatMarkdownState({
},
[createAssetUrl, cwd, openPreview, preparedConnection, threadRef],
);
const literalWorkspaceFileExists = useCallback(
async (workspaceRelativePath: string) => {
if (!cwd || environmentId === null) return false;
const result = await readWorkspaceFile({
environmentId,
input: { cwd, relativePath: workspaceRelativePath },
});
if (result._tag === "Success") return true;
// readFile reads from disk rather than the search index, so gitignored
// files still report present. Existing binary files (and directories)
// fail the read with a distinct reason but still prove the literal path
// exists, so the index must not hijack them. Only a genuinely missing
// file falls through to the indexed match.
const cause = squashAtomCommandFailure(result);
return (
isProjectReadFileError(cause) &&
(cause.failure === "binary_file" || cause.failure === "path_not_file")
);
},
[cwd, environmentId, readWorkspaceFile],
);
const findWorkspaceBasenameMatch = useCallback(
async (workspaceRelativePath: string) => {
if (!cwd || environmentId === null || !needsWorkspaceBasenameLookup(workspaceRelativePath)) {
const lookupPath = normalizeWorkspaceLookupPath(workspaceRelativePath);
Comment thread
cbangera2 marked this conversation as resolved.
if (!cwd || environmentId === null || !lookupPath) {
return null;
}
const result = await searchProjectEntries({
environmentId,
input: {
cwd,
query: workspaceRelativePath,
query: lookupPath,
limit: WORKSPACE_BASENAME_LOOKUP_LIMIT,
kind: "file",
},
});
return result._tag === "Success"
? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries)
: null;
if (result._tag !== "Success") return null;
const match = pickWorkspaceBasenameMatch(lookupPath, result.value.entries);
if (
needsLiteralWorkspaceFileCheck(lookupPath, match) &&
(await literalWorkspaceFileExists(lookupPath))
) {
// The literal chip path names a real file, so keep it instead of the
// indexed twin. Callers fall back to the literal path on null.
return null;
}
return match;
},
[cwd, environmentId, searchProjectEntries],
[cwd, environmentId, literalWorkspaceFileExists, searchProjectEntries],
);
// A bare filename resolves to the workspace root, which is rarely where the
// file is, so ask the index before opening. Absolute host paths open as-is.
// Chip paths are relative to the agent's cwd, so every workspace open goes
// through the index first. Absolute host paths open as-is.
const openFileInPanel = useCallback(
(panelPath: string, line: number | undefined) => {
if (!threadRef) return;
// Claimed on every open so a synchronous one supersedes a lookup already
// in flight.
const isLatestLookup = claimWorkspaceBasenameLookup();
const isLatestLookup = claimWorkspaceBasenameLookup(scopedThreadKey(threadRef));
const openAt = (path: string) =>
useRightPanelStore.getState().openFile(threadRef, path, line);
if (!cwd || !needsWorkspaceBasenameLookup(panelPath)) {
if (!cwd || environmentId === null || isAbsolutePath(panelPath)) {
openAt(panelPath);
return;
}
Expand All @@ -2597,7 +2637,7 @@ function useChatMarkdownState({
openAt(match ?? panelPath);
})();
},
[cwd, findWorkspaceBasenameMatch, threadRef],
[cwd, environmentId, findWorkspaceBasenameMatch, threadRef],
);
const revealMarkdownFileInFileManager = useCallback(
async (fileLinkMeta: MarkdownFileLinkMeta) => {
Expand All @@ -2610,6 +2650,34 @@ function useChatMarkdownState({
},
[cwd, findWorkspaceBasenameMatch, revealFileInFileManager],
);
const openMarkdownFileInEditor = useCallback(
async (fileLinkMeta: MarkdownFileLinkMeta) => {
const workspaceRelativePath = fileLinkMeta.workspaceRelativePath;
const match = workspaceRelativePath
? await findWorkspaceBasenameMatch(workspaceRelativePath)
: null;
if (!match || !cwd) {
return openInPreferredEditor(fileLinkMeta.targetPath);
}
const withPosition = fileLinkMeta.line
? `${match}:${fileLinkMeta.line}${fileLinkMeta.column ? `:${fileLinkMeta.column}` : ""}`
: match;
return openInPreferredEditor(resolvePathLinkTarget(withPosition, cwd));
},
[cwd, findWorkspaceBasenameMatch, openInPreferredEditor],
);
const openMarkdownFileInBrowser = useCallback(
async (fileLinkMeta: MarkdownFileLinkMeta) => {
const workspaceRelativePath = fileLinkMeta.workspaceRelativePath;
const match = workspaceRelativePath
? await findWorkspaceBasenameMatch(workspaceRelativePath)
: null;
return openMarkdownFileInPreview(
match && cwd ? resolvePathLinkTarget(match, cwd) : fileLinkMeta.filePath,
);
},
[cwd, findWorkspaceBasenameMatch, openMarkdownFileInPreview],
);
const fileLinkChip = useCallback(
(fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, mediaSource?: string) => {
const parentSuffix = fileLinkParentSuffixByPath.get(
Expand Down Expand Up @@ -2647,7 +2715,7 @@ function useChatMarkdownState({
copyMarkdown={copyMarkdown}
theme={resolvedTheme}
threadRef={threadRef}
{...(canUseShellActions ? { onOpen: openInPreferredEditor } : {})}
{...(canUseShellActions ? { onOpen: () => openMarkdownFileInEditor(fileLinkMeta) } : {})}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
onOpenInPanel={openFileInPanel}
onOpenMedia={
threadRef && canPreviewMedia
Expand All @@ -2665,7 +2733,7 @@ function useChatMarkdownState({
threadRef &&
isPreviewSupportedInRuntime() &&
isBrowserPreviewFile(fileLinkMeta.filePath)
? () => openMarkdownFileInPreview(fileLinkMeta.filePath)
? () => openMarkdownFileInBrowser(fileLinkMeta)
: undefined
}
/>
Expand All @@ -2675,8 +2743,8 @@ function useChatMarkdownState({
canUseShellActions,
fileLinkParentSuffixByPath,
openFileInPanel,
openInPreferredEditor,
openMarkdownFileInPreview,
openMarkdownFileInEditor,
openMarkdownFileInBrowser,
openMarkdownMedia,
preferredEditorMenuLabel,
resolvedTheme,
Expand Down
98 changes: 76 additions & 22 deletions apps/web/src/workspaceBasenameLookup.test.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,50 @@ import { describe, expect, it } from "vite-plus/test";

import {
claimWorkspaceBasenameLookup,
needsWorkspaceBasenameLookup,
needsLiteralWorkspaceFileCheck,
pickWorkspaceBasenameMatch,
} from "./workspaceBasenameLookup";

describe("needsWorkspaceBasenameLookup", () => {
it("flags bare filenames", () => {
expect(needsWorkspaceBasenameLookup("ChatView.tsx")).toBe(true);
expect(needsWorkspaceBasenameLookup("Makefile")).toBe(true);
});

it("leaves anything with a directory alone", () => {
expect(needsWorkspaceBasenameLookup("apps/web/src/components/ChatView.tsx")).toBe(false);
expect(needsWorkspaceBasenameLookup("apps\\web\\ChatView.tsx")).toBe(false);
expect(needsWorkspaceBasenameLookup(".")).toBe(false);
expect(needsWorkspaceBasenameLookup("..")).toBe(false);
expect(needsWorkspaceBasenameLookup(" ")).toBe(false);
});
});

describe("pickWorkspaceBasenameMatch", () => {
const entries = [
{ path: "apps/web/src/components/ChatView.test.tsx", kind: "file" as const },
{ path: "apps/web/src/components/ChatView.tsx", kind: "file" as const },
];

it("takes the first exact filename match, not the closest fuzzy one", () => {
expect(pickWorkspaceBasenameMatch("ChatView.tsx", entries)).toBe(
"apps/web/src/components/ChatView.tsx",
);
expect(
pickWorkspaceBasenameMatch("ChatView.tsx", [
...entries,
{ path: "apps/desktop/src/ChatView.tsx", kind: "file" },
]),
).toBe("apps/web/src/components/ChatView.tsx");
});

it("matches a slashed path as a suffix wherever it actually lives", () => {
expect(
pickWorkspaceBasenameMatch("docs/GROUPS_PLAN.md", [
{ path: "BudgetLens/docs/GROUPS_PLAN.md", kind: "file" },
{ path: "docs/other.md", kind: "file" },
]),
).toBe("BudgetLens/docs/GROUPS_PLAN.md");
});

it("does not let a suffix match straddle a segment boundary", () => {
expect(
pickWorkspaceBasenameMatch("docs/plan.md", [{ path: "src/mydocs/plan.md", kind: "file" }]),
).toBeNull();
expect(
pickWorkspaceBasenameMatch("plan.md", [{ path: "src/plan.md.bak", kind: "file" }]),
).toBeNull();
});

it("prefers the exact suffix over a basename-only twin", () => {
expect(
pickWorkspaceBasenameMatch("docs/plan.md", [
{ path: "other/plan.md", kind: "file" },
{ path: "BudgetLens/docs/plan.md", kind: "file" },
]),
).toBe("BudgetLens/docs/plan.md");
});

it("ignores directories", () => {
Expand Down Expand Up @@ -66,6 +81,12 @@ describe("pickWorkspaceBasenameMatch", () => {
).toBeNull();
});

it("leaves workspace-root links alone", () => {
expect(pickWorkspaceBasenameMatch(".", entries)).toBeNull();
expect(pickWorkspaceBasenameMatch("..", entries)).toBeNull();
expect(pickWorkspaceBasenameMatch(" ", entries)).toBeNull();
});

it("returns null when nothing matches the name", () => {
expect(pickWorkspaceBasenameMatch("ChatView.tsx", [])).toBeNull();
expect(
Expand All @@ -76,10 +97,37 @@ describe("pickWorkspaceBasenameMatch", () => {
});
});

describe("needsLiteralWorkspaceFileCheck", () => {
it("checks the disk when a slashed hit names a different file than the chip", () => {
expect(
needsLiteralWorkspaceFileCheck("docs/GROUPS_PLAN.md", "BudgetLens/docs/GROUPS_PLAN.md"),
).toBe(true);
});

it("skips the disk when the hit is the literal path", () => {
expect(
needsLiteralWorkspaceFileCheck(
"apps/web/src/components/ChatView.tsx",
"apps/web/src/components/ChatView.tsx",
),
).toBe(false);
});

it("skips the disk when there is no hit", () => {
expect(needsLiteralWorkspaceFileCheck("docs/plan.md", null)).toBe(false);
});

it("skips the disk for bare names, which keep the ranked-first hit", () => {
expect(needsLiteralWorkspaceFileCheck("ChatView.tsx", "apps/web/src/ChatView.tsx")).toBe(
false,
);
});
});

describe("claimWorkspaceBasenameLookup", () => {
it("keeps only the newest claim, whatever order the lookups settle in", () => {
const first = claimWorkspaceBasenameLookup();
const second = claimWorkspaceBasenameLookup();
const first = claimWorkspaceBasenameLookup("thread-a");
const second = claimWorkspaceBasenameLookup("thread-a");

// The older lookup answering last must not reopen the panel behind the
// newer one.
Expand All @@ -88,7 +136,13 @@ describe("claimWorkspaceBasenameLookup", () => {
});

it("stays valid while it is the only claim", () => {
const only = claimWorkspaceBasenameLookup();
const only = claimWorkspaceBasenameLookup("thread-a");
expect(only()).toBe(true);
});

it("keeps another thread's in-flight claim valid", () => {
const threadA = claimWorkspaceBasenameLookup("thread-a");
claimWorkspaceBasenameLookup("thread-b");
expect(threadA()).toBe(true);
});
});
Loading
Loading