authorgravatar for 108270628+tristanmanchester@users.noreply.github.comTristan Manchester <108270628+tristanmanchester@users.noreply.github.com> 2026-10-06 11:26:57+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 04:26:57-05:00
log4980ba282eb313b3be87a6b7df28f3b42dcb4ce9
tree5bfa6b2c89777511fdf16d6f86c8dbfc92cbe197
parente65063ccf6ce83aa33d10521aeaf929210f0ff04
signature Signed by PGP key B5690EEEBB952194

fix(web): queued messages no longer split the composer notice stack (#16400)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Yash Singh <saiansh2525@gmail.com>

2 files changed, 113 insertions(+), 106 deletions(-)

apps/web/src/components/chat/ChatComposer.tsx+1-1
......@@ -6613,11 +6613,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
66136613 : null}
66146614 <ComposerBanner.Dock>
66156615 <ComposerBanner.Column>
6616 {props.queuedRunsControl}
66176616 <ComposerBannerStack
66186617 key={activeThreadId}
66196618 className="relative z-0"
66206619 items={bannerStackItems}
6620 attachedAbove={props.queuedRunsControl}
66216621 />
66226622 {!activityStackItem && (shownSyncPhase || inlineTasksBadge) ? (
66236623 <ComposerBanner.Attachment>
apps/web/src/components/chat/ComposerBannerStack.tsx+112-105
......@@ -43,9 +43,12 @@ function bannerPriority(item: ComposerBannerStackEntry) {
4343interface ComposerBannerStackProps {
4444 readonly className?: string;
4545 readonly items: ReadonlyArray<ComposerBannerStackEntry>;
46 /** Attachments that sit between the stacked notices and the front item, such as the queue.
47 The stacked notices peek above the whole column, never from between two attachments. */
48 readonly attachedAbove?: ReactNode;
4649}
4750
48export function ComposerBannerStack({ className, items }: ComposerBannerStackProps) {
51export function ComposerBannerStack({ className, items, attachedAbove }: ComposerBannerStackProps) {
4952 const [stackExpanded, setStackExpanded] = useState(false);
5053 const noticesRef = useRef<HTMLDivElement>(null);
5154 const peekRef = useRef<HTMLButtonElement>(null);
......@@ -84,15 +87,17 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
8487 }
8588 }, [stackExpanded]);
8689
87 if (items.length === 0) {
88 return null;
89 }
90
9190 // Activity stays attached. Urgency and severity only order the notices behind it.
9291 const orderedItems = items.toSorted((a, b) => bannerPriority(a) - bannerPriority(b));
9392 const frontItem = orderedItems[0];
9493 if (!frontItem) {
95 return null;
94 return (
95 <>
96 {null}
97 {attachedAbove}
98 {null}
99 </>
100 );
96101 }
97102 const stackedItems = orderedItems.slice(1);
98103 const hasStack = stackedItems.length > 0;
......@@ -114,12 +119,105 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
114119 };
115120
116121 return (
117 <ComposerBanner.Attachment
118 className={className}
119 data-composer-banner-drawer="true"
120 data-chat-composer-collapsed-controls="true"
121 >
122 <div className={cn("relative flex flex-col-reverse", hasStack && stackExpanded && "z-50")}>
122 <>
123 {hasStack ? (
124 <div
125 ref={noticesRef}
126 data-chat-composer-collapsed-controls="true"
127 className={cn("relative z-20 min-h-3", stackExpanded && "z-50")}
128 onPointerEnter={(event) => {
129 if (event.pointerType === "touch") return;
130 if (document.activeElement === peekRef.current) {
131 pendingFocusRef.current = "notice";
132 }
133 setStackExpanded(true);
134 }}
135 onPointerLeave={(event) => {
136 if (!event.currentTarget.contains(document.activeElement)) setStackExpanded(false);
137 }}
138 onBlurCapture={(event) => {
139 if (
140 !event.currentTarget.contains(event.relatedTarget) &&
141 !event.currentTarget.matches(":hover")
142 ) {
143 setStackExpanded(false);
144 }
145 }}
146 onKeyDown={(event) => {
147 if (event.key !== "Escape" || !stackExpanded) return;
148 event.preventDefault();
149 event.stopPropagation();
150 pendingFocusRef.current = "peek";
151 setStackExpanded(false);
152 }}
153 >
154 {showCollapsedStackCap && firstStackedItem ? (
155 <ComposerBanner.Peek
156 ref={peekRef}
157 variant={firstStackedItem.variant}
158 aria-label="Show other notices"
159 aria-expanded={stackExpanded}
160 aria-controls={expandedItemsId}
161 aria-hidden={stackExpanded || undefined}
162 tabIndex={stackExpanded ? -1 : 0}
163 onClick={(event) => {
164 event.currentTarget.focus({ preventScroll: true });
165 pendingFocusRef.current = "notice";
166 setStackExpanded(true);
167 }}
168 className={cn(stackExpanded && "pointer-events-none invisible opacity-0")}
169 />
170 ) : null}
171 <div
172 id={expandedItemsId}
173 ref={expandedItemsRef}
174 role="group"
175 aria-label="Other notices"
176 tabIndex={-1}
177 data-composer-banner-stack-expanded-items="true"
178 className={cn(
179 "grid transition-[grid-template-rows] duration-150 ease-out focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring",
180 stackExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
181 )}
182 >
183 <div className="min-h-0 overflow-hidden">
184 <div
185 className={cn(
186 "transform-gpu space-y-2 pb-2 transition-[opacity,transform] duration-150 ease-out will-change-[opacity,transform]",
187 stackExpanded
188 ? "pointer-events-auto visible translate-y-0 opacity-100"
189 : "pointer-events-none invisible translate-y-1 opacity-0",
190 )}
191 >
192 {stackedItems.map((item) => (
193 <div
194 key={item.id}
195 className={cn(
196 "transition-[opacity,translate] duration-220 ease-in",
197 exitingItemId === item.id
198 ? "pointer-events-none translate-y-28 opacity-0"
199 : "opacity-100",
200 )}
201 >
202 <ComposerBannerStackAlert
203 item={item}
204 attached={false}
205 exiting={exitingItemId === item.id}
206 onDismissRequest={() => requestDismiss(item)}
207 />
208 </div>
209 ))}
210 </div>
211 </div>
212 </div>
213 </div>
214 ) : null}
215 {attachedAbove}
216 <ComposerBanner.Attachment
217 className={className}
218 data-composer-banner-drawer="true"
219 data-chat-composer-collapsed-controls="true"
220 >
123221 <div
124222 key={frontItem.id}
125223 className={cn(
......@@ -146,99 +244,8 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
146244 onDismissRequest={() => requestDismiss(frontItem)}
147245 />
148246 </div>
149 {hasStack ? (
150 <div
151 ref={noticesRef}
152 className="relative z-20 min-h-3"
153 onPointerEnter={(event) => {
154 if (event.pointerType === "touch") return;
155 if (document.activeElement === peekRef.current) {
156 pendingFocusRef.current = "notice";
157 }
158 setStackExpanded(true);
159 }}
160 onPointerLeave={(event) => {
161 if (!event.currentTarget.contains(document.activeElement)) setStackExpanded(false);
162 }}
163 onBlurCapture={(event) => {
164 if (
165 !event.currentTarget.contains(event.relatedTarget) &&
166 !event.currentTarget.matches(":hover")
167 ) {
168 setStackExpanded(false);
169 }
170 }}
171 onKeyDown={(event) => {
172 if (event.key !== "Escape" || !stackExpanded) return;
173 event.preventDefault();
174 event.stopPropagation();
175 pendingFocusRef.current = "peek";
176 setStackExpanded(false);
177 }}
178 >
179 {showCollapsedStackCap && firstStackedItem ? (
180 <ComposerBanner.Peek
181 ref={peekRef}
182 variant={firstStackedItem.variant}
183 aria-label="Show other notices"
184 aria-expanded={stackExpanded}
185 aria-controls={expandedItemsId}
186 aria-hidden={stackExpanded || undefined}
187 tabIndex={stackExpanded ? -1 : 0}
188 onClick={(event) => {
189 event.currentTarget.focus({ preventScroll: true });
190 pendingFocusRef.current = "notice";
191 setStackExpanded(true);
192 }}
193 className={cn(stackExpanded && "pointer-events-none invisible opacity-0")}
194 />
195 ) : null}
196 <div
197 id={expandedItemsId}
198 ref={expandedItemsRef}
199 role="group"
200 aria-label="Other notices"
201 tabIndex={-1}
202 data-composer-banner-stack-expanded-items="true"
203 className={cn(
204 "grid transition-[grid-template-rows] duration-150 ease-out focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring",
205 stackExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
206 )}
207 >
208 <div className="min-h-0 overflow-hidden">
209 <div
210 className={cn(
211 "transform-gpu space-y-2 pb-2 transition-[opacity,transform] duration-150 ease-out will-change-[opacity,transform]",
212 stackExpanded
213 ? "pointer-events-auto visible translate-y-0 opacity-100"
214 : "pointer-events-none invisible translate-y-1 opacity-0",
215 )}
216 >
217 {stackedItems.map((item) => (
218 <div
219 key={item.id}
220 className={cn(
221 "transition-[opacity,translate] duration-220 ease-in",
222 exitingItemId === item.id
223 ? "pointer-events-none translate-y-28 opacity-0"
224 : "opacity-100",
225 )}
226 >
227 <ComposerBannerStackAlert
228 item={item}
229 attached={false}
230 exiting={exitingItemId === item.id}
231 onDismissRequest={() => requestDismiss(item)}
232 />
233 </div>
234 ))}
235 </div>
236 </div>
237 </div>
238 </div>
239 ) : null}
240 </div>
241 </ComposerBanner.Attachment>
247 </ComposerBanner.Attachment>
248 </>
242249 );
243250}
244251