From b2b8b1480a1d69efc6f41b9e4de513ab7597eb6e Mon Sep 17 00:00:00 2001 From: charlielockyer-rice Date: Fri, 24 Jul 2026 15:20:13 -0400 Subject: [PATCH 1/3] feat(web): add transcript presentation settings --- apps/web/src/components/BranchToolbar.tsx | 2 +- apps/web/src/components/ChatMarkdown.tsx | 5 +- apps/web/src/components/ChatView.tsx | 8 +- apps/web/src/components/chat/ChatComposer.tsx | 2 +- .../components/chat/ComposerBannerStack.tsx | 7 +- .../src/components/chat/MessagesTimeline.tsx | 11 ++- .../components/settings/SettingsPanels.tsx | 95 +++++++++++++++++++ apps/web/src/index.css | 44 +++++++-- packages/contracts/src/settings.test.ts | 23 +++++ packages/contracts/src/settings.ts | 16 ++++ 10 files changed, 192 insertions(+), 21 deletions(-) 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 ? ( +
{rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null}
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)
+
( -
+
), @@ -1585,7 +1588,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -
+
{inlineNodes}
); @@ -1623,7 +1626,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -
+
{inlineNodes}
); @@ -1664,7 +1667,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; From d8f7d47f5ad8350c4096d13597ef8c38bf73a43a Mon Sep 17 00:00:00 2001 From: charlielockyer-rice Date: Fri, 24 Jul 2026 18:52:14 -0400 Subject: [PATCH 2/3] fix(web): measure minimap content gutter --- .../components/chat/MessagesTimeline.logic.ts | 34 ++++++++++++------- .../components/chat/MessagesTimeline.test.tsx | 25 ++++++++------ .../src/components/chat/MessagesTimeline.tsx | 17 ++++++++-- 3 files changed, 51 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 3227bac2413..c13eed53c0a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -13,7 +13,6 @@ export const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; export const TIMELINE_MINIMAP_ITEM_SPACING = 8; export const TIMELINE_MINIMAP_MIN_ITEMS = 2; export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)"; -export const TIMELINE_CONTENT_MAX_WIDTH = 768; export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; export interface TimelineEndState { @@ -54,13 +53,24 @@ export function resolveTimelineMinimapIndexFromPointer(input: { return Math.max(0, Math.min(input.itemCount - 1, Math.round(progress * (input.itemCount - 1)))); } -export function resolveTimelineMinimapHasPersistentGutter(viewportWidth: number): boolean { - if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) { - return false; +function resolveTimelineContentSideGutter(viewportWidth: number, contentWidth: number): number { + if ( + !Number.isFinite(viewportWidth) || + viewportWidth <= 0 || + !Number.isFinite(contentWidth) || + contentWidth <= 0 + ) { + return 0; } - const contentWidth = Math.min(viewportWidth, TIMELINE_CONTENT_MAX_WIDTH); - const sideGutter = Math.max(0, (viewportWidth - contentWidth) / 2); + return Math.max(0, (viewportWidth - Math.min(viewportWidth, contentWidth)) / 2); +} + +export function resolveTimelineMinimapHasPersistentGutter( + viewportWidth: number, + contentWidth: number, +): boolean { + const sideGutter = resolveTimelineContentSideGutter(viewportWidth, contentWidth); return sideGutter >= TIMELINE_MINIMAP_PERSISTENT_GUTTER; } @@ -75,13 +85,11 @@ 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) { - return 0; - } - - const contentWidth = Math.min(viewportWidth, TIMELINE_CONTENT_MAX_WIDTH); - const sideGutter = Math.max(0, (viewportWidth - contentWidth) / 2); +export function resolveTimelineMinimapHitStripWidth( + viewportWidth: number, + contentWidth: number, +): number { + const sideGutter = resolveTimelineContentSideGutter(viewportWidth, contentWidth); 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..09421572877 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -330,22 +330,27 @@ describe("MessagesTimeline", () => { pointerY: 999, }), ).toBe(100); - expect(resolveTimelineMinimapHasPersistentGutter(832)).toBe(false); - expect(resolveTimelineMinimapHasPersistentGutter(863)).toBe(false); - expect(resolveTimelineMinimapHasPersistentGutter(864)).toBe(true); + expect(resolveTimelineMinimapHasPersistentGutter(832, 768)).toBe(false); + expect(resolveTimelineMinimapHasPersistentGutter(863, 768)).toBe(false); + expect(resolveTimelineMinimapHasPersistentGutter(864, 768)).toBe(true); + // A wide transcript can consume almost all of a narrower viewport, so its + // actual rendered width must win over the former fixed 768px assumption. + expect(resolveTimelineMinimapHasPersistentGutter(900, 860)).toBe(false); // 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(768, 768)).toBe(0); + expect(resolveTimelineMinimapHitStripWidth(792, 768)).toBe(0); // Partial gutter: strip shrinks to what fits between the viewport edge // and the content column. - expect(resolveTimelineMinimapHitStripWidth(820)).toBe(14); + expect(resolveTimelineMinimapHitStripWidth(820, 768)).toBe(14); + expect(resolveTimelineMinimapHitStripWidth(900, 860)).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(Number.NaN)).toBe(0); + expect(resolveTimelineMinimapHitStripWidth(872, 768)).toBe(40); + expect(resolveTimelineMinimapHitStripWidth(1400, 768)).toBe(40); + expect(resolveTimelineMinimapHitStripWidth(0, 768)).toBe(0); + expect(resolveTimelineMinimapHitStripWidth(Number.NaN, 768)).toBe(0); + expect(resolveTimelineMinimapHitStripWidth(900, 0)).toBe(0); // The collapsed target stays narrow, but an open preview keeps its full // 20rem width plus the 2rem offset from the minimap rail interactive. diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 18ce20154c6..4d21dd1c49c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -397,17 +397,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const measure = () => { const viewportWidth = timelineViewportElement.getBoundingClientRect().width; - const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter(viewportWidth); + const contentElement = timelineViewportElement.querySelector( + '[data-timeline-root="true"]', + ); + const contentWidth = contentElement?.getBoundingClientRect().width ?? viewportWidth; + const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter( + viewportWidth, + contentWidth, + ); setMinimapHasPersistentGutter((current) => current === nextHasPersistentGutter ? current : nextHasPersistentGutter, ); - setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth)); + setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth, contentWidth)); }; const frame = requestAnimationFrame(measure); const observer = new ResizeObserver(measure); observer.observe(timelineViewportElement); + const contentElement = timelineViewportElement.querySelector( + '[data-timeline-root="true"]', + ); + if (contentElement) { + observer.observe(contentElement); + } return () => { cancelAnimationFrame(frame); From 160179dcc82b8c2f48db265ff59c4554b2cce8bb Mon Sep 17 00:00:00 2001 From: charlielockyer-rice Date: Fri, 24 Jul 2026 19:06:41 -0400 Subject: [PATCH 3/3] refactor(web): scope transcript presentation --- apps/web/src/components/ChatView.tsx | 9 +++--- .../components/chat/MessagesTimeline.logic.ts | 29 +++++-------------- .../components/chat/MessagesTimeline.test.tsx | 26 +++++++---------- .../src/components/chat/MessagesTimeline.tsx | 25 +++++++--------- 4 files changed, 32 insertions(+), 57 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index eee05cb5668..ad90b4e8347 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5606,11 +5606,7 @@ function ChatViewContent(props: ChatViewProps) { ) : null; return ( -
+
{rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null}
{/* Top bar */}
{/* scroll to end pill — shown when user has scrolled away from the live edge */} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c13eed53c0a..5bf818e80d0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -53,24 +53,10 @@ export function resolveTimelineMinimapIndexFromPointer(input: { return Math.max(0, Math.min(input.itemCount - 1, Math.round(progress * (input.itemCount - 1)))); } -function resolveTimelineContentSideGutter(viewportWidth: number, contentWidth: number): number { - if ( - !Number.isFinite(viewportWidth) || - viewportWidth <= 0 || - !Number.isFinite(contentWidth) || - contentWidth <= 0 - ) { - return 0; +export function resolveTimelineMinimapHasPersistentGutter(sideGutter: number): boolean { + if (!Number.isFinite(sideGutter) || sideGutter <= 0) { + return false; } - - return Math.max(0, (viewportWidth - Math.min(viewportWidth, contentWidth)) / 2); -} - -export function resolveTimelineMinimapHasPersistentGutter( - viewportWidth: number, - contentWidth: number, -): boolean { - const sideGutter = resolveTimelineContentSideGutter(viewportWidth, contentWidth); return sideGutter >= TIMELINE_MINIMAP_PERSISTENT_GUTTER; } @@ -85,11 +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, - contentWidth: number, -): number { - const sideGutter = resolveTimelineContentSideGutter(viewportWidth, contentWidth); +export function resolveTimelineMinimapHitStripWidth(sideGutter: number): number { + if (!Number.isFinite(sideGutter) || sideGutter <= 0) { + return 0; + } 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 09421572877..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,27 +331,22 @@ describe("MessagesTimeline", () => { pointerY: 999, }), ).toBe(100); - expect(resolveTimelineMinimapHasPersistentGutter(832, 768)).toBe(false); - expect(resolveTimelineMinimapHasPersistentGutter(863, 768)).toBe(false); - expect(resolveTimelineMinimapHasPersistentGutter(864, 768)).toBe(true); - // A wide transcript can consume almost all of a narrower viewport, so its - // actual rendered width must win over the former fixed 768px assumption. - expect(resolveTimelineMinimapHasPersistentGutter(900, 860)).toBe(false); + 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, 768)).toBe(0); - expect(resolveTimelineMinimapHitStripWidth(792, 768)).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, 768)).toBe(14); - expect(resolveTimelineMinimapHitStripWidth(900, 860)).toBe(8); + expect(resolveTimelineMinimapHitStripWidth(26)).toBe(14); + expect(resolveTimelineMinimapHitStripWidth(20)).toBe(8); // Full gutter: unchanged 40px-wide strip. - expect(resolveTimelineMinimapHitStripWidth(872, 768)).toBe(40); - expect(resolveTimelineMinimapHitStripWidth(1400, 768)).toBe(40); - expect(resolveTimelineMinimapHitStripWidth(0, 768)).toBe(0); - expect(resolveTimelineMinimapHitStripWidth(Number.NaN, 768)).toBe(0); - expect(resolveTimelineMinimapHitStripWidth(900, 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 // 20rem width plus the 2rem offset from the minimap rail interactive. diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 4d21dd1c49c..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,37 +398,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } const measure = () => { - const viewportWidth = timelineViewportElement.getBoundingClientRect().width; + const viewportLeft = timelineViewportElement.getBoundingClientRect().left; const contentElement = timelineViewportElement.querySelector( '[data-timeline-root="true"]', ); - const contentWidth = contentElement?.getBoundingClientRect().width ?? viewportWidth; - const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter( - viewportWidth, - contentWidth, - ); + const sideGutter = contentElement + ? Math.max(0, contentElement.getBoundingClientRect().left - viewportLeft) + : 0; + const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter(sideGutter); setMinimapHasPersistentGutter((current) => current === nextHasPersistentGutter ? current : nextHasPersistentGutter, ); - setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth, contentWidth)); + setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(sideGutter)); }; const frame = requestAnimationFrame(measure); const observer = new ResizeObserver(measure); observer.observe(timelineViewportElement); - const contentElement = timelineViewportElement.querySelector( - '[data-timeline-root="true"]', - ); - if (contentElement) { - observer.observe(contentElement); - } return () => { cancelAnimationFrame(frame); observer.disconnect(); }; - }, [timelineViewportElement, rows.length]); + }, [timelineViewportElement, rows.length, transcriptWidth]); const sharedState = useMemo( () => ({