diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index e703427d4b6..a1cd4024b34 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -304,7 +304,7 @@ export const BranchToolbar = memo(function BranchToolbar({ if (!hasActiveThread || !activeProject) return null; return ( -
+
{isMobile ? ( {/* Top bar */}
{/* scroll to end pill — shown when user has scrolled away from the live edge */} @@ -5776,7 +5779,7 @@ function ChatViewContent(props: ChatViewProps) { >
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b427037bdf7..25fa4d736dd 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2197,7 +2197,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
+
= TIMELINE_MINIMAP_PERSISTENT_GUTTER; } @@ -75,13 +71,10 @@ export const TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH = "22rem"; * text and swallow its pointer events. Cap the strip's width so it never * extends past the gutter into the content column; 0 disables the strip. */ -export function resolveTimelineMinimapHitStripWidth(viewportWidth: number): number { - if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) { +export function resolveTimelineMinimapHitStripWidth(sideGutter: number): number { + if (!Number.isFinite(sideGutter) || sideGutter <= 0) { return 0; } - - const contentWidth = Math.min(viewportWidth, TIMELINE_CONTENT_MAX_WIDTH); - const sideGutter = Math.max(0, (viewportWidth - contentWidth) / 2); return Math.max( 0, Math.min( diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 83ca7d3e952..4e98a1fe2c6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -196,6 +196,7 @@ function buildProps() { contentInsetEndAdjustment: 0, onIsAtEndChange: () => {}, onManualNavigation: () => {}, + transcriptWidth: "medium" as const, }; } @@ -330,21 +331,21 @@ describe("MessagesTimeline", () => { pointerY: 999, }), ).toBe(100); - expect(resolveTimelineMinimapHasPersistentGutter(832)).toBe(false); - expect(resolveTimelineMinimapHasPersistentGutter(863)).toBe(false); - expect(resolveTimelineMinimapHasPersistentGutter(864)).toBe(true); + expect(resolveTimelineMinimapHasPersistentGutter(32)).toBe(false); + expect(resolveTimelineMinimapHasPersistentGutter(47.5)).toBe(false); + expect(resolveTimelineMinimapHasPersistentGutter(48)).toBe(true); // No usable gutter (zoomed in / narrow pane): the strip must go inert // instead of overlaying the centered content column. - expect(resolveTimelineMinimapHitStripWidth(768)).toBe(0); - expect(resolveTimelineMinimapHitStripWidth(792)).toBe(0); + expect(resolveTimelineMinimapHitStripWidth(0)).toBe(0); + expect(resolveTimelineMinimapHitStripWidth(12)).toBe(0); // Partial gutter: strip shrinks to what fits between the viewport edge // and the content column. - expect(resolveTimelineMinimapHitStripWidth(820)).toBe(14); + expect(resolveTimelineMinimapHitStripWidth(26)).toBe(14); + expect(resolveTimelineMinimapHitStripWidth(20)).toBe(8); // Full gutter: unchanged 40px-wide strip. - expect(resolveTimelineMinimapHitStripWidth(872)).toBe(40); - expect(resolveTimelineMinimapHitStripWidth(1400)).toBe(40); - expect(resolveTimelineMinimapHitStripWidth(0)).toBe(0); + expect(resolveTimelineMinimapHitStripWidth(52)).toBe(40); + expect(resolveTimelineMinimapHitStripWidth(316)).toBe(40); expect(resolveTimelineMinimapHitStripWidth(Number.NaN)).toBe(0); // The collapsed target stays narrow, but an open preview keeps its full diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a429b54deaf..2ba68fc82cd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -97,7 +97,7 @@ import { } from "~/lib/previewAnnotation"; import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; -import { type TimestampFormat } from "@t3tools/contracts/settings"; +import { type TimestampFormat, type TranscriptWidth } from "@t3tools/contracts/settings"; import { formatChatTimestampTooltip, formatShortTimestamp } from "../../timestampFormat"; import { @@ -184,6 +184,7 @@ interface MessagesTimelineProps { onManualNavigation: () => void; hideEmptyPlaceholder?: boolean; topFadeEnabled?: boolean; + transcriptWidth: TranscriptWidth; } // --------------------------------------------------------------------------- @@ -219,6 +220,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onManualNavigation, hideEmptyPlaceholder = false, topFadeEnabled = false, + transcriptWidth, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); @@ -396,12 +398,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } const measure = () => { - const viewportWidth = timelineViewportElement.getBoundingClientRect().width; - const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter(viewportWidth); + const viewportLeft = timelineViewportElement.getBoundingClientRect().left; + const contentElement = timelineViewportElement.querySelector( + '[data-timeline-root="true"]', + ); + const sideGutter = contentElement + ? Math.max(0, contentElement.getBoundingClientRect().left - viewportLeft) + : 0; + const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter(sideGutter); setMinimapHasPersistentGutter((current) => current === nextHasPersistentGutter ? current : nextHasPersistentGutter, ); - setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth)); + setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(sideGutter)); }; const frame = requestAnimationFrame(measure); @@ -413,7 +421,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ cancelAnimationFrame(frame); observer.disconnect(); }; - }, [timelineViewportElement, rows.length]); + }, [timelineViewportElement, rows.length, transcriptWidth]); const sharedState = useMemo( () => ({ @@ -460,7 +468,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ // from TimelineRowCtx, which propagates through LegendList's memo. const renderItem = useCallback( ({ item }: { item: MessagesTimelineRow }) => ( -
+
), @@ -1585,7 +1596,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -
+
{inlineNodes}
); @@ -1623,7 +1634,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -
+
{inlineNodes}
); @@ -1664,7 +1675,7 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte
{comment.text.length > 0 && ( -
+
)} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index fa7c7299667..c6c7d1c3028 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -69,6 +69,7 @@ import { Button } from "../ui/button"; import { DraftInput } from "../ui/draft-input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; +import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; @@ -114,6 +115,18 @@ const THEME_OPTIONS = [ }, ] as const; +const TRANSCRIPT_TEXT_SIZE_OPTIONS = [ + { value: "small", label: "Small" }, + { value: "medium", label: "Medium" }, + { value: "large", label: "Large" }, +] as const; + +const TRANSCRIPT_WIDTH_OPTIONS = [ + { value: "narrow", label: "Narrow" }, + { value: "medium", label: "Medium" }, + { value: "wide", label: "Wide" }, +] as const; + const TIMESTAMP_FORMAT_LABELS = { locale: "System default", "12-hour": "12-hour", @@ -404,6 +417,12 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), + ...(settings.transcriptTextSize !== DEFAULT_UNIFIED_SETTINGS.transcriptTextSize + ? ["Transcript text size"] + : []), + ...(settings.transcriptWidth !== DEFAULT_UNIFIED_SETTINGS.transcriptWidth + ? ["Transcript width"] + : []), ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount ? ["Visible threads"] : []), @@ -463,6 +482,8 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, settings.timestampFormat, + settings.transcriptTextSize, + settings.transcriptWidth, settings.wordWrap, theme, ], @@ -481,6 +502,8 @@ export function useSettingsRestore(onRestored?: () => void) { setTheme("system"); updateSettings({ timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, + transcriptTextSize: DEFAULT_UNIFIED_SETTINGS.transcriptTextSize, + transcriptWidth: DEFAULT_UNIFIED_SETTINGS.transcriptWidth, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, @@ -588,6 +611,78 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + transcriptTextSize: DEFAULT_UNIFIED_SETTINGS.transcriptTextSize, + }) + } + /> + ) : null + } + control={ + { + const nextValue = value[0]; + if (nextValue === "small" || nextValue === "medium" || nextValue === "large") { + updateSettings({ transcriptTextSize: nextValue }); + } + }} + aria-label="Transcript text size" + > + {TRANSCRIPT_TEXT_SIZE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + } + /> + + + updateSettings({ transcriptWidth: DEFAULT_UNIFIED_SETTINGS.transcriptWidth }) + } + /> + ) : null + } + control={ + { + const nextValue = value[0]; + if (nextValue === "narrow" || nextValue === "medium" || nextValue === "wide") { + updateSettings({ transcriptWidth: nextValue }); + } + }} + aria-label="Transcript width" + > + {TRANSCRIPT_WIDTH_OPTIONS.map((option) => ( + + {option.label} + + ))} + + } + /> + select#reasoning-effort) select { } /* Chat markdown rendering */ +[data-transcript-width="narrow"] { + --chat-content-max-width: 40rem; +} + +[data-transcript-width="medium"] { + --chat-content-max-width: 48rem; +} + +[data-transcript-width="wide"] { + --chat-content-max-width: 64rem; +} + +[data-transcript-text-size="small"] { + --transcript-text-size: 0.8125rem; + --transcript-compact-text-size: 0.6875rem; +} + +[data-transcript-text-size="medium"] { + --transcript-text-size: 0.875rem; + --transcript-compact-text-size: 0.75rem; +} + +[data-transcript-text-size="large"] { + --transcript-text-size: 1rem; + --transcript-compact-text-size: 0.875rem; +} + .chat-markdown { min-width: 0; overflow-wrap: anywhere; word-break: break-word; + font-size: var(--transcript-text-size, 0.875rem); } .chat-markdown > :first-child { @@ -1125,21 +1153,21 @@ label:has(> select#reasoning-effort) select { } .chat-markdown h1 { - font-size: 1.25rem; + font-size: 1.4286em; } .chat-markdown h2 { - font-size: 1.125rem; + font-size: 1.2857em; } .chat-markdown h3 { - font-size: 1rem; + font-size: 1.1429em; } .chat-markdown h4, .chat-markdown h5, .chat-markdown h6 { - font-size: 0.875rem; + font-size: 1em; } .chat-markdown h6 { @@ -1222,7 +1250,7 @@ label:has(> select#reasoning-effort) select { border-top: 1px solid var(--border); padding-top: 0.75rem; color: var(--muted-foreground); - font-size: 0.75rem; + font-size: var(--transcript-compact-text-size, 0.75rem); } .chat-markdown section[data-footnotes] ol { @@ -1252,7 +1280,7 @@ label:has(> select#reasoning-effort) select { background: var(--muted); padding: 0.1rem 0.35rem; color: var(--foreground); - font-size: 0.75rem; + font-size: var(--transcript-compact-text-size, 0.75rem); } .chat-markdown a.chat-markdown-file-link { @@ -1283,7 +1311,7 @@ label:has(> select#reasoning-effort) select { border: none; background: transparent; padding: 0; - font-size: 0.75rem; + font-size: var(--transcript-compact-text-size, 0.75rem); } .chat-markdown pre { @@ -1390,7 +1418,7 @@ label:has(> select#reasoning-effort) select { border-collapse: collapse; overflow-wrap: normal; word-break: normal; - font-size: 0.75rem; + font-size: var(--transcript-compact-text-size, 0.75rem); } .chat-markdown th, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index e8eaf723e17..30c8156e6b7 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -49,6 +49,29 @@ describe("ClientSettings glass opacity", () => { }); }); +describe("ClientSettings transcript presentation", () => { + it("defaults to the existing medium transcript presentation", () => { + const settings = decodeClientSettings({}); + expect(settings.transcriptTextSize).toBe("medium"); + expect(settings.transcriptWidth).toBe("medium"); + }); + + it.each(["small", "medium", "large"] as const)("accepts transcript text size: %s", (value) => { + expect(decodeClientSettings({ transcriptTextSize: value }).transcriptTextSize).toBe(value); + expect(decodeClientSettingsPatch({ transcriptTextSize: value }).transcriptTextSize).toBe(value); + }); + + it.each(["narrow", "medium", "wide"] as const)("accepts transcript width: %s", (value) => { + expect(decodeClientSettings({ transcriptWidth: value }).transcriptWidth).toBe(value); + expect(decodeClientSettingsPatch({ transcriptWidth: value }).transcriptWidth).toBe(value); + }); + + it("rejects unsupported transcript presentation values", () => { + expect(() => decodeClientSettings({ transcriptTextSize: "extra-large" })).toThrow(); + expect(() => decodeClientSettingsPatch({ transcriptWidth: "full" })).toThrow(); + }); +}); + describe("ClientSettings sidebar v2", () => { it("defaults the beta off with a three-day auto-settle threshold", () => { const settings = decodeClientSettings({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 06f7de3db67..889aee618c7 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -59,6 +59,14 @@ export const GlassOpacity = Schema.Int.check( export type GlassOpacity = typeof GlassOpacity.Type; export const DEFAULT_GLASS_OPACITY: GlassOpacity = 80; +export const TranscriptTextSize = Schema.Literals(["small", "medium", "large"]); +export type TranscriptTextSize = typeof TranscriptTextSize.Type; +export const DEFAULT_TRANSCRIPT_TEXT_SIZE: TranscriptTextSize = "medium"; + +export const TranscriptWidth = Schema.Literals(["narrow", "medium", "wide"]); +export type TranscriptWidth = typeof TranscriptWidth.Type; +export const DEFAULT_TRANSCRIPT_WIDTH: TranscriptWidth = "medium"; + export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -118,6 +126,12 @@ export const ClientSettingsSchema = Schema.Struct({ timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), + transcriptTextSize: TranscriptTextSize.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_TRANSCRIPT_TEXT_SIZE)), + ), + transcriptWidth: TranscriptWidth.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_TRANSCRIPT_WIDTH)), + ), wordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), }); export type ClientSettings = typeof ClientSettingsSchema.Type; @@ -605,6 +619,8 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), sidebarV2Enabled: Schema.optionalKey(Schema.Boolean), timestampFormat: Schema.optionalKey(TimestampFormat), + transcriptTextSize: Schema.optionalKey(TranscriptTextSize), + transcriptWidth: Schema.optionalKey(TranscriptWidth), wordWrap: Schema.optionalKey(Schema.Boolean), }); export type ClientSettingsPatch = typeof ClientSettingsPatch.Type;