| author | |
| committer | |
| log | fb1034546f989c11568ef1c4b5801e6a5372a07d |
| tree | 21d773330ffc2263970d6b76bfb94adf22875030 |
| parent | f5f98cf0ae48a14b524480a0942f9ce37190656c |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <jmarminge@gmail.com>8 files changed, 85 insertions(+), 32 deletions(-)
apps/desktop/src/settings/DesktopClientSettings.test.ts+1-1| ... | ... | @@ -18,7 +18,6 @@ const clientSettings: ClientSettings = { |
| 18 | 18 | confirmThreadDelete: false, |
| 19 | 19 | dismissedProviderUpdateNotificationKeys: [], |
| 20 | 20 | diffIgnoreWhitespace: true, |
| 21 | diffWordWrap: true, | |
| 22 | 21 | favorites: [], |
| 23 | 22 | providerModelPreferences: {}, |
| 24 | 23 | sidebarProjectGroupingMode: "repository_path", |
| ... | ... | @@ -29,6 +28,7 @@ const clientSettings: ClientSettings = { |
| 29 | 28 | sidebarThreadSortOrder: "created_at", |
| 30 | 29 | sidebarThreadPreviewCount: 6, |
| 31 | 30 | timestampFormat: "24-hour", |
| 31 | wordWrap: true, | |
| 32 | 32 | }; |
| 33 | 33 | |
| 34 | 34 | const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(ClientSettingsSchema)); |
apps/web/src/clientPersistenceStorage.test.ts+21| ... | ... | @@ -69,4 +69,25 @@ describe("clientPersistenceStorage", () => { |
| 69 | 69 | }), |
| 70 | 70 | ); |
| 71 | 71 | }); |
| 72 | ||
| 73 | it("defaults word wrap on and discards obsolete wrapping preferences", async () => { | |
| 74 | const testWindow = getTestWindow(); | |
| 75 | testWindow.localStorage.setItem( | |
| 76 | "t3code:client-settings:v1", | |
| 77 | JSON.stringify({ | |
| 78 | chatWordWrap: false, | |
| 79 | diffWordWrap: false, | |
| 80 | }), | |
| 81 | ); | |
| 82 | const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); | |
| 83 | const settings = readBrowserClientSettings(); | |
| 84 | ||
| 85 | expect(settings).toEqual( | |
| 86 | expect.objectContaining({ | |
| 87 | wordWrap: true, | |
| 88 | }), | |
| 89 | ); | |
| 90 | expect(settings).not.toHaveProperty("chatWordWrap"); | |
| 91 | expect(settings).not.toHaveProperty("diffWordWrap"); | |
| 92 | }); | |
| 72 | 93 | }); |
apps/web/src/components/ChatMarkdown.tsx+4-2| ... | ... | @@ -54,6 +54,7 @@ import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; |
| 54 | 54 | import { fnv1a32 } from "../lib/diffRendering"; |
| 55 | 55 | import { LRUCache } from "../lib/lruCache"; |
| 56 | 56 | import { useTheme } from "../hooks/useTheme"; |
| 57 | import { useClientSettings } from "../hooks/useSettings"; | |
| 57 | 58 | import { |
| 58 | 59 | chatMarkdownClipboardPayload, |
| 59 | 60 | serializeTableElementToCsv, |
| ... | ... | @@ -295,7 +296,7 @@ function getHighlighterPromise(language: string): Promise<DiffsHighlighter> { |
| 295 | 296 | function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { |
| 296 | 297 | const containerRef = useRef<HTMLDivElement | null>(null); |
| 297 | 298 | const tableRef = useRef<HTMLTableElement | null>(null); |
| 298 | const [expanded, setExpanded] = useState(false); | |
| 299 | const [expanded, setExpanded] = useState(useClientSettings((settings) => settings.wordWrap)); | |
| 299 | 300 | const [copied, setCopied] = useState(false); |
| 300 | 301 | const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 301 | 302 | const expandLabel = expanded ? "Collapse table cells" : "Expand table cells"; |
| ... | ... | @@ -525,10 +526,11 @@ function MarkdownCodeBlock({ |
| 525 | 526 | children: ReactNode; |
| 526 | 527 | }) { |
| 527 | 528 | const [copied, setCopied] = useState(false); |
| 528 | const [wrapped, setWrapped] = useState(false); | |
| 529 | const [wrapped, setWrapped] = useState(useClientSettings((settings) => settings.wordWrap)); | |
| 529 | 530 | const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 530 | 531 | const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines"; |
| 531 | 532 | const copyLabel = copied ? "Copied" : "Copy code"; |
| 533 | ||
| 532 | 534 | const handleCopy = useCallback(() => { |
| 533 | 535 | if (typeof navigator === "undefined" || navigator.clipboard == null) { |
| 534 | 536 | return; |
apps/web/src/components/DiffPanel.tsx+8-9| ... | ... | @@ -186,7 +186,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff |
| 186 | 186 | const { resolvedTheme } = useTheme(); |
| 187 | 187 | const settings = useClientSettings(); |
| 188 | 188 | const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked"); |
| 189 | const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); | |
| 189 | const [wordWrap, setWordWrap] = useState(settings.wordWrap); | |
| 190 | 190 | const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); |
| 191 | 191 | const [baseRefQuery, setBaseRefQuery] = useState(""); |
| 192 | 192 | const [collapsedDiffFiles, setCollapsedDiffFiles] = useState<CollapsedDiffFilesState>(() => ({ |
| ... | ... | @@ -194,6 +194,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff |
| 194 | 194 | fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS, |
| 195 | 195 | })); |
| 196 | 196 | const codeViewRef = useRef<AnnotatableCodeViewHandle>(null); |
| 197 | ||
| 197 | 198 | const routeThreadRef = useParams({ |
| 198 | 199 | strict: false, |
| 199 | 200 | select: (params) => resolveThreadRouteRef(params), |
| ... | ... | @@ -695,14 +696,12 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff |
| 695 | 696 | <TooltipTrigger |
| 696 | 697 | render={ |
| 697 | 698 | <Toggle |
| 698 | aria-label={ | |
| 699 | diffWordWrap ? "Disable diff line wrapping" : "Enable diff line wrapping" | |
| 700 | } | |
| 699 | aria-label={wordWrap ? "Disable diff line wrapping" : "Enable diff line wrapping"} | |
| 701 | 700 | variant="outline" |
| 702 | 701 | size="xs" |
| 703 | pressed={diffWordWrap} | |
| 702 | pressed={wordWrap} | |
| 704 | 703 | onPressedChange={(pressed) => { |
| 705 | setDiffWordWrap(Boolean(pressed)); | |
| 704 | setWordWrap(Boolean(pressed)); | |
| 706 | 705 | }} |
| 707 | 706 | /> |
| 708 | 707 | } |
| ... | ... | @@ -710,7 +709,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff |
| 710 | 709 | <TextWrapIcon className="size-3" /> |
| 711 | 710 | </TooltipTrigger> |
| 712 | 711 | <TooltipPopup side="top"> |
| 713 | {diffWordWrap ? "Disable line wrapping" : "Enable line wrapping"} | |
| 712 | {wordWrap ? "Disable line wrapping" : "Enable line wrapping"} | |
| 714 | 713 | </TooltipPopup> |
| 715 | 714 | </Tooltip> |
| 716 | 715 | <Tooltip> |
| ... | ... | @@ -844,7 +843,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff |
| 844 | 843 | options={{ |
| 845 | 844 | diffStyle: diffRenderMode === "split" ? "split" : "unified", |
| 846 | 845 | lineDiffType: "none", |
| 847 | overflow: diffWordWrap ? "wrap" : "scroll", | |
| 846 | overflow: wordWrap ? "wrap" : "scroll", | |
| 848 | 847 | theme: resolveDiffThemeName(resolvedTheme), |
| 849 | 848 | themeType: resolvedTheme as DiffThemeType, |
| 850 | 849 | unsafeCSS: DIFF_PANEL_UNSAFE_CSS, |
| ... | ... | @@ -860,7 +859,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff |
| 860 | 859 | <pre |
| 861 | 860 | className={cn( |
| 862 | 861 | "max-h-[72vh] rounded-md border border-border/70 bg-background/70 p-3 font-mono text-[11px] leading-relaxed text-muted-foreground/90", |
| 863 | diffWordWrap | |
| 862 | wordWrap | |
| 864 | 863 | ? "overflow-auto whitespace-pre-wrap wrap-break-word" |
| 865 | 864 | : "overflow-auto", |
| 866 | 865 | )} |
apps/web/src/components/files/FilePreviewPanel.tsx+13-3| ... | ... | @@ -18,6 +18,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 18 | 18 | import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; |
| 19 | 19 | import ChatMarkdown from "~/components/ChatMarkdown"; |
| 20 | 20 | import { OpenInPicker } from "~/components/chat/OpenInPicker"; |
| 21 | import { useClientSettings } from "~/hooks/useSettings"; | |
| 21 | 22 | import { useTheme } from "~/hooks/useTheme"; |
| 22 | 23 | import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; |
| 23 | 24 | import { resolveDiffThemeName } from "~/lib/diffRendering"; |
| ... | ... | @@ -248,6 +249,7 @@ interface EditableFileSurfaceProps { |
| 248 | 249 | contents: string; |
| 249 | 250 | resolvedTheme: "light" | "dark"; |
| 250 | 251 | revealRequestId: number; |
| 252 | wordWrap: boolean; | |
| 251 | 253 | onPostRender: FilePostRender; |
| 252 | 254 | onPendingChange: (relativePath: string, pending: boolean) => void; |
| 253 | 255 | } |
| ... | ... | @@ -296,6 +298,7 @@ function EditableFileSurface({ |
| 296 | 298 | contents, |
| 297 | 299 | resolvedTheme, |
| 298 | 300 | revealRequestId, |
| 301 | wordWrap, | |
| 299 | 302 | onPostRender, |
| 300 | 303 | onPendingChange, |
| 301 | 304 | }: EditableFileSurfaceProps) { |
| ... | ... | @@ -516,7 +519,7 @@ function EditableFileSurface({ |
| 516 | 519 | onGutterUtilityClick: setSelectedRange, |
| 517 | 520 | onLineSelectionChange: setSelectedRange, |
| 518 | 521 | onLineSelectionEnd: handleLineSelectionEnd, |
| 519 | overflow: "scroll", | |
| 522 | overflow: wordWrap ? "wrap" : "scroll", | |
| 520 | 523 | theme: resolveDiffThemeName(resolvedTheme), |
| 521 | 524 | themeType: resolvedTheme, |
| 522 | 525 | unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS, |
| ... | ... | @@ -557,7 +560,12 @@ function RenderedMarkdownSurface({ |
| 557 | 560 | onPendingChange, |
| 558 | 561 | }: Omit< |
| 559 | 562 | EditableFileSurfaceProps, |
| 560 | "resolvedTheme" | "composerDraftTarget" | "revealLine" | "revealRequestId" | "onPostRender" | |
| 563 | | "resolvedTheme" | |
| 564 | | "composerDraftTarget" | |
| 565 | | "revealLine" | |
| 566 | | "revealRequestId" | |
| 567 | | "wordWrap" | |
| 568 | | "onPostRender" | |
| 561 | 569 | > & { |
| 562 | 570 | threadRef: ScopedThreadRef; |
| 563 | 571 | }) { |
| ... | ... | @@ -613,6 +621,7 @@ export default function FilePreviewPanel({ |
| 613 | 621 | onPendingChange, |
| 614 | 622 | }: FilePreviewPanelProps) { |
| 615 | 623 | const { resolvedTheme } = useTheme(); |
| 624 | const wordWrap = useClientSettings((settings) => settings.wordWrap); | |
| 616 | 625 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 617 | 626 | const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId); |
| 618 | 627 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| ... | ... | @@ -844,7 +853,7 @@ export default function FilePreviewPanel({ |
| 844 | 853 | }} |
| 845 | 854 | options={{ |
| 846 | 855 | disableFileHeader: true, |
| 847 | overflow: "scroll", | |
| 856 | overflow: wordWrap ? "wrap" : "scroll", | |
| 848 | 857 | theme: resolveDiffThemeName(resolvedTheme), |
| 849 | 858 | themeType: resolvedTheme, |
| 850 | 859 | unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS, |
| ... | ... | @@ -863,6 +872,7 @@ export default function FilePreviewPanel({ |
| 863 | 872 | contents={file.data.contents} |
| 864 | 873 | resolvedTheme={resolvedTheme} |
| 865 | 874 | revealRequestId={revealRequestId} |
| 875 | wordWrap={wordWrap} | |
| 866 | 876 | onPostRender={onFilePostRender} |
| 867 | 877 | onPendingChange={onPendingChange} |
| 868 | 878 | /> |
apps/web/src/components/settings/SettingsPanels.tsx+11-13| ... | ... | @@ -391,9 +391,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 391 | 391 | ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount |
| 392 | 392 | ? ["Visible threads"] |
| 393 | 393 | : []), |
| 394 | ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap | |
| 395 | ? ["Diff line wrapping"] | |
| 396 | : []), | |
| 394 | ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), | |
| 397 | 395 | ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace |
| 398 | 396 | ? ["Diff whitespace changes"] |
| 399 | 397 | : []), |
| ... | ... | @@ -434,11 +432,11 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 434 | 432 | settings.defaultThreadEnvMode, |
| 435 | 433 | settings.newWorktreesStartFromOrigin, |
| 436 | 434 | settings.diffIgnoreWhitespace, |
| 437 | settings.diffWordWrap, | |
| 438 | 435 | settings.automaticGitFetchInterval, |
| 439 | 436 | settings.enableAssistantStreaming, |
| 440 | 437 | settings.sidebarThreadPreviewCount, |
| 441 | 438 | settings.timestampFormat, |
| 439 | settings.wordWrap, | |
| 442 | 440 | theme, |
| 443 | 441 | ], |
| 444 | 442 | ); |
| ... | ... | @@ -456,7 +454,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 456 | 454 | setTheme("system"); |
| 457 | 455 | updateSettings({ |
| 458 | 456 | timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, |
| 459 | diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap, | |
| 457 | wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, | |
| 460 | 458 | diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, |
| 461 | 459 | sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, |
| 462 | 460 | autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, |
| ... | ... | @@ -594,15 +592,15 @@ export function GeneralSettingsPanel() { |
| 594 | 592 | /> |
| 595 | 593 | |
| 596 | 594 | <SettingsRow |
| 597 | title="Diff line wrapping" | |
| 598 | description="Set the default wrap state when the diff panel opens." | |
| 595 | title="Word wrap" | |
| 596 | description="Wrap long lines in code blocks, tables, diffs, and file previews by default." | |
| 599 | 597 | resetAction={ |
| 600 | settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap ? ( | |
| 598 | settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ( | |
| 601 | 599 | <SettingResetButton |
| 602 | label="diff line wrapping" | |
| 600 | label="word wrapping" | |
| 603 | 601 | onClick={() => |
| 604 | 602 | updateSettings({ |
| 605 | diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap, | |
| 603 | wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, | |
| 606 | 604 | }) |
| 607 | 605 | } |
| 608 | 606 | /> |
| ... | ... | @@ -610,9 +608,9 @@ export function GeneralSettingsPanel() { |
| 610 | 608 | } |
| 611 | 609 | control={ |
| 612 | 610 | <Switch |
| 613 | checked={settings.diffWordWrap} | |
| 614 | onCheckedChange={(checked) => updateSettings({ diffWordWrap: Boolean(checked) })} | |
| 615 | aria-label="Wrap diff lines by default" | |
| 611 | checked={settings.wordWrap} | |
| 612 | onCheckedChange={(checked) => updateSettings({ wordWrap: Boolean(checked) })} | |
| 613 | aria-label="Wrap code, tables, diffs, and file previews by default" | |
| 616 | 614 | /> |
| 617 | 615 | } |
| 618 | 616 | /> |
packages/contracts/src/settings.test.ts+25-2| ... | ... | @@ -2,12 +2,35 @@ import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | |
| 4 | 4 | import { ProviderInstanceId } from "./providerInstance.ts"; |
| 5 | import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./settings.ts"; | |
| 6 | ||
| 5 | import { | |
| 6 | ClientSettingsSchema, | |
| 7 | DEFAULT_SERVER_SETTINGS, | |
| 8 | ServerSettings, | |
| 9 | ServerSettingsPatch, | |
| 10 | } from "./settings.ts"; | |
| 11 | ||
| 12 | const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema); | |
| 7 | 13 | const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); |
| 8 | 14 | const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); |
| 9 | 15 | const encodeServerSettings = Schema.encodeSync(ServerSettings); |
| 10 | 16 | |
| 17 | describe("ClientSettings word wrap", () => { | |
| 18 | it("defaults word wrap on", () => { | |
| 19 | expect(decodeClientSettings({}).wordWrap).toBe(true); | |
| 20 | }); | |
| 21 | ||
| 22 | it("ignores obsolete wrapping preferences", () => { | |
| 23 | const decoded = decodeClientSettings({ | |
| 24 | chatWordWrap: false, | |
| 25 | diffWordWrap: false, | |
| 26 | }); | |
| 27 | ||
| 28 | expect(decoded.wordWrap).toBe(true); | |
| 29 | expect(decoded).not.toHaveProperty("chatWordWrap"); | |
| 30 | expect(decoded).not.toHaveProperty("diffWordWrap"); | |
| 31 | }); | |
| 32 | }); | |
| 33 | ||
| 11 | 34 | describe("ServerSettings.providerInstances (slice-2 invariant)", () => { |
| 12 | 35 | it("defaults to an empty record so legacy configs without the key still decode", () => { |
| 13 | 36 | expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({}); |
packages/contracts/src/settings.ts+2-2| ... | ... | @@ -47,7 +47,6 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 47 | 47 | Schema.withDecodingDefault(Effect.succeed([])), |
| 48 | 48 | ), |
| 49 | 49 | diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 50 | diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | |
| 51 | 50 | // Model favorites. Historically keyed by provider kind, now |
| 52 | 51 | // widened to `ProviderInstanceId` so users can favorite a specific model |
| 53 | 52 | // on a custom provider instance (e.g. "Codex Personal · gpt-5") without |
| ... | ... | @@ -92,6 +91,7 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 92 | 91 | timestampFormat: TimestampFormat.pipe( |
| 93 | 92 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), |
| 94 | 93 | ), |
| 94 | wordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | |
| 95 | 95 | }); |
| 96 | 96 | export type ClientSettings = typeof ClientSettingsSchema.Type; |
| 97 | 97 | |
| ... | ... | @@ -538,7 +538,6 @@ export const ClientSettingsPatch = Schema.Struct({ |
| 538 | 538 | confirmThreadArchive: Schema.optionalKey(Schema.Boolean), |
| 539 | 539 | confirmThreadDelete: Schema.optionalKey(Schema.Boolean), |
| 540 | 540 | diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), |
| 541 | diffWordWrap: Schema.optionalKey(Schema.Boolean), | |
| 542 | 541 | favorites: Schema.optionalKey( |
| 543 | 542 | Schema.Array( |
| 544 | 543 | Schema.Struct({ |
| ... | ... | @@ -568,5 +567,6 @@ export const ClientSettingsPatch = Schema.Struct({ |
| 568 | 567 | sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), |
| 569 | 568 | sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), |
| 570 | 569 | timestampFormat: Schema.optionalKey(TimestampFormat), |
| 570 | wordWrap: Schema.optionalKey(Schema.Boolean), | |
| 571 | 571 | }); |
| 572 | 572 | export type ClientSettingsPatch = typeof ClientSettingsPatch.Type; |