diff --git a/README.md b/README.md index ad6ef8403140..fcc710764eb9 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index b1ceb33062c8..eb4a12a47ad7 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -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"; @@ -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(); - 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 { @@ -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 @@ -632,6 +639,49 @@ describe("ChatMarkdown skill chips", () => { }); describe("ChatMarkdown file option chips", () => { + it("shows the existing short label by default", () => { + const html = renderToStaticMarkup( + , + ); + + 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( + , + ); + }); + 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( , diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 42a34d8b5d25..27a69135ef57 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -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"; @@ -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 ( @@ -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( @@ -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} @@ -2889,6 +2891,7 @@ function useChatMarkdownState({ [ canUseShellActions, canOperatePreview, + cwd, fileLinkParentSuffixByPath, openFileInPanel, openInPreferredEditor, @@ -2898,6 +2901,7 @@ function useChatMarkdownState({ resolvedTheme, revealInFileManagerLabel, revealMarkdownFileInFileManager, + showFileLinkPaths, threadRef, ], ); diff --git a/apps/web/src/components/settings/SettingsPanels.restore.test.tsx b/apps/web/src/components/settings/SettingsPanels.restore.test.tsx index 88c9cb1776e3..d7882b194299 100644 --- a/apps/web/src/components/settings/SettingsPanels.restore.test.tsx +++ b/apps/web/src/components/settings/SettingsPanels.restore.test.tsx @@ -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"], diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5e52b4827891..bd8a568eed54 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -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"] @@ -717,6 +720,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.notificationMode, settings.inAppNotificationsEnabled, settings.wordWrap, + settings.showFileLinkPaths, followSystem, theme, themeHalves, @@ -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, @@ -2533,6 +2538,27 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ showFileLinkPaths: DEFAULT_UNIFIED_SETTINGS.showFileLinkPaths }) + } + /> + ) : null + } + control={ + updateSettings({ showFileLinkPaths: Boolean(checked) })} + aria-label="Show compact paths in file chips" + /> + } + /> = [ ]; 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: "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/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"); }); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 81b2d1248f19..8964fb535c1f 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -357,6 +357,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "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/settings/general", searchTerms: ["timestamp clock locale system browser os 12 hour 24 hour"], }, + { + id: "file-chip-paths", + title: "File chip paths", + to: "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/settings/general", + searchTerms: ["file chip path compact relative workspace home absolute"], + }, { id: "response-streaming", title: "Response streaming", diff --git a/apps/web/src/filePathDisplay.test.ts b/apps/web/src/filePathDisplay.test.ts index 4c49133a3138..aa4f12a8f6c4 100644 --- a/apps/web/src/filePathDisplay.test.ts +++ b/apps/web/src/filePathDisplay.test.ts @@ -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", () => { @@ -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([ + ["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/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"); + }); +}); diff --git a/apps/web/src/filePathDisplay.ts b/apps/web/src/filePathDisplay.ts index 36f5873cf8e0..73e24c49413a 100644 --- a/apps/web/src/filePathDisplay.ts +++ b/apps/web/src/filePathDisplay.ts @@ -18,6 +18,105 @@ function stripRelativePrefixes(path: string): string { return path.replace(/^\.\/+/, "").replace(/^\/+/, ""); } +function normalizeAbsolutePath(path: string): string { + const normalized = stripSlashPrefixedWindowsDrive(normalizePathSeparators(path)); + if (normalized === "/" || /^[A-Za-z]:\/$/.test(normalized)) return normalized; + return normalized.replace(/\/+$/, ""); +} + +function hasDotPathSegment(path: string): boolean { + return normalizePathSeparators(path) + .split("/") + .some((segment) => segment === "." || segment === ".."); +} + +function homeDirectoryFromWorkspace(workspaceRoot: string): string | undefined { + if (workspaceRoot === "/root" || workspaceRoot.startsWith("/root/")) return "/root"; + const wslShare = workspaceRoot.match(/^\/\/(?:wsl\.localhost|wsl\$)\/[^/]+/i)?.[0]; + if (wslShare) { + const linuxPath = workspaceRoot.slice(wslShare.length); + const linuxHome = + linuxPath.match(/^\/home\/[^/]+(?=\/|$)/)?.[0] ?? linuxPath.match(/^\/root(?=\/|$)/)?.[0]; + if (linuxHome) return `${wslShare}${linuxHome}`; + } + return ( + workspaceRoot.match(/^\/Users\/[^/]+/)?.[0] ?? + workspaceRoot.match(/^\/home\/[^/]+/)?.[0] ?? + workspaceRoot.match(/^[A-Za-z]:\/Users\/[^/]+/i)?.[0] + ); +} + +function comparisonPath(path: string): string { + // Drive-letter paths are owned by Windows. UNC paths may instead point at a + // case-sensitive WSL or SMB backend, so an exact-case match is the only safe + // browser-side containment claim for them. + return /^[A-Za-z]:\//.test(path) ? path.toLowerCase() : path; +} + +function suffixWithin(path: string, parent: string): string | null { + const comparablePath = comparisonPath(path); + const comparableParent = comparisonPath(parent); + if (comparablePath === comparableParent) return ""; + const parentWithSeparator = parent.endsWith("/") ? parent : `${parent}/`; + const comparablePrefix = comparisonPath(parentWithSeparator); + return comparablePath.startsWith(comparablePrefix) + ? path.slice(parentWithSeparator.length) + : null; +} + +export function formatCompactFilePath( + pathWithPosition: string, + workspaceRoot: string | undefined, +): string { + const position = splitFilePathPosition(pathWithPosition); + const normalizedPath = normalizeAbsolutePath(position.path); + let displayPath = normalizedPath; + + // Resolving dot segments is filesystem-dependent when an earlier segment is + // a symlink. The browser does not own that filesystem (and it may be remote), + // so preserve the authored absolute target instead of falsely claiming ./ or + // ~/ containment. + const canSafelyShorten = + !hasDotPathSegment(position.path) && (!workspaceRoot || !hasDotPathSegment(workspaceRoot)); + + if (workspaceRoot && canSafelyShorten) { + const normalizedWorkspaceRoot = normalizeAbsolutePath(workspaceRoot); + const workspaceSuffix = suffixWithin(normalizedPath, normalizedWorkspaceRoot); + if (workspaceSuffix !== null) { + displayPath = workspaceSuffix ? `./${workspaceSuffix}` : "./"; + } else { + const homeDirectory = homeDirectoryFromWorkspace(normalizedWorkspaceRoot); + const homeSuffix = homeDirectory ? suffixWithin(normalizedPath, homeDirectory) : null; + if (homeSuffix !== null) { + displayPath = homeSuffix ? `~/${homeSuffix}` : "~/"; + } + } + } + + return formatFilePathPosition({ ...position, path: displayPath }); +} + +export function formatFileChipLabel(input: { + readonly showFileLinkPaths: boolean; + readonly targetPath: string; + readonly workspaceRoot: string | undefined; + readonly basename: string; + readonly parentSuffix?: string | undefined; + readonly line?: number | undefined; + readonly column?: number | undefined; +}): string { + if (input.showFileLinkPaths) { + return formatCompactFilePath(input.targetPath, input.workspaceRoot); + } + + const labelParts = [input.basename]; + if (input.parentSuffix) labelParts.push(input.parentSuffix); + if (input.line) { + labelParts.push(`L${input.line}${input.column ? `:C${input.column}` : ""}`); + } + return labelParts.join(" · "); +} + export function formatWorkspaceRelativePath( pathWithPosition: string, workspaceRoot: string | undefined, diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 04fab1c1d1c0..af0d46fa6415 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -594,7 +594,7 @@ export function __resetClientSettingsPersistenceForTests(): void { export function __setClientSettingsForTests(settings: ClientSettings): void { clientSettingsHydrationGeneration += 1; - clientSettingsSnapshot = settings; + replaceClientSettingsSnapshot(settings); clientSettingsHydrationStatus = "ready"; clientSettingsHydrationPromise = null; } diff --git a/docs/README.md b/docs/README.md index ead465ccce03..b6b83ea8de8f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ - [Project settings](./user/project-settings.md) - [Appearance and themes](./user/appearance.md) - [Keyboard shortcuts](./user/keybindings.md) +- [File links](./user/file-links.md) - [SnapShots](./user/snap-shot.md) - [Visual replies](./user/html-renders.md) - [Import browser sessions](./user/browser-import.md) diff --git a/docs/user/file-links.md b/docs/user/file-links.md new file mode 100644 index 000000000000..f1ed456c6a51 --- /dev/null +++ b/docs/user/file-links.md @@ -0,0 +1,11 @@ +# File links + +File references in web and desktop chat appear as clickable chips. By default, each chip shows the +file name and adds parent folders only when two links would otherwise look the same. Hover a chip to +see its full path. The mobile renderer remains basename-only. + +On web or desktop, enable **Settings → General → File chip paths** to show a compact path directly +in each chip. Files safely identified inside the project use `./`. For projects under your home +directory, other home files use `~/`; files outside both keep their absolute path. Authored paths +containing `.` or `..` also stay absolute because resolving them safely requires the owning +filesystem. Line and column numbers stay attached to the path. diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 419db85883bc..a719874e9059 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -388,6 +388,22 @@ describe("ClientSettings word wrap", () => { }); }); +describe("ClientSettings file link paths", () => { + it("keeps short file-link labels by default", () => { + expect(decodeClientSettings({}).showFileLinkPaths).toBe(false); + }); + + it("persists an explicit compact-path preference", () => { + expect(decodeClientSettings({ showFileLinkPaths: true }).showFileLinkPaths).toBe(true); + expect(decodeClientSettingsPatch({ showFileLinkPaths: true }).showFileLinkPaths).toBe(true); + }); + + it("rejects non-boolean file-link preferences", () => { + expect(() => decodeClientSettings({ showFileLinkPaths: "yes" })).toThrow(); + expect(() => decodeClientSettingsPatch({ showFileLinkPaths: "yes" })).toThrow(); + }); +}); + describe("ClientSettings window capture", () => { it("defaults capture off while keeping its feedback enabled", () => { const settings = decodeClientSettings({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 942fbb915ffe..7cf8e2727fdf 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -459,6 +459,7 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed("queue")), ), proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + showFileLinkPaths: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), // Legacy sidebar (the original per-project tree). Deliberately a fresh key // (was `sidebarV2Enabled` + `sidebarV2ConfiguredByUser`): decoding drops the @@ -1885,6 +1886,7 @@ export const ClientSettingsPatch = Schema.Struct({ sendShortcut: Schema.optionalKey(Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"])), followUpBehavior: Schema.optionalKey(Schema.Literals(["queue", "steer"])), proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), + showFileLinkPaths: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), sidebarWorkingShelfEnabled: Schema.optionalKey(Schema.Boolean),