History

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

Bilal Bakr committed 2 months ago

Commit 660cddd1 parent ebb9b9f6 files · +105 −30Browse files

Unified · Split

6 files
  1. apps/web/src/components/ChatView.logic.test.ts +19 −0
  2. apps/web/src/components/ChatView.logic.ts +11 −0
  3. apps/web/src/components/ChatView.tsx +34 −22
  4. apps/web/src/components/chat/ComposerStashBadge.tsx +1 −1
  5. apps/web/src/components/chat/ComposerTasksBadge.test.tsx +35 −4
  6. apps/web/src/components/chat/ComposerTasksBadge.tsx +5 −3
apps/web/src/components/ChatView.logic.test.ts+19−0
@@ -32,6 +32,7 @@ import { · 31 unmodified lines
3232 resolveSendEnvMode,
3333 resolveDraftHeroState,
3434 scheduleEnvironmentReconnectWarning,
35+ shoulderTabReserve,
3536 startNewThreadForProject,
3637 shouldDockDraftHeroForSubmission,
3738 shouldReleaseTimelineAnchorForToolActivity,
@@ -45,6 +46,24 @@ const projectId = ProjectId.make("project-1"); · 7 unmodified lines
4546 const threadId = ThreadId.make("thread-1");
4647 const now = "2026-03-29T00:00:00.000Z";
4748
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+
4867 describe("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−0
@@ -39,6 +39,17 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; · 38 unmodified lines
3939
4040 export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
4141
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+
4253 export function shouldDockDraftHeroForSubmission(input: {
4354 isDraftHeroState: boolean;
4455 activeThreadKey: string | null;
apps/web/src/components/ChatView.tsx+34−22
@@ -350,6 +350,7 @@ import { · 349 unmodified lines
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) { · 1151 unmodified lines
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) { · 1 unmodified line
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) { · 2928 unmodified lines
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) { · 2675 unmodified lines
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) { · 23 unmodified lines
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: { · 65 unmodified lines
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", () => { · 31 unmodified lines
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", () => { · 23 unmodified lines
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", () => { · 53 unmodified lines
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 { · 16 unmodified lines
1717 readonly status: "pending" | "inProgress" | "completed";
1818 }
1919
20+const MAX_TASK_SEGMENTS = 10;
21+
2022 function keyedTaskSteps(steps: readonly ComposerTaskStep[]) {
2123 const occurrences = new Map<string, number>();
2224 return steps.map((step) => {
@@ -33,7 +35,7 @@ function TaskSegments({ · 10 unmodified lines
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({ · 76 unmodified lines
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"

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t