| author | |
| committer | |
| log | 660cddd3bc9801e089afcabba11c62f41aeac5c3 |
| tree | 54372b41bf7593045d8ece8b5437456ef0d5deb7 |
| parent | ebb9b9fda03d33a81cad3ce7f4d1106adba3044b |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 105 insertions(+), 30 deletions(-)
apps/web/src/components/ChatView.logic.test.ts+19| ... | @@ -32,6 +32,7 @@ import { | ... | @@ -32,6 +32,7 @@ import { |
| 32 | resolveSendEnvMode, | 32 | resolveSendEnvMode, |
| 33 | resolveDraftHeroState, | 33 | resolveDraftHeroState, |
| 34 | scheduleEnvironmentReconnectWarning, | 34 | scheduleEnvironmentReconnectWarning, |
| 35 | shoulderTabReserve, | ||
| 35 | startNewThreadForProject, | 36 | startNewThreadForProject, |
| 36 | shouldDockDraftHeroForSubmission, | 37 | shouldDockDraftHeroForSubmission, |
| 37 | shouldReleaseTimelineAnchorForToolActivity, | 38 | shouldReleaseTimelineAnchorForToolActivity, |
| ... | @@ -45,6 +46,24 @@ const projectId = ProjectId.make("project-1"); | ... | @@ -45,6 +46,24 @@ const projectId = ProjectId.make("project-1"); |
| 45 | const threadId = ThreadId.make("thread-1"); | 46 | const threadId = ThreadId.make("thread-1"); |
| 46 | const now = "2026-03-29T00:00:00.000Z"; | 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 | describe("draft hero submission transition", () => { | 67 | describe("draft hero submission transition", () => { |
| 49 | it("does not dock the composer before a background submission", () => { | 68 | it("does not dock the composer before a background submission", () => { |
| 50 | expect( | 69 | expect( |
apps/web/src/components/ChatView.logic.ts+11| ... | @@ -39,6 +39,17 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; | ... | @@ -39,6 +39,17 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; |
| 39 | 39 | ||
| 40 | export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); | 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 | export function shouldDockDraftHeroForSubmission(input: { | 53 | export function shouldDockDraftHeroForSubmission(input: { |
| 43 | isDraftHeroState: boolean; | 54 | isDraftHeroState: boolean; |
| 44 | activeThreadKey: string | null; | 55 | activeThreadKey: string | null; |
apps/web/src/components/ChatView.tsx+34-22| ... | @@ -350,6 +350,7 @@ import { | ... | @@ -350,6 +350,7 @@ import { |
| 350 | shouldDockDraftHeroForSubmission, | 350 | shouldDockDraftHeroForSubmission, |
| 351 | shouldReleaseTimelineAnchorForToolActivity, | 351 | shouldReleaseTimelineAnchorForToolActivity, |
| 352 | shouldShowBranchMismatchBanner, | 352 | shouldShowBranchMismatchBanner, |
| 353 | shoulderTabReserve, | ||
| 353 | shouldShowPlanFollowUpPrompt, | 354 | shouldShowPlanFollowUpPrompt, |
| 354 | getStartedThreadModelChangeBlockReason, | 355 | getStartedThreadModelChangeBlockReason, |
| 355 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, | 356 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, |
| ... | @@ -1507,6 +1508,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1507,6 +1508,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 1507 | const legendListRef = useRef<LegendListRef | null>(null); | 1508 | const legendListRef = useRef<LegendListRef | null>(null); |
| 1508 | const [composerOverlayElement, setComposerOverlayElement] = useState<HTMLDivElement | null>(null); | 1509 | const [composerOverlayElement, setComposerOverlayElement] = useState<HTMLDivElement | null>(null); |
| 1509 | const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); | 1510 | const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); |
| 1511 | const [scrollToEndClearance, setScrollToEndClearance] = useState(0); | ||
| 1510 | const isAtEndRef = useRef(true); | 1512 | const isAtEndRef = useRef(true); |
| 1511 | const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({}); | 1513 | const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({}); |
| 1512 | const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({}); | 1514 | const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({}); |
| ... | @@ -1514,25 +1516,6 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1514,25 +1516,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 1514 | const feedbackUploadsInFlightRef = useRef(new Set<string>()); | 1516 | const feedbackUploadsInFlightRef = useRef(new Set<string>()); |
| 1515 | const terminalUiOpenByThreadRef = useRef<Record<string, boolean>>({}); | 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 | const terminalUiState = useTerminalUiStateStore((state) => | 1519 | const terminalUiState = useTerminalUiStateStore((state) => |
| 1537 | selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), | 1520 | selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), |
| 1538 | ); | 1521 | ); |
| ... | @@ -4467,6 +4450,35 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4467,6 +4450,35 @@ function ChatViewContent(props: ChatViewProps) { |
| 4467 | activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId | 4450 | activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId |
| 4468 | ? activePlan.steps | 4451 | ? activePlan.steps |
| 4469 | : null; | 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 | const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); | 4482 | const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); |
| 4471 | const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); | 4483 | const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); |
| 4472 | const linkedPullRequestStatus = useLinkedThreadPullRequest( | 4484 | const linkedPullRequestStatus = useLinkedThreadPullRequest( |
| ... | @@ -7148,7 +7160,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -7148,7 +7160,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 7148 | {showScrollToBottom && ( | 7160 | {showScrollToBottom && ( |
| 7149 | <div | 7161 | <div |
| 7150 | className="pointer-events-none absolute left-1/2 z-30 flex -translate-x-1/2 justify-center py-1.5" | 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 | <Button | 7165 | <Button |
| 7154 | aria-label="Scroll to end" | 7166 | aria-label="Scroll to end" |
| ... | @@ -7178,11 +7190,11 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -7178,11 +7190,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 7178 | ref={attachDraftHeroTransitionGroupRef} | 7190 | ref={attachDraftHeroTransitionGroupRef} |
| 7179 | 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)]" | 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 | {isDraftHeroState ? ( | 7194 | {isDraftHeroState ? ( |
| 7183 | <div className="absolute inset-x-0 bottom-full z-0"> | 7195 | <div className="absolute inset-x-0 bottom-full z-0"> |
| 7184 | <div | 7196 | <div |
| 7185 | className="pb-8" | 7197 | className="pb-8 group-has-[.chat-composer-shoulder-tab]/composer-stack:pb-4" |
| 7186 | style={ | 7198 | style={ |
| 7187 | forceExpandedMobileComposer | 7199 | forceExpandedMobileComposer |
| 7188 | ? { | 7200 | ? { |
apps/web/src/components/chat/ComposerStashBadge.tsx+1-1| ... | @@ -66,7 +66,7 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: { | ... | @@ -66,7 +66,7 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: { |
| 66 | aria-label={`Stashed prompts: ${props.count}. Open stash.`} | 66 | aria-label={`Stashed prompts: ${props.count}. Open stash.`} |
| 67 | aria-expanded={props.menuOpen} | 67 | aria-expanded={props.menuOpen} |
| 68 | className={cn( | 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 | "transition-[color,border-color] duration-200", | 70 | "transition-[color,border-color] duration-200", |
| 71 | props.menuOpen && "pointer-events-none", | 71 | props.menuOpen && "pointer-events-none", |
| 72 | props.menuOpen || props.pulsing | 72 | props.menuOpen || props.pulsing |
apps/web/src/components/chat/ComposerTasksBadge.test.tsx+35-4| ... | @@ -32,8 +32,8 @@ describe("ComposerTasksBadge", () => { | ... | @@ -32,8 +32,8 @@ describe("ComposerTasksBadge", () => { |
| 32 | expect(markup).toContain("chat-composer-tasks-tab"); | 32 | expect(markup).toContain("chat-composer-tasks-tab"); |
| 33 | expect(markup).toContain("rounded-t-xl"); | 33 | expect(markup).toContain("rounded-t-xl"); |
| 34 | expect(markup).toContain("border-b-0"); | 34 | expect(markup).toContain("border-b-0"); |
| 35 | expect(markup).toContain("left-4"); | 35 | expect(markup).toContain("left-5.5"); |
| 36 | expect(markup).toContain("right-4"); | 36 | expect(markup).toContain("right-5.5"); |
| 37 | expect(markup).toContain('data-composer-task-current="true"'); | 37 | expect(markup).toContain('data-composer-task-current="true"'); |
| 38 | expect(markup).toContain("min-w-0 flex-1 truncate"); | 38 | expect(markup).toContain("min-w-0 flex-1 truncate"); |
| 39 | expect(markup).toContain("w-20"); | 39 | expect(markup).toContain("w-20"); |
| ... | @@ -63,8 +63,8 @@ describe("ComposerTasksBadge", () => { | ... | @@ -63,8 +63,8 @@ describe("ComposerTasksBadge", () => { |
| 63 | />, | 63 | />, |
| 64 | ); | 64 | ); |
| 65 | 65 | ||
| 66 | expect(markup).toContain("right-28"); | 66 | expect(markup).toContain("right-30"); |
| 67 | expect(markup).not.toContain("right-4"); | 67 | expect(markup).not.toContain("right-5.5"); |
| 68 | }); | 68 | }); |
| 69 | 69 | ||
| 70 | it("has a compact inline fallback for occupied composer shoulders", () => { | 70 | it("has a compact inline fallback for occupied composer shoulders", () => { |
| ... | @@ -124,6 +124,37 @@ describe("ComposerTasksBadge", () => { | ... | @@ -124,6 +124,37 @@ describe("ComposerTasksBadge", () => { |
| 124 | expect(markup).not.toContain("bg-muted-foreground/25"); | 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 | it("keeps every long-list task inside the bounded scroll region", () => { | 158 | it("keeps every long-list task inside the bounded scroll region", () => { |
| 128 | const longSteps = Array.from({ length: 20 }, (_, index) => ({ | 159 | const longSteps = Array.from({ length: 20 }, (_, index) => ({ |
| 129 | step: `Task ${index + 1}`, | 160 | step: `Task ${index + 1}`, |
apps/web/src/components/chat/ComposerTasksBadge.tsx+5-3| ... | @@ -17,6 +17,8 @@ export interface ComposerTaskStep { | ... | @@ -17,6 +17,8 @@ export interface ComposerTaskStep { |
| 17 | readonly status: "pending" | "inProgress" | "completed"; | 17 | readonly status: "pending" | "inProgress" | "completed"; |
| 18 | } | 18 | } |
| 19 | 19 | ||
| 20 | const MAX_TASK_SEGMENTS = 10; | ||
| 21 | |||
| 20 | function keyedTaskSteps(steps: readonly ComposerTaskStep[]) { | 22 | function keyedTaskSteps(steps: readonly ComposerTaskStep[]) { |
| 21 | const occurrences = new Map<string, number>(); | 23 | const occurrences = new Map<string, number>(); |
| 22 | return steps.map((step) => { | 24 | return steps.map((step) => { |
| ... | @@ -33,7 +35,7 @@ function TaskSegments({ | ... | @@ -33,7 +35,7 @@ function TaskSegments({ |
| 33 | readonly className?: string; | 35 | readonly className?: string; |
| 34 | readonly steps: readonly ComposerTaskStep[]; | 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 | return ( | 40 | return ( |
| 39 | <span aria-hidden className={cn("flex w-10 shrink-0 items-center gap-0.5", className)}> | 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({ | ... | @@ -116,8 +118,8 @@ export const ComposerTasksBadge = memo(function ComposerTasksBadge({ |
| 116 | return ( | 118 | return ( |
| 117 | <div | 119 | <div |
| 118 | className={cn( | 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", | 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", |
| 120 | hasTrailingShoulder ? "right-28" : "right-4", | 122 | hasTrailingShoulder ? "right-30" : "right-5.5", |
| 121 | allDone && "text-foreground", | 123 | allDone && "text-foreground", |
| 122 | )} | 124 | )} |
| 123 | data-composer-tasks-badge="true" | 125 | data-composer-tasks-badge="true" |