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)...@@ -6613,11 +6613,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6613 : null}6613 : null}
6614 <ComposerBanner.Dock>6614 <ComposerBanner.Dock>
6615 <ComposerBanner.Column>6615 <ComposerBanner.Column>
6616 {props.queuedRunsControl}
6617 <ComposerBannerStack6616 <ComposerBannerStack
6618 key={activeThreadId}6617 key={activeThreadId}
6619 className="relative z-0"6618 className="relative z-0"
6620 items={bannerStackItems}6619 items={bannerStackItems}
6620 attachedAbove={props.queuedRunsControl}
6621 />6621 />
6622 {!activityStackItem && (shownSyncPhase || inlineTasksBadge) ? (6622 {!activityStackItem && (shownSyncPhase || inlineTasksBadge) ? (
6623 <ComposerBanner.Attachment>6623 <ComposerBanner.Attachment>
apps/web/src/components/chat/ComposerBannerStack.tsx+112-105
...@@ -43,9 +43,12 @@ function bannerPriority(item: ComposerBannerStackEntry) {...@@ -43,9 +43,12 @@ function bannerPriority(item: ComposerBannerStackEntry) {
43interface ComposerBannerStackProps {43interface ComposerBannerStackProps {
44 readonly className?: string;44 readonly className?: string;
45 readonly items: ReadonlyArray<ComposerBannerStackEntry>;45 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;
46}49}
4750
48export function ComposerBannerStack({ className, items }: ComposerBannerStackProps) {51export function ComposerBannerStack({ className, items, attachedAbove }: ComposerBannerStackProps) {
49 const [stackExpanded, setStackExpanded] = useState(false);52 const [stackExpanded, setStackExpanded] = useState(false);
50 const noticesRef = useRef<HTMLDivElement>(null);53 const noticesRef = useRef<HTMLDivElement>(null);
51 const peekRef = useRef<HTMLButtonElement>(null);54 const peekRef = useRef<HTMLButtonElement>(null);
...@@ -84,15 +87,17 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro...@@ -84,15 +87,17 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
84 }87 }
85 }, [stackExpanded]);88 }, [stackExpanded]);
8689
87 if (items.length === 0) {
88 return null;
89 }
90
91 // Activity stays attached. Urgency and severity only order the notices behind it.90 // Activity stays attached. Urgency and severity only order the notices behind it.
92 const orderedItems = items.toSorted((a, b) => bannerPriority(a) - bannerPriority(b));91 const orderedItems = items.toSorted((a, b) => bannerPriority(a) - bannerPriority(b));
93 const frontItem = orderedItems[0];92 const frontItem = orderedItems[0];
94 if (!frontItem) {93 if (!frontItem) {
95 return null;94 return (
95 <>
96 {null}
97 {attachedAbove}
98 {null}
99 </>
100 );
96 }101 }
97 const stackedItems = orderedItems.slice(1);102 const stackedItems = orderedItems.slice(1);
98 const hasStack = stackedItems.length > 0;103 const hasStack = stackedItems.length > 0;
...@@ -114,12 +119,105 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro...@@ -114,12 +119,105 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
114 };119 };
115120
116 return (121 return (
117 <ComposerBanner.Attachment122 <>
118 className={className}123 {hasStack ? (
119 data-composer-banner-drawer="true"124 <div
120 data-chat-composer-collapsed-controls="true"125 ref={noticesRef}
121 >126 data-chat-composer-collapsed-controls="true"
122 <div className={cn("relative flex flex-col-reverse", hasStack && stackExpanded && "z-50")}>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 >
123 <div221 <div
124 key={frontItem.id}222 key={frontItem.id}
125 className={cn(223 className={cn(
...@@ -146,99 +244,8 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro...@@ -146,99 +244,8 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
146 onDismissRequest={() => requestDismiss(frontItem)}244 onDismissRequest={() => requestDismiss(frontItem)}
147 />245 />
148 </div>246 </div>
149 {hasStack ? (247 </ComposerBanner.Attachment>
150 <div248 </>
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>
242 );249 );
243}250}
244251