authorgravatar for abdulazeez44@gmail.comAbdul Azeez <abdulazeez44@gmail.com> 2026-06-23 01:44:24+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-22 13:14:24-07:00
logfb1034546f989c11568ef1c4b5801e6a5372a07d
tree21d773330ffc2263970d6b76bfb94adf22875030
parentf5f98cf0ae48a14b524480a0942f9ce37190656c
signature Signed by PGP key B5690EEEBB952194

feat: add persistent word-wrap setting for chat code blocks and tables (#3480)

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 = {
1818 confirmThreadDelete: false,
1919 dismissedProviderUpdateNotificationKeys: [],
2020 diffIgnoreWhitespace: true,
21 diffWordWrap: true,
2221 favorites: [],
2322 providerModelPreferences: {},
2423 sidebarProjectGroupingMode: "repository_path",
......@@ -29,6 +28,7 @@ const clientSettings: ClientSettings = {
2928 sidebarThreadSortOrder: "created_at",
3029 sidebarThreadPreviewCount: 6,
3130 timestampFormat: "24-hour",
31 wordWrap: true,
3232};
3333
3434const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(ClientSettingsSchema));
apps/web/src/clientPersistenceStorage.test.ts+21
......@@ -69,4 +69,25 @@ describe("clientPersistenceStorage", () => {
6969 }),
7070 );
7171 });
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 });
7293});
apps/web/src/components/ChatMarkdown.tsx+4-2
......@@ -54,6 +54,7 @@ import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
5454import { fnv1a32 } from "../lib/diffRendering";
5555import { LRUCache } from "../lib/lruCache";
5656import { useTheme } from "../hooks/useTheme";
57import { useClientSettings } from "../hooks/useSettings";
5758import {
5859 chatMarkdownClipboardPayload,
5960 serializeTableElementToCsv,
......@@ -295,7 +296,7 @@ function getHighlighterPromise(language: string): Promise<DiffsHighlighter> {
295296function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) {
296297 const containerRef = useRef<HTMLDivElement | null>(null);
297298 const tableRef = useRef<HTMLTableElement | null>(null);
298 const [expanded, setExpanded] = useState(false);
299 const [expanded, setExpanded] = useState(useClientSettings((settings) => settings.wordWrap));
299300 const [copied, setCopied] = useState(false);
300301 const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
301302 const expandLabel = expanded ? "Collapse table cells" : "Expand table cells";
......@@ -525,10 +526,11 @@ function MarkdownCodeBlock({
525526 children: ReactNode;
526527}) {
527528 const [copied, setCopied] = useState(false);
528 const [wrapped, setWrapped] = useState(false);
529 const [wrapped, setWrapped] = useState(useClientSettings((settings) => settings.wordWrap));
529530 const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
530531 const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines";
531532 const copyLabel = copied ? "Copied" : "Copy code";
533
532534 const handleCopy = useCallback(() => {
533535 if (typeof navigator === "undefined" || navigator.clipboard == null) {
534536 return;
apps/web/src/components/DiffPanel.tsx+8-9
......@@ -186,7 +186,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
186186 const { resolvedTheme } = useTheme();
187187 const settings = useClientSettings();
188188 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
189 const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap);
189 const [wordWrap, setWordWrap] = useState(settings.wordWrap);
190190 const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace);
191191 const [baseRefQuery, setBaseRefQuery] = useState("");
192192 const [collapsedDiffFiles, setCollapsedDiffFiles] = useState<CollapsedDiffFilesState>(() => ({
......@@ -194,6 +194,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
194194 fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS,
195195 }));
196196 const codeViewRef = useRef<AnnotatableCodeViewHandle>(null);
197
197198 const routeThreadRef = useParams({
198199 strict: false,
199200 select: (params) => resolveThreadRouteRef(params),
......@@ -695,14 +696,12 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
695696 <TooltipTrigger
696697 render={
697698 <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"}
701700 variant="outline"
702701 size="xs"
703 pressed={diffWordWrap}
702 pressed={wordWrap}
704703 onPressedChange={(pressed) => {
705 setDiffWordWrap(Boolean(pressed));
704 setWordWrap(Boolean(pressed));
706705 }}
707706 />
708707 }
......@@ -710,7 +709,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
710709 <TextWrapIcon className="size-3" />
711710 </TooltipTrigger>
712711 <TooltipPopup side="top">
713 {diffWordWrap ? "Disable line wrapping" : "Enable line wrapping"}
712 {wordWrap ? "Disable line wrapping" : "Enable line wrapping"}
714713 </TooltipPopup>
715714 </Tooltip>
716715 <Tooltip>
......@@ -844,7 +843,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
844843 options={{
845844 diffStyle: diffRenderMode === "split" ? "split" : "unified",
846845 lineDiffType: "none",
847 overflow: diffWordWrap ? "wrap" : "scroll",
846 overflow: wordWrap ? "wrap" : "scroll",
848847 theme: resolveDiffThemeName(resolvedTheme),
849848 themeType: resolvedTheme as DiffThemeType,
850849 unsafeCSS: DIFF_PANEL_UNSAFE_CSS,
......@@ -860,7 +859,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
860859 <pre
861860 className={cn(
862861 "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
864863 ? "overflow-auto whitespace-pre-wrap wrap-break-word"
865864 : "overflow-auto",
866865 )}
apps/web/src/components/files/FilePreviewPanel.tsx+13-3
......@@ -18,6 +18,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1818import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview";
1919import ChatMarkdown from "~/components/ChatMarkdown";
2020import { OpenInPicker } from "~/components/chat/OpenInPicker";
21import { useClientSettings } from "~/hooks/useSettings";
2122import { useTheme } from "~/hooks/useTheme";
2223import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage";
2324import { resolveDiffThemeName } from "~/lib/diffRendering";
......@@ -248,6 +249,7 @@ interface EditableFileSurfaceProps {
248249 contents: string;
249250 resolvedTheme: "light" | "dark";
250251 revealRequestId: number;
252 wordWrap: boolean;
251253 onPostRender: FilePostRender;
252254 onPendingChange: (relativePath: string, pending: boolean) => void;
253255}
......@@ -296,6 +298,7 @@ function EditableFileSurface({
296298 contents,
297299 resolvedTheme,
298300 revealRequestId,
301 wordWrap,
299302 onPostRender,
300303 onPendingChange,
301304}: EditableFileSurfaceProps) {
......@@ -516,7 +519,7 @@ function EditableFileSurface({
516519 onGutterUtilityClick: setSelectedRange,
517520 onLineSelectionChange: setSelectedRange,
518521 onLineSelectionEnd: handleLineSelectionEnd,
519 overflow: "scroll",
522 overflow: wordWrap ? "wrap" : "scroll",
520523 theme: resolveDiffThemeName(resolvedTheme),
521524 themeType: resolvedTheme,
522525 unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS,
......@@ -557,7 +560,12 @@ function RenderedMarkdownSurface({
557560 onPendingChange,
558561}: Omit<
559562 EditableFileSurfaceProps,
560 "resolvedTheme" | "composerDraftTarget" | "revealLine" | "revealRequestId" | "onPostRender"
563 | "resolvedTheme"
564 | "composerDraftTarget"
565 | "revealLine"
566 | "revealRequestId"
567 | "wordWrap"
568 | "onPostRender"
561569> & {
562570 threadRef: ScopedThreadRef;
563571}) {
......@@ -613,6 +621,7 @@ export default function FilePreviewPanel({
613621 onPendingChange,
614622}: FilePreviewPanelProps) {
615623 const { resolvedTheme } = useTheme();
624 const wordWrap = useClientSettings((settings) => settings.wordWrap);
616625 const primaryEnvironmentId = usePrimaryEnvironmentId();
617626 const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId);
618627 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
......@@ -844,7 +853,7 @@ export default function FilePreviewPanel({
844853 }}
845854 options={{
846855 disableFileHeader: true,
847 overflow: "scroll",
856 overflow: wordWrap ? "wrap" : "scroll",
848857 theme: resolveDiffThemeName(resolvedTheme),
849858 themeType: resolvedTheme,
850859 unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS,
......@@ -863,6 +872,7 @@ export default function FilePreviewPanel({
863872 contents={file.data.contents}
864873 resolvedTheme={resolvedTheme}
865874 revealRequestId={revealRequestId}
875 wordWrap={wordWrap}
866876 onPostRender={onFilePostRender}
867877 onPendingChange={onPendingChange}
868878 />
apps/web/src/components/settings/SettingsPanels.tsx+11-13
......@@ -391,9 +391,7 @@ export function useSettingsRestore(onRestored?: () => void) {
391391 ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount
392392 ? ["Visible threads"]
393393 : []),
394 ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap
395 ? ["Diff line wrapping"]
396 : []),
394 ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []),
397395 ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
398396 ? ["Diff whitespace changes"]
399397 : []),
......@@ -434,11 +432,11 @@ export function useSettingsRestore(onRestored?: () => void) {
434432 settings.defaultThreadEnvMode,
435433 settings.newWorktreesStartFromOrigin,
436434 settings.diffIgnoreWhitespace,
437 settings.diffWordWrap,
438435 settings.automaticGitFetchInterval,
439436 settings.enableAssistantStreaming,
440437 settings.sidebarThreadPreviewCount,
441438 settings.timestampFormat,
439 settings.wordWrap,
442440 theme,
443441 ],
444442 );
......@@ -456,7 +454,7 @@ export function useSettingsRestore(onRestored?: () => void) {
456454 setTheme("system");
457455 updateSettings({
458456 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
459 diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap,
457 wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
460458 diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
461459 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
462460 autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar,
......@@ -594,15 +592,15 @@ export function GeneralSettingsPanel() {
594592 />
595593
596594 <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."
599597 resetAction={
600 settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap ? (
598 settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? (
601599 <SettingResetButton
602 label="diff line wrapping"
600 label="word wrapping"
603601 onClick={() =>
604602 updateSettings({
605 diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap,
603 wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
606604 })
607605 }
608606 />
......@@ -610,9 +608,9 @@ export function GeneralSettingsPanel() {
610608 }
611609 control={
612610 <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"
616614 />
617615 }
618616 />
packages/contracts/src/settings.test.ts+25-2
......@@ -2,12 +2,35 @@ import { describe, expect, it } from "vite-plus/test";
22import * as Schema from "effect/Schema";
33
44import { ProviderInstanceId } from "./providerInstance.ts";
5import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./settings.ts";
6
5import {
6 ClientSettingsSchema,
7 DEFAULT_SERVER_SETTINGS,
8 ServerSettings,
9 ServerSettingsPatch,
10} from "./settings.ts";
11
12const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema);
713const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);
814const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
915const encodeServerSettings = Schema.encodeSync(ServerSettings);
1016
17describe("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
1134describe("ServerSettings.providerInstances (slice-2 invariant)", () => {
1235 it("defaults to an empty record so legacy configs without the key still decode", () => {
1336 expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({});
packages/contracts/src/settings.ts+2-2
......@@ -47,7 +47,6 @@ export const ClientSettingsSchema = Schema.Struct({
4747 Schema.withDecodingDefault(Effect.succeed([])),
4848 ),
4949 diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
50 diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
5150 // Model favorites. Historically keyed by provider kind, now
5251 // widened to `ProviderInstanceId` so users can favorite a specific model
5352 // on a custom provider instance (e.g. "Codex Personal · gpt-5") without
......@@ -92,6 +91,7 @@ export const ClientSettingsSchema = Schema.Struct({
9291 timestampFormat: TimestampFormat.pipe(
9392 Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)),
9493 ),
94 wordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
9595});
9696export type ClientSettings = typeof ClientSettingsSchema.Type;
9797
......@@ -538,7 +538,6 @@ export const ClientSettingsPatch = Schema.Struct({
538538 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
539539 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
540540 diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),
541 diffWordWrap: Schema.optionalKey(Schema.Boolean),
542541 favorites: Schema.optionalKey(
543542 Schema.Array(
544543 Schema.Struct({
......@@ -568,5 +567,6 @@ export const ClientSettingsPatch = Schema.Struct({
568567 sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder),
569568 sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount),
570569 timestampFormat: Schema.optionalKey(TimestampFormat),
570 wordWrap: Schema.optionalKey(Schema.Boolean),
571571});
572572export type ClientSettingsPatch = typeof ClientSettingsPatch.Type;