authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-08-29 19:25:20+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-29 09:25:20-07:00
log660cddd3bc9801e089afcabba11c62f41aeac5c3
tree54372b41bf7593045d8ece8b5437456ef0d5deb7
parentebb9b9fda03d33a81cad3ce7f4d1106adba3044b
signature Signed by PGP key B5690EEEBB952194

fix(web): four composer spacing defects (#8090)


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");
45const threadId = ThreadId.make("thread-1");46const threadId = ThreadId.make("thread-1");
46const now = "2026-03-29T00:00:00.000Z";47const now = "2026-03-29T00:00:00.000Z";
4748
49describe("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
48describe("draft hero submission transition", () => {67describe("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;
3939
40export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);40export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
4141
42export 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
42export function shouldDockDraftHeroForSubmission(input: {53export 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>>({});
15161518
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?.turnId4450 activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId
4468 ? activePlan.steps4451 ? 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 <div7161 <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 <Button7165 <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 <div7196 <div
7185 className="pb-8"7197 className="pb-8 group-has-[.chat-composer-shoulder-tab]/composer-stack:pb-4"
7186 style={7198 style={
7187 forceExpandedMobileComposer7199 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.pulsing72 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 );
6565
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 });
6969
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 });
126126
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}
1919
20const MAX_TASK_SEGMENTS = 10;
21
20function keyedTaskSteps(steps: readonly ComposerTaskStep[]) {22function 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;
3739
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 <div119 <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"