authorgravatar for raphael@lty.ggRaphael Lüthy <raphael@lty.gg> 2026-03-24 17:34:22+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-24 09:34:22-07:00
logd4a7e1ac0b03399376a0acde78b58365715415ae
treefebf7f592bbcb55a451ce5c3ddd2bb3faa7598d2
parentbe1abc88fda0beacb606b2e7de6f4e82cc31155f
signature Signed by PGP key B5690EEEBB952194

feat: add word wrapping setting and in-panel button (#1326)


3 files changed, 88 insertions(+), 20 deletions(-)

apps/web/src/appSettings.ts+1
......@@ -56,6 +56,7 @@ export const AppSettingsSchema = Schema.Struct({
5656 codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")),
5757 defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)),
5858 confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)),
59 diffWordWrap: Schema.Boolean.pipe(withDefaults(() => false)),
5960 enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)),
6061 timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)),
6162 customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])),
apps/web/src/components/DiffPanel.tsx+58-20
......@@ -3,7 +3,13 @@ import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/reac
33import { useQuery } from "@tanstack/react-query";
44import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
55import { ThreadId, type TurnId } from "@t3tools/contracts";
6import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
6import {
7 ChevronLeftIcon,
8 ChevronRightIcon,
9 Columns2Icon,
10 Rows3Icon,
11 TextWrapIcon,
12} from "lucide-react";
713import {
814 type WheelEvent as ReactWheelEvent,
915 useCallback,
......@@ -162,8 +168,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
162168 const { resolvedTheme } = useTheme();
163169 const { settings } = useAppSettings();
164170 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
171 const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap);
165172 const patchViewportRef = useRef<HTMLDivElement>(null);
166173 const turnStripRef = useRef<HTMLDivElement>(null);
174 const previousDiffOpenRef = useRef(false);
167175 const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false);
168176 const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false);
169177 const routeThreadId = useParams({
......@@ -171,6 +179,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
171179 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
172180 });
173181 const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) });
182 const diffOpen = diffSearch.diff === "1";
174183 const activeThreadId = routeThreadId;
175184 const activeThread = useStore((store) =>
176185 activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined,
......@@ -293,6 +302,13 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
293302 );
294303 }, [renderablePatch]);
295304
305 useEffect(() => {
306 if (diffOpen && !previousDiffOpenRef.current) {
307 setDiffWordWrap(settings.diffWordWrap);
308 }
309 previousDiffOpenRef.current = diffOpen;
310 }, [diffOpen, settings.diffWordWrap]);
311
296312 useEffect(() => {
297313 if (!selectedFilePath || !patchViewportRef.current) {
298314 return;
......@@ -490,25 +506,39 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
490506 ))}
491507 </div>
492508 </div>
493 <ToggleGroup
494 className="shrink-0 [-webkit-app-region:no-drag]"
495 variant="outline"
496 size="xs"
497 value={[diffRenderMode]}
498 onValueChange={(value) => {
499 const next = value[0];
500 if (next === "stacked" || next === "split") {
501 setDiffRenderMode(next);
502 }
503 }}
504 >
505 <Toggle aria-label="Stacked diff view" value="stacked">
506 <Rows3Icon className="size-3" />
507 </Toggle>
508 <Toggle aria-label="Split diff view" value="split">
509 <Columns2Icon className="size-3" />
509 <div className="flex shrink-0 items-center gap-1 [-webkit-app-region:no-drag]">
510 <ToggleGroup
511 className="shrink-0"
512 variant="outline"
513 size="xs"
514 value={[diffRenderMode]}
515 onValueChange={(value) => {
516 const next = value[0];
517 if (next === "stacked" || next === "split") {
518 setDiffRenderMode(next);
519 }
520 }}
521 >
522 <Toggle aria-label="Stacked diff view" value="stacked">
523 <Rows3Icon className="size-3" />
524 </Toggle>
525 <Toggle aria-label="Split diff view" value="split">
526 <Columns2Icon className="size-3" />
527 </Toggle>
528 </ToggleGroup>
529 <Toggle
530 aria-label={diffWordWrap ? "Disable diff line wrapping" : "Enable diff line wrapping"}
531 title={diffWordWrap ? "Disable line wrapping" : "Enable line wrapping"}
532 variant="outline"
533 size="xs"
534 pressed={diffWordWrap}
535 onPressedChange={(pressed) => {
536 setDiffWordWrap(Boolean(pressed));
537 }}
538 >
539 <TextWrapIcon className="size-3" />
510540 </Toggle>
511 </ToggleGroup>
541 </div>
512542 </>
513543 );
514544
......@@ -582,6 +612,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
582612 options={{
583613 diffStyle: diffRenderMode === "split" ? "split" : "unified",
584614 lineDiffType: "none",
615 overflow: diffWordWrap ? "wrap" : "scroll",
585616 theme: resolveDiffThemeName(resolvedTheme),
586617 themeType: resolvedTheme as DiffThemeType,
587618 unsafeCSS: DIFF_PANEL_UNSAFE_CSS,
......@@ -595,7 +626,14 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
595626 <div className="h-full overflow-auto p-2">
596627 <div className="space-y-2">
597628 <p className="text-[11px] text-muted-foreground/75">{renderablePatch.reason}</p>
598 <pre className="max-h-[72vh] overflow-auto rounded-md border border-border/70 bg-background/70 p-3 font-mono text-[11px] leading-relaxed text-muted-foreground/90">
629 <pre
630 className={cn(
631 "max-h-[72vh] rounded-md border border-border/70 bg-background/70 p-3 font-mono text-[11px] leading-relaxed text-muted-foreground/90",
632 diffWordWrap
633 ? "overflow-auto whitespace-pre-wrap wrap-break-word"
634 : "overflow-auto",
635 )}
636 >
599637 {renderablePatch.text}
600638 </pre>
601639 </div>
apps/web/src/routes/_chat.settings.tsx+29
......@@ -252,6 +252,7 @@ function SettingsRouteView() {
252252 const changedSettingLabels = [
253253 ...(theme !== "system" ? ["Theme"] : []),
254254 ...(settings.timestampFormat !== defaults.timestampFormat ? ["Time format"] : []),
255 ...(settings.diffWordWrap !== defaults.diffWordWrap ? ["Diff line wrapping"] : []),
255256 ...(settings.enableAssistantStreaming !== defaults.enableAssistantStreaming
256257 ? ["Assistant output"]
257258 : []),
......@@ -500,6 +501,34 @@ function SettingsRouteView() {
500501 }
501502 />
502503
504 <SettingsRow
505 title="Diff line wrapping"
506 description="Set the default wrap state when the diff panel opens. The in-panel wrap toggle only affects the current diff session."
507 resetAction={
508 settings.diffWordWrap !== defaults.diffWordWrap ? (
509 <SettingResetButton
510 label="diff line wrapping"
511 onClick={() =>
512 updateSettings({
513 diffWordWrap: defaults.diffWordWrap,
514 })
515 }
516 />
517 ) : null
518 }
519 control={
520 <Switch
521 checked={settings.diffWordWrap}
522 onCheckedChange={(checked) =>
523 updateSettings({
524 diffWordWrap: Boolean(checked),
525 })
526 }
527 aria-label="Wrap diff lines by default"
528 />
529 }
530 />
531
503532 <SettingsRow
504533 title="Assistant output"
505534 description="Show token-by-token output while a response is in progress."