Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ Full docs live in [docs/](./docs). There's no docs site yet.
- [Install and first run](./docs/user/install.md)
- [Permission modes](./docs/user/permission-modes.md)
- [Keyboard shortcuts](./docs/user/keybindings.md)
- [File links](./docs/user/file-links.md)
- [Project settings](./docs/user/project-settings.md)
- [Appearance preferences](./docs/user/appearance.md)
- [Remote access from a phone or another machine](./docs/user/remote-access.md)
Expand Down
72 changes: 61 additions & 11 deletions apps/web/src/components/ChatMarkdown.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
// @vitest-environment jsdom

import { EnvironmentId, type AuthEnvironmentScope } from "@t3tools/contracts";
import {
DEFAULT_CLIENT_SETTINGS,
EnvironmentId,
type AuthEnvironmentScope,
} from "@t3tools/contracts";
import { act, type ComponentProps, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { describe, expect, it, vi } from "vite-plus/test";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
import { GitHubIcon } from "./Icons";
Expand All @@ -13,15 +17,6 @@ import { setMarkdownTaskChecked } from "./files/filePreviewMode";

vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
vi.mock("../hooks/useSettings", async (importOriginal) => {
const actual = await importOriginal<typeof import("../hooks/useSettings")>();
const settings = actual.getClientSettings();
return {
...actual,
useClientSettings: (select?: (value: typeof settings) => unknown) =>
select ? select(settings) : settings,
};
});
vi.mock("./ui/tooltip", async () => {
const { cloneElement, isValidElement } = await import("react");
return {
Expand Down Expand Up @@ -75,6 +70,18 @@ import ChatMarkdown, {
hasMarkdownFilePrimaryAction,
shouldUseMarkdownFileBrowserPrimaryAction,
} from "./ChatMarkdown";
import {
__resetClientSettingsPersistenceForTests,
__setClientSettingsForTests,
} from "../hooks/useSettings";

beforeEach(() => {
__setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
});

afterEach(() => {
__resetClientSettingsPersistenceForTests();
});

function codeButton(renderer: ReactTestRenderer, label: string) {
const button = renderer.root
Expand Down Expand Up @@ -632,6 +639,49 @@ describe("ChatMarkdown skill chips", () => {
});

describe("ChatMarkdown file option chips", () => {
it("shows the existing short label by default", () => {
const html = renderToStaticMarkup(
<ChatMarkdown cwd="/tmp/project" text="[Source](/tmp/project/src/main.ts)" />,
);

expect(html).toContain("main.ts");
expect(html).not.toContain("./src/main.ts");
});

it("updates an already-mounted chip when the preference changes", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
let renderer: ReactTestRenderer | undefined;
const chipText = () =>
renderer!.root
.findAllByType("button")
.filter((button) => String(button.props.className).includes("chat-markdown-file-link"))
.flatMap((button) => button.findAll(() => true))
.flatMap((node) => node.children)
.filter((child): child is string => typeof child === "string")
.join("");

try {
await act(async () => {
renderer = create(
<ChatMarkdown cwd="/tmp/project" text="[Source](/tmp/project/src/main.ts)" />,
);
});
expect(chipText()).toContain("main.ts");
expect(chipText()).not.toContain("./src/main.ts");

await act(async () => {
__setClientSettingsForTests({
...DEFAULT_CLIENT_SETTINGS,
showFileLinkPaths: true,
});
});
expect(chipText()).toContain("./src/main.ts");
} finally {
await act(async () => renderer?.unmount());
vi.unstubAllGlobals();
}
});

it("keeps the fallback button text selectable", () => {
const html = renderToStaticMarkup(
<ChatMarkdown cwd="/tmp/project" text="[Source](/tmp/project/src/main.ts)" />,
Expand Down
24 changes: 14 additions & 10 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ import {
import { openInEditorMenuLabel } from "../editorLabels";
import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
import { fnv1a32 } from "../lib/diffRendering";
import { formatFileChipLabel } from "../filePathDisplay";
import { LRUCache } from "../lib/lruCache";
import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
import { GitHubIcon } from "./Icons";
Expand Down Expand Up @@ -2680,6 +2681,7 @@ function useChatMarkdownState({
// synchronously whether to intercept its `_blank`, and a subscription is what
// makes a persisted "app" apply once settings hydrate after launch.
const linkTargetPreference = useClientSettings((settings) => settings.browserLinkTarget);
const showFileLinkPaths = useClientSettings((settings) => settings.showFileLinkPaths);
const resolveThreadPullRequest = useCallback(
(href: string): (ThreadPullRequestKey & { readonly url: string }) | null => {
if (
Expand Down Expand Up @@ -2829,15 +2831,15 @@ function useChatMarkdownState({
const parentSuffix = fileLinkParentSuffixByPath.get(
fileLinkMeta.filePath.replaceAll("\\", "/"),
);
const labelParts = [fileLinkMeta.basename];
if (typeof parentSuffix === "string" && parentSuffix.length > 0) {
labelParts.push(parentSuffix);
}
if (fileLinkMeta.line) {
labelParts.push(
`L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`,
);
}
const label = formatFileChipLabel({
showFileLinkPaths,
targetPath: fileLinkMeta.targetPath,
workspaceRoot: cwd,
basename: fileLinkMeta.basename,
parentSuffix,
line: fileLinkMeta.line,
column: fileLinkMeta.column,
});
const mediaPath = mediaSource ?? fileLinkMeta.filePath;
const canPreviewMedia =
mediaMimeTypeFromExtension(
Expand All @@ -2857,7 +2859,7 @@ function useChatMarkdownState({
displayPath={fileLinkMeta.displayPath}
panelPath={panelPath}
line={fileLinkMeta.line}
label={labelParts.join(" · ")}
label={label}
copyMarkdown={copyMarkdown}
theme={resolvedTheme}
threadRef={threadRef}
Expand Down Expand Up @@ -2889,6 +2891,7 @@ function useChatMarkdownState({
[
canUseShellActions,
canOperatePreview,
cwd,
fileLinkParentSuffixByPath,
openFileInPanel,
openInPreferredEditor,
Expand All @@ -2898,6 +2901,7 @@ function useChatMarkdownState({
resolvedTheme,
revealInFileManagerLabel,
revealMarkdownFileInFileManager,
showFileLinkPaths,
threadRef,
],
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ beforeEach(() => {

describe("restoring V2 settings", () => {
it.each([
["showFileLinkPaths", "File chip paths"],
["persistComposerContextStrip", "Composer context"],
["autoResumeLimitedThreads", "Auto-resume limited threads"],
["snoozeLimitedThreads", "Snooze limited threads"],
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,9 @@ export function useSettingsRestore(onRestored?: () => void) {
? ["Snooze limited threads"]
: []),
...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []),
...(settings.showFileLinkPaths !== DEFAULT_UNIFIED_SETTINGS.showFileLinkPaths
? ["File chip paths"]
: []),
...(settings.persistComposerContextStrip !==
DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip
? ["Composer context"]
Expand Down Expand Up @@ -717,6 +720,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.notificationMode,
settings.inAppNotificationsEnabled,
settings.wordWrap,
settings.showFileLinkPaths,
followSystem,
theme,
themeHalves,
Expand Down Expand Up @@ -793,6 +797,7 @@ export function useSettingsRestore(onRestored?: () => void) {
notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode,
inAppNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled,
wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
showFileLinkPaths: DEFAULT_UNIFIED_SETTINGS.showFileLinkPaths,
persistComposerContextStrip: DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip,
diffFilesCollapsed: DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed,
diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
Expand Down Expand Up @@ -2533,6 +2538,27 @@ export function GeneralSettingsPanel() {
</Select>
}
/>
<SettingsRow
{...searchableSetting("file-chip-paths")}
description="Show compact paths in chat file chips instead of only file names."
resetAction={
settings.showFileLinkPaths !== DEFAULT_UNIFIED_SETTINGS.showFileLinkPaths ? (
<SettingResetButton
label="file chip paths"
onClick={() =>
updateSettings({ showFileLinkPaths: DEFAULT_UNIFIED_SETTINGS.showFileLinkPaths })
}
/>
) : null
}
control={
<Switch
checked={settings.showFileLinkPaths}
onCheckedChange={(checked) => updateSettings({ showFileLinkPaths: Boolean(checked) })}
aria-label="Show compact paths in file chips"
/>
}
/>
<SettingsRow
serverScoped
settingKeys={["responseStreamingMode"]}
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/components/settings/settingsSearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ const ITEMS: ReadonlyArray<SettingsSearchItem> = [
];

describe("searchSettings", () => {
it.each(["file chip paths", "compact path", "relative workspace"])(
"finds the file chip preference for %s",
(query) => {
expect(searchSettings(query)[0]).toMatchObject({
id: "file-chip-paths",
to: "/settings/general",
});
},
);

it.each(["send shortcut", "multiline", "new line"])("finds Send shortcut for %s", (query) => {
expect(searchSettings(query).map((item) => item.id)).toContain("send-shortcut");
});
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,12 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/general",
searchTerms: ["timestamp clock locale system browser os 12 hour 24 hour"],
},
{
id: "file-chip-paths",
title: "File chip paths",
to: "/settings/general",
searchTerms: ["file chip path compact relative workspace home absolute"],
},
{
id: "response-streaming",
title: "Response streaming",
Expand Down
112 changes: 111 additions & 1 deletion apps/web/src/filePathDisplay.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vite-plus/test";

import { formatWorkspaceRelativePath } from "./filePathDisplay";
import {
formatCompactFilePath,
formatFileChipLabel,
formatWorkspaceRelativePath,
} from "./filePathDisplay";

describe("formatWorkspaceRelativePath", () => {
it("formats absolute workspace paths from the workspace root", () => {
Expand Down Expand Up @@ -45,3 +49,109 @@ describe("formatWorkspaceRelativePath", () => {
);
});
});

describe("formatCompactFilePath", () => {
it.each([
["/root/project/src/main.ts", "/root/project", "./src/main.ts"],
["/root/project", "/root/project", "./"],
["/root/notes/todo.md", "/root/project", "~/notes/todo.md"],
["/root", "/root/project", "~/"],
["/opt/tools/run.ts", "/root/project", "/opt/tools/run.ts"],
["/home/alice/notes/todo.md", "/home/alice/project", "~/notes/todo.md"],
["C:/Users/mike/project/src/main.ts", "C:/Users/mike/project", "./src/main.ts"],
["C:/Users/mike/notes/todo.md", "C:/Users/mike/project", "~/notes/todo.md"],
["D:/tools/run.ts", "C:/Users/mike/project", "D:/tools/run.ts"],
])("formats %s from %s as %s", (path, workspaceRoot, expected) => {
expect(formatCompactFilePath(path, workspaceRoot)).toBe(expected);
});

it("keeps authored dot segments absolute instead of guessing across symlinks", () => {
const formatted = formatCompactFilePath(
"/root/project/src/../../notes/todo.md:12:3",
"/root/project",
);

expect(formatted).toBe("/root/project/src/../../notes/todo.md:12:3");
expect(formatted).not.toMatch(/^\.\.\//);
});

it.each([
["/srv/Project/src/main.ts", "/srv/project", "/srv/Project/src/main.ts"],
["C:/Users/MIKE/Project/src/main.ts", "c:/users/mike/project", "./src/main.ts"],
["//server/share/Project/src/main.ts", "//server/share/Project", "./src/main.ts"],
[
"//server/share/project/src/main.ts",
"//server/share/Project",
"//server/share/project/src/main.ts",
],
[
String.raw`\\wsl.localhost\Ubuntu\home\alice\Project\src\main.ts:12:3`,
String.raw`\\wsl.localhost\Ubuntu\home\alice\Project`,
"./src/main.ts:12:3",
],
[
"//wsl.localhost/Ubuntu/home/alice/project/src/main.ts:12:3",
"//wsl.localhost/Ubuntu/home/alice/Project",
"~/project/src/main.ts:12:3",
],
])("uses safe case rules for %s from %s", (path, workspaceRoot, expected) => {
expect(formatCompactFilePath(path, workspaceRoot)).toBe(expected);
});

it.each([
[
String.raw`\\wsl.localhost\Ubuntu\home\alice\notes\todo.md:12:3`,
String.raw`\\wsl.localhost\Ubuntu\home\alice\project`,
"~/notes/todo.md:12:3",
],
["//wsl$/Ubuntu/root/notes/todo.md", "//wsl$/Ubuntu/root/project", "~/notes/todo.md"],
[
"//wsl.localhost/Ubuntu/home/Alice/notes/todo.md",
"//wsl.localhost/Ubuntu/home/alice/project",
"//wsl.localhost/Ubuntu/home/Alice/notes/todo.md",
],
[
"//server/share/home/alice/notes/todo.md",
"//server/share/home/alice/project",
"//server/share/home/alice/notes/todo.md",
],
])("infers WSL home paths safely for %s", (path, workspaceRoot, expected) => {
expect(formatCompactFilePath(path, workspaceRoot)).toBe(expected);
});

it.each([
["/src/main.ts", "/", "./src/main.ts"],
["C:/src/main.ts", "C:/", "./src/main.ts"],
["//server/share/src/main.ts", "//server/share/", "./src/main.ts"],
["//server/share", "//server/share/", "./"],
])("handles path roots and trailing separators for %s", (path, workspaceRoot, expected) => {
expect(formatCompactFilePath(path, workspaceRoot)).toBe(expected);
});

it("does not shorten against a workspace root containing dot segments", () => {
expect(formatCompactFilePath("/srv/project/src/main.ts", "/srv/link/../project")).toBe(
"/srv/project/src/main.ts",
);
});
});

describe("formatFileChipLabel", () => {
const input = {
basename: "index.ts",
parentSuffix: "project/src",
targetPath: "/root/project/src/index.ts:12:3",
workspaceRoot: "/root/project",
line: 12,
column: 3,
};

it("keeps the current short label when compact paths are disabled", () => {
expect(formatFileChipLabel({ ...input, showFileLinkPaths: false })).toBe(
"index.ts · project/src · L12:C3",
);
});

it("uses one real compact path when compact paths are enabled", () => {
expect(formatFileChipLabel({ ...input, showFileLinkPaths: true })).toBe("./src/index.ts:12:3");
});
});
Loading
Loading