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 {
3232 resolveSendEnvMode,
3333 resolveDraftHeroState,
3434 scheduleEnvironmentReconnectWarning,
35 shoulderTabReserve,
3536 startNewThreadForProject,
3637 shouldDockDraftHeroForSubmission,
3738 shouldReleaseTimelineAnchorForToolActivity,
......@@ -45,6 +46,24 @@ const projectId = ProjectId.make("project-1");
4546const threadId = ThreadId.make("thread-1");
4647const 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
4867describe("draft hero submission transition", () => {
4968 it("does not dock the composer before a background submission", () => {
5069 expect(
apps/web/src/components/ChatView.logic.ts+11
......@@ -39,6 +39,17 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000;
3939
4040export 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
4253export function shouldDockDraftHeroForSubmission(input: {
4354 isDraftHeroState: boolean;
4455 activeThreadKey: string | null;
apps/web/src/components/ChatView.tsx+34-22
......@@ -350,6 +350,7 @@ import {
350350 shouldDockDraftHeroForSubmission,
351351 shouldReleaseTimelineAnchorForToolActivity,
352352 shouldShowBranchMismatchBanner,
353 shoulderTabReserve,
353354 shouldShowPlanFollowUpPrompt,
354355 getStartedThreadModelChangeBlockReason,
355356 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
......@@ -1507,6 +1508,7 @@ function ChatViewContent(props: ChatViewProps) {
15071508 const legendListRef = useRef<LegendListRef | null>(null);
15081509 const [composerOverlayElement, setComposerOverlayElement] = useState<HTMLDivElement | null>(null);
15091510 const [composerOverlayHeight, setComposerOverlayHeight] = useState(0);
1511 const [scrollToEndClearance, setScrollToEndClearance] = useState(0);
15101512 const isAtEndRef = useRef(true);
15111513 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
15121514 const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({});
......@@ -1514,25 +1516,6 @@ function ChatViewContent(props: ChatViewProps) {
15141516 const feedbackUploadsInFlightRef = useRef(new Set<string>());
15151517 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
15361519 const terminalUiState = useTerminalUiStateStore((state) =>
15371520 selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef),
15381521 );
......@@ -4467,6 +4450,35 @@ function ChatViewContent(props: ChatViewProps) {
44674450 activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId
44684451 ? activePlan.steps
44694452 : 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
44704482 const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays);
44714483 const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge);
44724484 const linkedPullRequestStatus = useLinkedThreadPullRequest(
......@@ -7148,7 +7160,7 @@ function ChatViewContent(props: ChatViewProps) {
71487160 {showScrollToBottom && (
71497161 <div
71507162 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 }}
71527164 >
71537165 <Button
71547166 aria-label="Scroll to end"
......@@ -7178,11 +7190,11 @@ function ChatViewContent(props: ChatViewProps) {
71787190 ref={attachDraftHeroTransitionGroupRef}
71797191 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)]"
71807192 >
7181 <div className="pointer-events-auto relative z-10">
7193 <div className="group/composer-stack pointer-events-auto relative z-10">
71827194 {isDraftHeroState ? (
71837195 <div className="absolute inset-x-0 bottom-full z-0">
71847196 <div
7185 className="pb-8"
7197 className="pb-8 group-has-[.chat-composer-shoulder-tab]/composer-stack:pb-4"
71867198 style={
71877199 forceExpandedMobileComposer
71887200 ? {
apps/web/src/components/chat/ComposerStashBadge.tsx+1-1
......@@ -66,7 +66,7 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {
6666 aria-label={`Stashed prompts: ${props.count}. Open stash.`}
6767 aria-expanded={props.menuOpen}
6868 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",
7070 "transition-[color,border-color] duration-200",
7171 props.menuOpen && "pointer-events-none",
7272 props.menuOpen || props.pulsing
apps/web/src/components/chat/ComposerTasksBadge.test.tsx+35-4
......@@ -32,8 +32,8 @@ describe("ComposerTasksBadge", () => {
3232 expect(markup).toContain("chat-composer-tasks-tab");
3333 expect(markup).toContain("rounded-t-xl");
3434 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");
3737 expect(markup).toContain('data-composer-task-current="true"');
3838 expect(markup).toContain("min-w-0 flex-1 truncate");
3939 expect(markup).toContain("w-20");
......@@ -63,8 +63,8 @@ describe("ComposerTasksBadge", () => {
6363 />,
6464 );
6565
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");
6868 });
6969
7070 it("has a compact inline fallback for occupied composer shoulders", () => {
......@@ -124,6 +124,37 @@ describe("ComposerTasksBadge", () => {
124124 expect(markup).not.toContain("bg-muted-foreground/25");
125125 });
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
127158 it("keeps every long-list task inside the bounded scroll region", () => {
128159 const longSteps = Array.from({ length: 20 }, (_, index) => ({
129160 step: `Task ${index + 1}`,
apps/web/src/components/chat/ComposerTasksBadge.tsx+5-3
......@@ -17,6 +17,8 @@ export interface ComposerTaskStep {
1717 readonly status: "pending" | "inProgress" | "completed";
1818}
1919
20const MAX_TASK_SEGMENTS = 10;
21
2022function keyedTaskSteps(steps: readonly ComposerTaskStep[]) {
2123 const occurrences = new Map<string, number>();
2224 return steps.map((step) => {
......@@ -33,7 +35,7 @@ function TaskSegments({
3335 readonly className?: string;
3436 readonly steps: readonly ComposerTaskStep[];
3537}) {
36 if (steps.length <= 1) return null;
38 if (steps.length <= 1 || steps.length > MAX_TASK_SEGMENTS) return null;
3739
3840 return (
3941 <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({
116118 return (
117119 <div
118120 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",
121123 allDone && "text-foreground",
122124 )}
123125 data-composer-tasks-badge="true"