Unified · Split
| @@ -32,6 +32,7 @@ import { · 31 unmodified lines | ||
| 32 | 32 | resolveSendEnvMode, |
| 33 | 33 | resolveDraftHeroState, |
| 34 | 34 | scheduleEnvironmentReconnectWarning, |
| 35 | + shoulderTabReserve, | |
| 35 | 36 | startNewThreadForProject, |
| 36 | 37 | shouldDockDraftHeroForSubmission, |
| 37 | 38 | shouldReleaseTimelineAnchorForToolActivity, |
| @@ -45,6 +46,24 @@ const projectId = ProjectId.make("project-1"); · 7 unmodified lines | ||
| 45 | 46 | const threadId = ThreadId.make("thread-1"); |
| 46 | 47 | const now = "2026-03-29T00:00:00.000Z"; |
| 47 | 48 | |
| 49 | +describe("shoulderTabReserve", () => { | |
| 50 | + it("ignores the top drawer when measuring the shoulder tab band", () => { | |
| 51 | + const elementAt = (top: number) => ({ getBoundingClientRect: () => ({ top }) }) as HTMLElement; | |
| 52 | + const elements = new Map<string, HTMLElement>([ | |
| 53 | + ['[data-chat-composer-form="true"]', elementAt(20)], | |
| 54 | + [".chat-composer-shoulder-tab", elementAt(100)], | |
| 55 | + ['[data-chat-composer-main-surface="true"]', elementAt(128)], | |
| 56 | + ]); | |
| 57 | + const overlay = { | |
| 58 | + querySelector: (selector: string) => elements.get(selector) ?? null, | |
| 59 | + } as HTMLElement; | |
| 60 | + | |
| 61 | + expect(shoulderTabReserve(overlay)).toBe(28); | |
| 62 | + elements.set(".chat-composer-tasks-tab", elementAt(100)); | |
| 63 | + expect(shoulderTabReserve(overlay)).toBe(0); | |
| 64 | + }); | |
| 65 | +}); | |
| 66 | + | |
| 48 | 67 | describe("draft hero submission transition", () => { |
| 49 | 68 | it("does not dock the composer before a background submission", () => { |
| 50 | 69 | expect( |
| @@ -39,6 +39,17 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; · 38 unmodified lines | ||
| 39 | 39 | |
| 40 | 40 | export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); |
| 41 | 41 | |
| 42 | +export function shoulderTabReserve(overlay: HTMLElement): number { | |
| 43 | + if (overlay.querySelector(".chat-composer-tasks-tab")) return 0; | |
| 44 | + const tab = overlay.querySelector<HTMLElement>(".chat-composer-shoulder-tab"); | |
| 45 | + const surface = overlay.querySelector<HTMLElement>('[data-chat-composer-main-surface="true"]'); | |
| 46 | + if (!tab || !surface) return 0; | |
| 47 | + return Math.max( | |
| 48 | + 0, | |
| 49 | + Math.round(surface.getBoundingClientRect().top - tab.getBoundingClientRect().top), | |
| 50 | + ); | |
| 51 | +} | |
| 52 | + | |
| 42 | 53 | export function shouldDockDraftHeroForSubmission(input: { |
| 43 | 54 | isDraftHeroState: boolean; |
| 44 | 55 | activeThreadKey: string | null; |
| @@ -350,6 +350,7 @@ import { · 349 unmodified lines | ||
| 350 | 350 | shouldDockDraftHeroForSubmission, |
| 351 | 351 | shouldReleaseTimelineAnchorForToolActivity, |
| 352 | 352 | shouldShowBranchMismatchBanner, |
| 353 | + shoulderTabReserve, | |
| 353 | 354 | shouldShowPlanFollowUpPrompt, |
| 354 | 355 | getStartedThreadModelChangeBlockReason, |
| 355 | 356 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, |
| @@ -1507,6 +1508,7 @@ function ChatViewContent(props: ChatViewProps) { · 1151 unmodified lines | ||
| 1507 | 1508 | const legendListRef = useRef<LegendListRef | null>(null); |
| 1508 | 1509 | const [composerOverlayElement, setComposerOverlayElement] = useState<HTMLDivElement | null>(null); |
| 1509 | 1510 | const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); |
| 1511 | + const [scrollToEndClearance, setScrollToEndClearance] = useState(0); | |
| 1510 | 1512 | const isAtEndRef = useRef(true); |
| 1511 | 1513 | const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({}); |
| 1512 | 1514 | const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({}); |
| @@ -1514,25 +1516,6 @@ function ChatViewContent(props: ChatViewProps) { · 1 unmodified line | ||
| 1514 | 1516 | const feedbackUploadsInFlightRef = useRef(new Set<string>()); |
| 1515 | 1517 | const terminalUiOpenByThreadRef = useRef<Record<string, boolean>>({}); |
| 1516 | 1518 | |
| 1517 | − useLayoutEffect(() => { | |
| 1518 | − if (!composerOverlayElement) return; | |
| 1519 | − | |
| 1520 | − const updateHeight = () => { | |
| 1521 | − const nextHeight = Math.ceil(composerOverlayElement.getBoundingClientRect().height); | |
| 1522 | − if (nextHeight <= 0) return; | |
| 1523 | − setComposerOverlayHeight((currentHeight) => | |
| 1524 | − currentHeight === nextHeight ? currentHeight : nextHeight, | |
| 1525 | − ); | |
| 1526 | − }; | |
| 1527 | − | |
| 1528 | − updateHeight(); | |
| 1529 | − if (typeof ResizeObserver === "undefined") return; | |
| 1530 | − | |
| 1531 | − const observer = new ResizeObserver(updateHeight); | |
| 1532 | − observer.observe(composerOverlayElement); | |
| 1533 | − return () => observer.disconnect(); | |
| 1534 | − }, [composerOverlayElement]); | |
| 1535 | − | |
| 1536 | 1519 | const terminalUiState = useTerminalUiStateStore((state) => |
| 1537 | 1520 | selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), |
| 1538 | 1521 | ); |
| @@ -4467,6 +4450,35 @@ function ChatViewContent(props: ChatViewProps) { · 2928 unmodified lines | ||
| 4467 | 4450 | activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId |
| 4468 | 4451 | ? activePlan.steps |
| 4469 | 4452 | : null; |
| 4453 | + | |
| 4454 | + useLayoutEffect(() => { | |
| 4455 | + if (!composerOverlayElement) return; | |
| 4456 | + | |
| 4457 | + const updateHeight = () => { | |
| 4458 | + const nextHeight = Math.ceil(composerOverlayElement.getBoundingClientRect().height); | |
| 4459 | + if (nextHeight <= 0) return; | |
| 4460 | + setComposerOverlayHeight((currentHeight) => | |
| 4461 | + currentHeight === nextHeight ? currentHeight : nextHeight, | |
| 4462 | + ); | |
| 4463 | + const nextClearance = Math.max(0, nextHeight - shoulderTabReserve(composerOverlayElement)); | |
| 4464 | + setScrollToEndClearance((currentClearance) => | |
| 4465 | + currentClearance === nextClearance ? currentClearance : nextClearance, | |
| 4466 | + ); | |
| 4467 | + }; | |
| 4468 | + | |
| 4469 | + updateHeight(); | |
| 4470 | + if (typeof ResizeObserver === "undefined") return; | |
| 4471 | + | |
| 4472 | + const resizeObserver = new ResizeObserver(updateHeight); | |
| 4473 | + resizeObserver.observe(composerOverlayElement); | |
| 4474 | + const tabObserver = new MutationObserver(updateHeight); | |
| 4475 | + tabObserver.observe(composerOverlayElement, { childList: true, subtree: true }); | |
| 4476 | + return () => { | |
| 4477 | + resizeObserver.disconnect(); | |
| 4478 | + tabObserver.disconnect(); | |
| 4479 | + }; | |
| 4480 | + }, [composerOverlayElement]); | |
| 4481 | + | |
| 4470 | 4482 | const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); |
| 4471 | 4483 | const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); |
| 4472 | 4484 | const linkedPullRequestStatus = useLinkedThreadPullRequest( |
| @@ -7148,7 +7160,7 @@ function ChatViewContent(props: ChatViewProps) { · 2675 unmodified lines | ||
| 7148 | 7160 | {showScrollToBottom && ( |
| 7149 | 7161 | <div |
| 7150 | 7162 | className="pointer-events-none absolute left-1/2 z-30 flex -translate-x-1/2 justify-center py-1.5" |
| 7151 | − style={{ bottom: composerOverlayHeight + 4 }} | |
| 7163 | + style={{ bottom: scrollToEndClearance + 4 }} | |
| 7152 | 7164 | > |
| 7153 | 7165 | <Button |
| 7154 | 7166 | aria-label="Scroll to end" |
| @@ -7178,11 +7190,11 @@ function ChatViewContent(props: ChatViewProps) { · 23 unmodified lines | ||
| 7178 | 7190 | ref={attachDraftHeroTransitionGroupRef} |
| 7179 | 7191 | className="w-full ps-[calc(env(safe-area-inset-left)+0.75rem)] pe-[calc(env(safe-area-inset-right)+0.75rem)] sm:ps-[calc(env(safe-area-inset-left)+1.25rem)] sm:pe-[calc(env(safe-area-inset-right)+1.25rem)]" |
| 7180 | 7192 | > |
| 7181 | − <div className="pointer-events-auto relative z-10"> | |
| 7193 | + <div className="group/composer-stack pointer-events-auto relative z-10"> | |
| 7182 | 7194 | {isDraftHeroState ? ( |
| 7183 | 7195 | <div className="absolute inset-x-0 bottom-full z-0"> |
| 7184 | 7196 | <div |
| 7185 | − className="pb-8" | |
| 7197 | + className="pb-8 group-has-[.chat-composer-shoulder-tab]/composer-stack:pb-4" | |
| 7186 | 7198 | style={ |
| 7187 | 7199 | forceExpandedMobileComposer |
| 7188 | 7200 | ? { |
| @@ -66,7 +66,7 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: { · 65 unmodified lines | ||
| 66 | 66 | aria-label={`Stashed prompts: ${props.count}. Open stash.`} |
| 67 | 67 | aria-expanded={props.menuOpen} |
| 68 | 68 | className={cn( |
| 69 | − "chat-composer-shoulder-tab chat-composer-stash-tab absolute -top-7 right-4 z-0 inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-t-xl border border-b-0 px-3 pb-1 text-xs leading-none", | |
| 69 | + "chat-composer-shoulder-tab chat-composer-stash-tab absolute -top-7 right-5.5 z-0 inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-t-xl border border-b-0 px-3 pb-1 text-xs leading-none", | |
| 70 | 70 | "transition-[color,border-color] duration-200", |
| 71 | 71 | props.menuOpen && "pointer-events-none", |
| 72 | 72 | props.menuOpen || props.pulsing |
| @@ -32,8 +32,8 @@ describe("ComposerTasksBadge", () => { · 31 unmodified lines | ||
| 32 | 32 | expect(markup).toContain("chat-composer-tasks-tab"); |
| 33 | 33 | expect(markup).toContain("rounded-t-xl"); |
| 34 | 34 | expect(markup).toContain("border-b-0"); |
| 35 | − expect(markup).toContain("left-4"); | |
| 36 | − expect(markup).toContain("right-4"); | |
| 35 | + expect(markup).toContain("left-5.5"); | |
| 36 | + expect(markup).toContain("right-5.5"); | |
| 37 | 37 | expect(markup).toContain('data-composer-task-current="true"'); |
| 38 | 38 | expect(markup).toContain("min-w-0 flex-1 truncate"); |
| 39 | 39 | expect(markup).toContain("w-20"); |
| @@ -63,8 +63,8 @@ describe("ComposerTasksBadge", () => { · 23 unmodified lines | ||
| 63 | 63 | />, |
| 64 | 64 | ); |
| 65 | 65 | |
| 66 | − expect(markup).toContain("right-28"); | |
| 67 | − expect(markup).not.toContain("right-4"); | |
| 66 | + expect(markup).toContain("right-30"); | |
| 67 | + expect(markup).not.toContain("right-5.5"); | |
| 68 | 68 | }); |
| 69 | 69 | |
| 70 | 70 | it("has a compact inline fallback for occupied composer shoulders", () => { |
| @@ -124,6 +124,37 @@ describe("ComposerTasksBadge", () => { · 53 unmodified lines | ||
| 124 | 124 | expect(markup).not.toContain("bg-muted-foreground/25"); |
| 125 | 125 | }); |
| 126 | 126 | |
| 127 | + it("drops the step segments when they would render as a blank gap", () => { | |
| 128 | + const manySteps = Array.from({ length: 24 }, (_, index) => ({ | |
| 129 | + step: `Step ${index + 1}`, | |
| 130 | + status: "pending" as const, | |
| 131 | + })); | |
| 132 | + const tab = renderToStaticMarkup( | |
| 133 | + <ComposerTasksBadge | |
| 134 | + expanded={false} | |
| 135 | + onDismiss={() => undefined} | |
| 136 | + onToggle={() => undefined} | |
| 137 | + progress={{ ...progress, totalSteps: manySteps.length }} | |
| 138 | + steps={manySteps} | |
| 139 | + />, | |
| 140 | + ); | |
| 141 | + const inline = renderToStaticMarkup( | |
| 142 | + <ComposerTasksBadge | |
| 143 | + expanded={false} | |
| 144 | + onDismiss={() => undefined} | |
| 145 | + onToggle={() => undefined} | |
| 146 | + placement="inline" | |
| 147 | + progress={{ ...progress, totalSteps: manySteps.length }} | |
| 148 | + steps={manySteps} | |
| 149 | + />, | |
| 150 | + ); | |
| 151 | + | |
| 152 | + expect(tab).not.toContain("w-20"); | |
| 153 | + expect(tab).toContain("1/24"); | |
| 154 | + expect(inline).not.toContain("w-10"); | |
| 155 | + expect(inline).toContain("1/24"); | |
| 156 | + }); | |
| 157 | + | |
| 127 | 158 | it("keeps every long-list task inside the bounded scroll region", () => { |
| 128 | 159 | const longSteps = Array.from({ length: 20 }, (_, index) => ({ |
| 129 | 160 | step: `Task ${index + 1}`, |
| @@ -17,6 +17,8 @@ export interface ComposerTaskStep { · 16 unmodified lines | ||
| 17 | 17 | readonly status: "pending" | "inProgress" | "completed"; |
| 18 | 18 | } |
| 19 | 19 | |
| 20 | +const MAX_TASK_SEGMENTS = 10; | |
| 21 | + | |
| 20 | 22 | function keyedTaskSteps(steps: readonly ComposerTaskStep[]) { |
| 21 | 23 | const occurrences = new Map<string, number>(); |
| 22 | 24 | return steps.map((step) => { |
| @@ -33,7 +35,7 @@ function TaskSegments({ · 10 unmodified lines | ||
| 33 | 35 | readonly className?: string; |
| 34 | 36 | readonly steps: readonly ComposerTaskStep[]; |
| 35 | 37 | }) { |
| 36 | − if (steps.length <= 1) return null; | |
| 38 | + if (steps.length <= 1 || steps.length > MAX_TASK_SEGMENTS) return null; | |
| 37 | 39 | |
| 38 | 40 | return ( |
| 39 | 41 | <span aria-hidden className={cn("flex w-10 shrink-0 items-center gap-0.5", className)}> |
| @@ -116,8 +118,8 @@ export const ComposerTasksBadge = memo(function ComposerTasksBadge({ · 76 unmodified lines | ||
| 116 | 118 | return ( |
| 117 | 119 | <div |
| 118 | 120 | className={cn( |
| 119 | − "chat-composer-shoulder-tab chat-composer-tasks-tab absolute -top-7 left-4 z-0 flex h-8 items-center gap-1 rounded-t-xl border border-b-0 px-2 pb-1 text-xs leading-none text-muted-foreground", | |
| 120 | − hasTrailingShoulder ? "right-28" : "right-4", | |
| 121 | + "chat-composer-shoulder-tab chat-composer-tasks-tab absolute -top-7 left-5.5 z-0 flex h-8 items-center gap-1 rounded-t-xl border border-b-0 px-2 pb-1 text-xs leading-none text-muted-foreground", | |
| 122 | + hasTrailingShoulder ? "right-30" : "right-5.5", | |
| 121 | 123 | allDone && "text-foreground", |
| 122 | 124 | )} |
| 123 | 125 | data-composer-tasks-badge="true" |