feat(web): attach composer state drawers#7150

merged14
main
ui-refresh/composer
openedAug 16, 2026byMmaria
View on GitHubTwo-way sync enabledPR last imported 1d ago
42 unresolved threads Β· Jump to file
Selected thread: apps/web/src/components/chat/ComposerStashBadge.tsx:27 β€” ### Stash trigger ignores open state **Low Severity** <!-- DESCRIPTION START --> The stash tab hardcodes `aria-expanded` to `false` after dropping the `menuOpen` prop. When the stash menu is open, assistive technology still reports the control as collapsed, so the disclosure state is wrong. <!-- DESCRIPTION END --> <!-- BUGBOT_BUG_ID: dcf08e9c-cdc0-4b70-a92e-c8f48d6dce5b --> <!-- LOCATIONS START apps/web/src/components/chat/ComposerStashBadge.tsx#L25-L27 LOCATIONS END --> <div><a href="https://cursor.com/open?link=eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiQlVHQk9UX0ZJWF9JTl9DVVJTT1IiLCJkYXRhIjp7InJlZGlzS2V5IjoiYnVnYm90OmQzN2YwM2E3LTZlZmItNDkzZi04N2UxLTZlNDA4NGM2Y2M3OCIsImVuY3J5cHRpb25LZXkiOiJTdWI2dXRsVG9NZjE2YVBEUW1sTXFVOEVneTlDODJpTkxmcG1ZVEotenFjIiwiYnJhbmNoIjoidWktcmVmcmVzaC9jb21wb3NlciIsInJlcG9Pd25lciI6InBpbmdkb3RnZyIsInJlcG9OYW1lIjoidDNjb2RlIiwicHJvdmlkZXIiOiJnaXRodWIifX0" target="_blank" rel="noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/fix-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/fix-in-cursor-light.png"><img alt="Fix in Cursor" width="115" height="28" src="https://cursor.com/assets/images/fix-in-cursor-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/agents?link=eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiQlVHQk9UX0ZJWF9JTl9XRUIiLCJkYXRhIjp7InJlZGlzS2V5IjoiYnVnYm90OmQzN2YwM2E3LTZlZmItNDkzZi04N2UxLTZlNDA4NGM2Y2M3OCIsImVuY3J5cHRpb25LZXkiOiJTdWI2dXRsVG9NZjE2YVBEUW1sTXFVOEVneTlDODJpTkxmcG1ZVEotenFjIiwiYnJhbmNoIjoidWktcmVmcmVzaC9jb21wb3NlciIsInJlcG9Pd25lciI6InBpbmdkb3RnZyIsInJlcG9OYW1lIjoidDNjb2RlIiwicHJOdW1iZXIiOjcxNTAsImNvbW1pdFNoYSI6ImZjNWM5NGI3Y2EyNmMzZGVmZGExNDZmYmI3YmJlNjFkNWRiNzllMDAiLCJwcm92aWRlciI6ImdpdGh1YiJ9fQ" target="_blank" rel="noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/fix-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/fix-in-web-light.png"><img alt="Fix in Web" width="99" height="28" src="https://cursor.com/assets/images/fix-in-web-dark.png"></picture></a></div> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit fc5c94b7ca26c3defda146fbb7bbe61d5db79e00. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>
0 / 36 viewed36 files changedLine totals unavailable

apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx Β· Earlier revision 5cc14855 Β· right 37

Original diff
@@ -1,48 +1,44 @@
 import { memo } from "react";
 import { type PendingApproval } from "../../session-logic";
+import { cn } from "~/lib/utils";
 
 interface ComposerPendingApprovalPanelProps {
   approval: PendingApproval;
   pendingCount: number;
+  className?: string;
 }
 
 export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprovalPanel({
   approval,
   pendingCount,
+  className,
 }: ComposerPendingApprovalPanelProps) {
-  const approvalSummary =
+  const fallbackLabel =
     approval.requestKind === "command"
-      ? "Command approval requested"
+      ? "Command approval"
       : approval.requestKind === "file-read"
-        ? "File-read approval requested"
-        : "File-change approval requested";
-  const detailLabel =
+        ? "File read approval"
+        : "File change approval";
+  const detailAriaLabel =
     approval.requestKind === "command"
       ? "Command"
       : approval.requestKind === "file-read"
         ? "File to read"
         : "File change";
 
   return (
-    <div className="min-w-0 px-4 py-3.5 sm:px-5 sm:py-4">
-      <div className="flex flex-wrap items-center gap-2">
-        <span className="uppercase text-sm tracking-[0.2em]">PENDING APPROVAL</span>
-        <span className="text-sm font-medium">{approvalSummary}</span>
-        {pendingCount > 1 ? (
-          <span className="text-xs text-muted-foreground">1/{pendingCount}</span>
-        ) : null}
-      </div>
-      {approval.detail ? (
-        <div className="mt-3 min-w-0 max-w-full rounded-lg border border-border/65 bg-background/70 p-3">
-          <p className="text-xs font-medium text-muted-foreground">{detailLabel}</p>
-          <pre
-            aria-label={detailLabel}
-            className="mt-2 min-w-0 max-w-full max-h-40 overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-xs leading-relaxed text-foreground"
-            data-approval-detail="complete"
-          >
-            {approval.detail}
-          </pre>
-        </div>
+    <div className={cn("flex min-w-0 flex-1 items-center gap-2", className)}>
+      <code
+        aria-label={detailAriaLabel}
+        className="block min-w-0 flex-1 overflow-x-auto whitespace-pre font-mono text-[11px] text-foreground/85"
+        data-approval-detail="complete"
+      >
+        {approval.detail ?? fallbackLabel}
+      </code>
C
cursorBotAug 16, 2026

Approval detail grows without cap

Low Severity

The compact approval row replaced the old max-h-40 scrollable detail block with whitespace-pre and only horizontal overflow. Multiline command details can expand the top drawer without a vertical bound and push the composer down the viewport.

Reviewed by Cursor Bugbot for commit 5cc14855f9c5202274881bc5315e8e6b82120513. Configure here.

apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx Β· Earlier revision ef3ffab7 Β· right 11–11

Original diff
@@ -1,23 +1,16 @@
 import { memo } from "react";
-import { Badge } from "../ui/badge";
 
 export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({
   planTitle,
 }: {
   planTitle: string | null;
 }) {
   return (
-    <div className="px-4 py-3.5 sm:px-5 sm:py-4">
+    <div className="px-3 py-2 sm:px-4">
       <div className="flex flex-wrap items-center gap-2">
-        <Badge
-          variant="info"
-          size="sm"
-          className="rounded-md px-1.5 py-0 font-semibold tracking-wide uppercase"
-        >
-          Plan Ready
-        </Badge>
+        <span className="text-sm font-medium text-foreground/90">Plan ready</span>

apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx Β· right 11–11

M
macroscopeappBotAug 19, 2026

chat/ComposerPlanFollowUpBanner.tsx:11

The three states of the new top drawer now disagree on their leading type, and this one inverts its own hierarchy: the constant label is text-sm/text-foreground/90 while the plan title beside it dropped from text-sm font-medium to text-xs text-foreground/85, so the fixed word is now larger and higher-contrast than the content it describes.

Across the drawer the same slot leads with text-xs font-medium text-muted-foreground for pending input (ComposerPendingUserInputPanel.tsx:190) and text-[11px] for a pending approval (ComposerPendingApprovalPanel.tsx:36). Since ChatComposer swaps these in place inside one .chat-composer-top-drawer, the leading text jumps 14px β†’ 12px β†’ 11px as the state changes, and this is the only 14px text left in the drawer after the compaction the rest of the PR applies.

Suggest matching the pending-input header so the plan title stays the emphasized part of the row:

suggestion
1 <span className="text-xs font-medium text-muted-foreground">Plan ready</span>

No longer relevant as of f5bd5efd9a28cf7025073c6c9e76bf24aeec678d

apps/web/src/components/chat/ComposerStashBadge.tsx Β· Earlier revision b196fc44 Β· right 30–68

Original diff
@@ -27,27 +27,25 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {
       aria-label={`Stashed prompts: ${props.count}. Open stash.`}
       aria-expanded={props.menuOpen}
       className={cn(
-        "absolute -top-3 right-4 z-10 inline-flex cursor-pointer items-center gap-1.5 rounded-full border bg-popover px-2.5 py-0.5 text-xs shadow-sm",
-        "transition-[color,border-color,opacity] duration-200",
-        props.menuOpen || props.pulsing
-          ? "border-border text-foreground opacity-100"
-          : "border-border/70 text-muted-foreground opacity-70 hover:opacity-100 hover:text-foreground",
+        "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",

apps/web/src/components/chat/ComposerStashBadge.tsx Β· right 30–68

M
macroscopeappBotAug 17, 2026

The tab now sits entirely inside the area the stash drawer paints over, so opening the stash hides its own toggle.

-top-7 h-8 puts the tab between the composer's top edge βˆ’28px and +4px, at z-0 behind the main surface. ComposerCommandMenuLayer portals the drawer to document.body as fixed z-[70] with bottom = innerHeight - surfaceTop - composerOverlap and left/width inset by only 1.375rem per side (ChatComposer.tsx 145-163), so its opaque region ends exactly at the composer's top edge and covers the tab's full 28px overhang. Escape (or the composer.stash shortcut) becomes the only way to collapse the drawer, and menuOpen no longer has any rendered effect β€” it only feeds aria-expanded, which is now set on an invisible control. The pre-PR pill signalled the open state (border-border text-foreground opacity-100) and was only clipped by ~4px.

Suggest keeping the tab out of the drawer's footprint (e.g. inset the drawer's right edge past the tab, or render the tab inside the drawer layer while it is open) and restoring an open-state treatment so the toggle still reads as expanded:

suggestion
1 "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",
2 "transition-[color,border-color] duration-200",
3 props.menuOpen || props.pulsing ? "text-foreground" : "text-muted-foreground hover:text-foreground",

Posted via Macroscope β€” UI Consistency

M
mariaAug 19, 2026

Fixed. The open stash drawer now has a compact visible Close stash button.

apps/web/src/components/chat/ChatComposer.tsx Β· Earlier revision 5cc14855 Β· right 2824

Original diff
@@ -2828,269 +2810,432 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
               </div>
             </div>
           ) : null}
-
-          {showCollapsedMobilePromptRow ? (
-            <div className="flex items-center justify-between gap-2 px-3 py-2">
-              <button
-                type="button"
-                className={cn(
-                  "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none",
-                  (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim())
-                    ? "text-foreground"
-                    : "text-placeholder",
-                )}
-                onPointerDown={(event) => event.preventDefault()}
-                onClick={expandMobileComposer}
-                aria-label="Expand composer"
-              >
-                {activePendingProgress
-                  ? activePendingProgress.customAnswer ||
-                    "Type your own answer, or leave this blank to use the selected option"
-                  : prompt.trim() ||
-                    (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}
-              </button>
-              <button
-                type="button"
-                className="flex size-8 shrink-0 items-center justify-center rounded-full bg-message-action text-message-action-foreground hover:bg-message-action-hover disabled:opacity-30"
-                disabled={collapsedComposerPrimaryActionDisabled}
-                aria-label={collapsedComposerPrimaryActionLabel}
-                onPointerDown={(event) => event.preventDefault()}
-                onClick={(event) => {
-                  event.stopPropagation();
-                  submitComposer();
-                }}
-              >
-                <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
-                  <path
-                    d="M8 3L8 13M8 3L4 7M8 3L12 7"
-                    stroke="currentColor"
-                    strokeWidth="2"
-                    strokeLinecap="round"
-                    strokeLinejoin="round"
-                  />
-                </svg>
-              </button>
-            </div>
-          ) : null}
-
+        </div>
+      ) : null}
+      <div className="relative">
+        {!showComposerTopDrawer && !isComposerCollapsedMobile ? (
+          <ComposerStashBadge
+            count={stashQueue.length}
+            menuOpen={isStashMenuOpen}
+            pulseKey={stashPulse.key}
+            pulsing={stashPulse.active}
+            onToggleMenu={toggleStashMenu}
+          />
+        ) : null}
C
cursorBotAug 16, 2026

Stash badge hidden during plan

Medium Severity

ComposerStashBadge is gated on !showComposerTopDrawer, and plan follow-up now opens that drawer. Unlike approval or pending user input, plan state still allows editing and ⌘S stashing, but the badge (the only normal way to open and restore the stash) disappears for the whole plan-ready period.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 5cc14855f9c5202274881bc5315e8e6b82120513. Configure here.

apps/web/src/components/chat/ComposerStashMenu.tsx Β· Earlier revision 5cc14855 Β· right 163

Original diff
@@ -150,20 +134,33 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
                       {missingImageCount(entry) === 1 ? "" : "s"} dropped
                     </span>
                   ) : null}
-                  <span className="shrink-0 text-secondary-label text-xs">
+                  {entry.attachments.length > 0 ? (
+                    <span className="flex shrink-0 items-center -space-x-1.5">
+                      {entry.attachments.slice(0, 3).map((attachment) => (
+                        <img
+                          key={attachment.id}
+                          src={attachment.dataUrl}
+                          alt=""
+                          aria-hidden="true"
+                          className="size-5 rounded border border-border/70 object-cover"
+                        />
+                      ))}
+                    </span>
+                  ) : null}
+                  <span className="shrink-0 text-secondary-label text-xs max-sm:hidden">
                     {formatRelativeTimeLabel(entry.createdAt)}
                   </span>
                   <Button
                     variant="ghost"
                     size="icon-xs"
-                    className="shrink-0 opacity-0 transition-opacity group-hover/stash:opacity-100"
+                    className="pointer-events-none absolute top-1/2 right-2 z-10 -translate-y-1/2 bg-popover! text-foreground! opacity-0 shadow-none! transition-opacity max-sm:pointer-events-auto max-sm:opacity-100 hover:bg-popover! hover:text-foreground! group-hover/stash:pointer-events-auto group-hover/stash:opacity-100 group-focus-within/stash:pointer-events-auto group-focus-within/stash:opacity-100 focus-visible:pointer-events-auto focus-visible:bg-popover! focus-visible:text-foreground! focus-visible:opacity-100"
                     aria-label="Delete stashed prompt"
                     onClick={(event) => {
                       event.stopPropagation();
                       onDelete(entry);
                     }}
                   >
-                    <XIcon />
+                    <XIcon className="size-3.5 stroke-2" />
C
cursorBotAug 16, 2026

Delete covers stash thumbnails

Low Severity

The delete control is absolutely positioned at the trailing edge and always visible on small screens. On entries with attachments, that overlay sits on top of the image thumbnails, so taps near the right edge hit delete instead of restore and the previews are hard to see.

Reviewed by Cursor Bugbot for commit 5cc14855f9c5202274881bc5315e8e6b82120513. Configure here.

apps/web/src/components/chat/ComposerPendingApprovalActions.tsx Β· Earlier revision b6a90879 Β· right 14

Original diff
@@ -11,6 +11,8 @@ interface ComposerPendingApprovalActionsProps {
   ) => Promise<unknown>;
 }
 
+const APPROVAL_ACTION_CLASS_NAME = "font-normal text-[11px] sm:text-[11px]";
M
macroscopeappBotAug 17, 2026

This shared constant exists to defeat the micro size's own type scale: micro declares text-xs, but buttonVariants' base string keeps sm:text-sm, and tailwind-merge treats the sm: modifier as a separate group, so from 640px up the button renders 14px text in a 20px box. xs right below it pairs text-sm sm:text-xs for exactly this reason.

The result is the same override duplicated at two call sites in this PR - here and ChatView.tsx:4473 (className="text-[11px] sm:text-[11px]" on the background-work action) - which is the treatment the shared size should own.

Suggest fixing it in the primitive (apps/web/src/components/ui/button.tsx, size.micro) by pairing the small-screen and sm: type scale, then dropping both call-site overrides so the variant keeps ownership of its typography:

1- micro:
2- "h-5 gap-1 rounded-sm px-[calc(--spacing(1.5)-1px)] text-xs before:rounded-[calc(var(--radius-sm)-1px)] [&_svg:not([class*='size-'])]:size-3",
3+ micro:
4+ "h-5 gap-1 rounded-sm px-[calc(--spacing(1.5)-1px)] text-[11px] before:rounded-[calc(var(--radius-sm)-1px)] sm:text-[11px] [&_svg:not([class*='size-'])]:size-3",

Posted via Macroscope β€” UI Consistency

apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx Β· Earlier revision 1d1ff47e Β· right 36–36

Original diff
@@ -1,48 +1,48 @@
 import { memo } from "react";
 import { type PendingApproval } from "../../session-logic";
+import { cn } from "~/lib/utils";
 
 interface ComposerPendingApprovalPanelProps {
   approval: PendingApproval;
   pendingCount: number;
+  className?: string;
 }
 
 export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprovalPanel({
   approval,
   pendingCount,
+  className,
 }: ComposerPendingApprovalPanelProps) {
-  const approvalSummary =
+  const fallbackLabel =
     approval.requestKind === "command"
-      ? "Command approval requested"
+      ? "Command approval"
       : approval.requestKind === "file-read"
-        ? "File-read approval requested"
-        : "File-change approval requested";
-  const detailLabel =
+        ? "File read approval"
+        : "File change approval";
+  const detailAriaLabel =
     approval.requestKind === "command"
       ? "Command"
       : approval.requestKind === "file-read"
         ? "File to read"
         : "File change";
 
   return (
-    <div className="min-w-0 px-4 py-3.5 sm:px-5 sm:py-4">
-      <div className="flex flex-wrap items-center gap-2">
-        <span className="uppercase text-sm tracking-[0.2em]">PENDING APPROVAL</span>
-        <span className="text-sm font-medium">{approvalSummary}</span>
-        {pendingCount > 1 ? (
-          <span className="text-xs text-muted-foreground">1/{pendingCount}</span>
-        ) : null}
-      </div>
-      {approval.detail ? (
-        <div className="mt-3 min-w-0 max-w-full rounded-lg border border-border/65 bg-background/70 p-3">
-          <p className="text-xs font-medium text-muted-foreground">{detailLabel}</p>
-          <pre
-            aria-label={detailLabel}
-            className="mt-2 min-w-0 max-w-full max-h-40 overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-xs leading-relaxed text-foreground"
-            data-approval-detail="complete"
-          >
-            {approval.detail}
-          </pre>
-        </div>
+    <div
+      aria-label={detailAriaLabel}
+      className={cn("flex min-w-0 flex-1 items-center gap-2", className)}
+      role="group"
+    >
+      <code
+        className="block max-h-20 min-w-0 flex-1 overflow-auto whitespace-pre font-mono text-[11px] text-foreground/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"

apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx Β· right 36–36

M
macroscopeappBotAug 17, 2026

chat/ComposerPendingApprovalPanel.tsx:36

whitespace-pre + overflow-auto makes this a horizontal scroller inside a row that is one 11px line tall, and the app's global scrollbar rule only sets a width (index.css: ::-webkit-scrollbar { width: 6px }), so a horizontal lane falls back to Chromium's ~15px default β€” taller than the command text above it, and always visible (styling ::-webkit-scrollbar opts every scroller out of overlay scrollbars). The previous panel wrapped instead (whitespace-pre-wrap [overflow-wrap:anywhere] in a max-h-40 box), so the lane is new here; .chat-markdown pre::-webkit-scrollbar { height: 7px } exists for exactly this gap.

Suggest sizing the lane at the owner so the row keeps its compact height:

suggestion
1 className="block max-h-20 min-w-0 flex-1 overflow-auto whitespace-pre font-mono text-[11px] text-foreground/85 [scrollbar-width:thin] [&::-webkit-scrollbar]:h-1.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"

No longer relevant as of b034b4120e367b79cc71b1600942b78df4584bca

apps/web/src/components/chat/ComposerStashMenu.tsx Β· Earlier revision b196fc44 Β· right 156–156

Original diff
@@ -150,20 +134,33 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
                       {missingImageCount(entry) === 1 ? "" : "s"} dropped
                     </span>
                   ) : null}
-                  <span className="shrink-0 text-secondary-label text-xs">
+                  {entry.attachments.length > 0 ? (
+                    <span className="flex shrink-0 items-center -space-x-1.5">
+                      {entry.attachments.slice(0, 3).map((attachment) => (
+                        <img
+                          key={attachment.id}
+                          src={attachment.dataUrl}
+                          alt=""
+                          aria-hidden="true"
+                          className="size-5 rounded border border-border/70 object-cover"
+                        />
+                      ))}
+                    </span>
+                  ) : null}
+                  <span className="shrink-0 text-secondary-label text-xs max-sm:hidden">
                     {formatRelativeTimeLabel(entry.createdAt)}
                   </span>
                   <Button
                     variant="ghost"
                     size="icon-xs"
-                    className="shrink-0 opacity-0 transition-opacity group-hover/stash:opacity-100"
+                    className="pointer-events-none shrink-0 [--control-icon-color:currentColor] opacity-0 shadow-none! transition-opacity max-sm:pointer-events-auto max-sm:opacity-100 group-hover/stash:pointer-events-auto group-hover/stash:opacity-100 group-focus-within/stash:pointer-events-auto group-focus-within/stash:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100"

apps/web/src/components/chat/ComposerStashMenu.tsx Β· right 156–156

M
macroscopeappBotAug 17, 2026

chat/ComposerStashMenu.tsx:156

Adding pointer-events-none makes the hover reveal gate interaction, not just opacity, while the touch escape hatch is a viewport query (max-sm:) rather than a pointer query. On a coarse pointer at β‰₯640px there is no hover, so the button stays pointer-events-none opacity-0; tapping the row runs onRestore and closes the menu, so a stashed entry can no longer be deleted at all. Before this change the button was opacity-0 but still tappable.

This repo gates touch reveals on the pointer rather than width (e.g. ThreadTerminalDrawer.tsx:1759 uses pointer-coarse:hidden / pointer-coarse:block). Suggest swapping the width query for a pointer query:

suggestion
1 className="pointer-events-none shrink-0 [--control-icon-color:currentColor] opacity-0 shadow-none! transition-opacity pointer-coarse:pointer-events-auto pointer-coarse:opacity-100 group-hover/stash:pointer-events-auto group-hover/stash:opacity-100 group-focus-within/stash:pointer-events-auto group-focus-within/stash:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100"

No longer relevant as of b034b4120e367b79cc71b1600942b78df4584bca

apps/web/src/components/chat/ComposerStashBadge.tsx Β· Earlier revision fc5c94b7 Β· right 27

Original diff
@@ -25,29 +24,29 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {
       type="button"
       data-prompt-stash-badge="true"
       aria-label={`Stashed prompts: ${props.count}. Open stash.`}
-      aria-expanded={props.menuOpen}
+      aria-expanded="false"
C
cursorBotAug 16, 2026

Stash trigger ignores open state

Low Severity

The stash tab hardcodes aria-expanded to false after dropping the menuOpen prop. When the stash menu is open, assistive technology still reports the control as collapsed, so the disclosure state is wrong.

Reviewed by Cursor Bugbot for commit fc5c94b7ca26c3defda146fbb7bbe61d5db79e00. Configure here.

apps/web/src/components/chat/ThreadSyncStatusPill.tsx Β· Earlier revision c2db9420 Β· right 11–11

Original diff
@@ -8,7 +8,8 @@ export function ThreadSyncStatusPill({ phase }: { readonly phase: ThreadSyncPhas
   return (
     <div
       aria-label={label}
-      className="pointer-events-none mx-auto mb-2 flex w-fit max-w-full items-center gap-2 rounded-full border border-border/60 bg-card/95 px-3 py-1.5 text-foreground text-xs font-medium shadow-sm"
+      className="chat-composer-drawer-surface chat-composer-drawer-attached chat-composer-drawer-slot pointer-events-none flex max-w-[calc(48rem-2.75rem)] items-center gap-2 px-3 pt-2 pb-[calc(var(--chat-composer-attachment-overlap)_+_0.375rem)] text-foreground text-xs font-medium"

apps/web/src/components/chat/ThreadSyncStatusPill.tsx Β· right 11–11

M
macroscopeappBotAug 17, 2026

This pill and ComposerBannerStack's front item both hardcode the attached-drawer treatment (chat-composer-drawer-surface + chat-composer-drawer-attached + chat-composer-drawer-slot), but that contract assumes exactly one surface joins the composer's top edge. ChatView renders them as ungated consecutive siblings (ChatView.tsx 6401/6404 and 6407), and chat-composer-drawer-slot gives each one margin-bottom: calc(-1 * var(--chat-composer-attachment-overlap)).

So when a banner and a sync phase are live together β€” e.g. opening a settled or snoozed thread (parkedThreadBannerItem) while cached messages reconcile β€” the banner pulls this pill into its own masked overlap: the banner's ::before is transparent across those 17px (it expects the composer surface and outline to paint over them) while the pill paints its 16px top corners and 1px top border inside that strip, leaving a notch at the joint and a double edge instead of one continuous drawer. ChatComposer's .chat-composer-top-drawer is a third surface with the same geometry and the same collision.

Suggest deriving the treatment rather than hardcoding it β€” take an attached prop here (as ComposerBannerStack already does for its stacked items) and let only the surface directly above the composer attach, with the others keeping the detached alert-glass styling.

Posted via Macroscope β€” UI Consistency

M
mariaAug 19, 2026

Fixed. Banner, sync, and top-drawer surfaces now use the shared stacked attachment rules without double seams.

M
macroscopeappBotAug 19, 2026

ComposerBannerStack now correctly derives attachment via its attached prop, but ThreadSyncStatusPill still hardcodes chat-composer-drawer-surface chat-composer-drawer-attached chat-composer-drawer-slot on line 11 and doesn't accept an attached prop. Since both can render simultaneously (ChatView 6437–6444), the double-seam issue persists when a banner and sync phase overlap.

apps/web/src/components/ChatView.tsx Β· Earlier revision 5b378af5 Β· right 4467–4467

Original diff
@@ -4457,19 +4457,21 @@ function ChatViewContent(props: ChatViewProps) {
       ),
       title: working
         ? liveCount > 0
-          ? `${liveCount} ${liveCount === 1 ? "agent" : "agents"} working in the background`
-          : "Background work running"
-        : "Monitoring in the background",
+          ? `${liveCount} ${liveCount === 1 ? "agent" : "agents"} working`
+          : "Background work"
+        : "Monitoring",
       actions: (
         <Button
           size="xs"
-          variant="outline"
+          variant="ghost-muted"
+          className="h-5 px-1.5 text-[11px]"

apps/web/src/components/ChatView.tsx Β· right 4467–4467

M
macroscopeappBotAug 16, 2026

components/ChatView.tsx:4467

Same override as ComposerPendingApprovalActions: size="xs" keeps sm:h-6/sm:text-xs through twMerge, so this banner action is 24px/12px at sm and up rather than the 20px/11px intended here. Suggest switching to the shared micro button size once it exists instead of re-specifying height, padding, and font size per call site.

No longer relevant as of deb67121f12bddcbe8e8a6e48a776981b513fca7

apps/web/src/components/chat/ThreadSyncStatusPill.tsx Β· Earlier revision 5b378af5 Β· right 11–11

Original diff
@@ -8,7 +8,8 @@ export function ThreadSyncStatusPill({ phase }: { readonly phase: ThreadSyncPhas
   return (
     <div
       aria-label={label}
-      className="pointer-events-none mx-auto mb-2 flex w-fit max-w-full items-center gap-2 rounded-full border border-border/60 bg-card/95 px-3 py-1.5 text-foreground text-xs font-medium shadow-sm"
+      className="chat-composer-drawer-surface chat-composer-drawer-attached pointer-events-none mx-auto mb-[calc(-1rem-1px)] flex w-[calc(100%-2.75rem)] max-w-[calc(48rem-2.75rem)] items-center gap-2 px-3 pt-2 pb-6 text-foreground text-xs font-medium"

apps/web/src/components/chat/ThreadSyncStatusPill.tsx Β· right 11–11

M
macroscopeappBotAug 16, 2026

chat-composer-drawer-attached has no definition anywhere: index.css only styles .chat-composer-drawer-surface / .chat-composer-top-drawer, and no :has(), query selector, or theme rule references it. It is emitted at four call sites and asserted in four tests, so the tests currently lock in a no-op string.

Either drop it (and the assertions), or give it the attachment geometry it appears to be named for β€” the w-[calc(100%-2.75rem)], mb-[calc(-1rem-1px)], pb-6 trio duplicated here and in ComposerBannerStack, which .chat-composer-top-drawer already owns in CSS. One owner for the seam geometry would keep the drawers from drifting apart.

Posted via Macroscope β€” UI Consistency

M
mariaAug 19, 2026

Fixed. The attached drawer class now owns the shared attachment geometry in index.css.

apps/web/src/index.css Β· Earlier revision 577902c5 Β· right 798

Original diff
@@ -680,7 +680,128 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
     outline: none;
   }
 
+  .chat-composer-stash-tab {
+    border-color: var(
+      --chat-composer-outline,
+      color-mix(in srgb, var(--foreground) 8%, transparent)
+    );
+    background: color-mix(
+      in srgb,
+      var(--chat-composer-glass-surface, var(--card)) var(--glass-opacity),
+      transparent
+    );
+    -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+    backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+    box-shadow: 0 -8px 24px -18px rgb(0 0 0 / 45%);
+
+    @variant dark {
+      box-shadow: 0 -10px 28px -18px rgb(0 0 0 / 80%);
+    }
+
+    @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
+      background: var(--chat-composer-glass-surface, var(--card));
+    }
+  }
+
+  /* Mirror the run-context strip above the composer. These surfaces deliberately
+     own no card fill or border themselves: a masked pseudo-element draws the
+     same inset glass, 16px outer corners, quiet outline, and open attachment
+     seam as the strip below. */
+  .chat-composer-drawer-surface,
+  .chat-composer-top-drawer {
+    --chat-composer-attachment-overlap: calc(1rem + 1px);
+    --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card));
+    --chat-composer-attached-outline: var(
+      --chat-composer-outline,
+      color-mix(in srgb, var(--foreground) 8%, transparent)
+    );
+    --chat-composer-attached-tint: transparent;
+    position: relative;
+    isolation: isolate;
+    border: 0 !important;
+    background: transparent !important;
+    box-shadow: none;
+
+    @variant dark {
+      --chat-composer-attached-surface: var(
+        --chat-composer-glass-surface,
+        color-mix(in srgb, var(--background) 96%, var(--color-white))
+      );
+      --chat-composer-attached-outline: var(
+        --chat-composer-outline,
+        color-mix(in srgb, var(--color-white) 5%, transparent)
+      );
+    }
+  }
+
+  .chat-composer-drawer-attached,
+  .chat-composer-top-drawer {
+    padding-bottom: var(--chat-composer-attachment-overlap);
+  }
+
+  .chat-composer-drawer-slot,
+  .chat-composer-top-drawer {
+    width: calc(100% - 2.75rem);
+    margin-inline: auto;
+    margin-bottom: calc(-1 * var(--chat-composer-attachment-overlap));
+  }
+
+  :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)::before {
+    pointer-events: none;
+    position: absolute;
+    z-index: -1;
+    inset: 0;
+    border: 1px solid var(--chat-composer-attached-outline);
+    border-radius: 16px 16px 0 0;
+    background:
+      linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)),
+      color-mix(in srgb, var(--chat-composer-attached-surface) var(--glass-opacity), transparent);
+    -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+    backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+    -webkit-mask-image: linear-gradient(
+      to top,
+      transparent 0 var(--chat-composer-attachment-overlap),
+      black var(--chat-composer-attachment-overlap)
+    );
+    mask-image: linear-gradient(
+      to top,
+      transparent 0 var(--chat-composer-attachment-overlap),
+      black var(--chat-composer-attachment-overlap)
+    );
+    box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);
+    content: "";
+
+    @variant dark {
+      background:
+        linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)),
+        linear-gradient(
+          to top,
+          transparent 0 var(--chat-composer-attachment-overlap),
+          rgb(0 0 0 / 18%) var(--chat-composer-attachment-overlap),
+          transparent calc(var(--chat-composer-attachment-overlap) + 10px)
+        ),
+        color-mix(in srgb, var(--chat-composer-attached-surface) var(--glass-opacity), transparent);
+      box-shadow: 0 14px 32px -18px rgb(0 0 0 / 75%);
+    }
+
+    @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
+      background:
+        linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)),
+        var(--chat-composer-attached-surface);
+    }
+  }
+
+  .chat-composer-top-drawer {
+    z-index: 0;
+  }
+
+  .chat-composer-top-drawer[data-variant="warning"] {
M
macroscopeappBotAug 16, 2026

src/index.css:798

The warning outline is dropped under every custom theme. This rule is inside @layer components, while the theme bridge at the bottom of the file (html[data-theme-id] :is(.chat-composer-drawer-surface, .chat-composer-top-drawer), ~line 1446) is unlayered, so it wins over any layered rule regardless of specificity and resets --chat-composer-attached-outline to --app-theme-toolbar-border. With a theme active the pending-approval drawer keeps its 8% tint (the theme rule does not set --chat-composer-attached-tint) but renders the same neutral outline as the info drawer.

Suggest either exempting the variant in the theme rule (html[data-theme-id] :is(...):not([data-variant="warning"])) or routing the warning edge through a separate variable the theme bridge does not assign, so the state override survives the theme cascade.

No longer relevant as of c2a57120390624a31b144abf77928ed0f450df1b

apps/web/src/components/chat/ChatComposer.tsx Β· Earlier revision 577902c5 Β· right 2815

Original diff
@@ -2828,269 +2809,432 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
               </div>
             </div>
           ) : null}
-
-          {showCollapsedMobilePromptRow ? (
-            <div className="flex items-center justify-between gap-2 px-3 py-2">
-              <button
-                type="button"
-                className={cn(
-                  "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none",
-                  (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim())
-                    ? "text-foreground"
-                    : "text-placeholder",
-                )}
-                onPointerDown={(event) => event.preventDefault()}
-                onClick={expandMobileComposer}
-                aria-label="Expand composer"
-              >
-                {activePendingProgress
-                  ? activePendingProgress.customAnswer ||
-                    "Type your own answer, or leave this blank to use the selected option"
-                  : prompt.trim() ||
-                    (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}
-              </button>
-              <button
-                type="button"
-                className="flex size-8 shrink-0 items-center justify-center rounded-full bg-message-action text-message-action-foreground hover:bg-message-action-hover disabled:opacity-30"
-                disabled={collapsedComposerPrimaryActionDisabled}
-                aria-label={collapsedComposerPrimaryActionLabel}
-                onPointerDown={(event) => event.preventDefault()}
-                onClick={(event) => {
-                  event.stopPropagation();
-                  submitComposer();
-                }}
-              >
-                <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
-                  <path
-                    d="M8 3L8 13M8 3L4 7M8 3L12 7"
-                    stroke="currentColor"
-                    strokeWidth="2"
-                    strokeLinecap="round"
-                    strokeLinejoin="round"
-                  />
-                </svg>
-              </button>
-            </div>
-          ) : null}
-
+        </div>
+      ) : null}
+      <div className="relative">
+        {!showComposerTopDrawer && !isComposerCollapsedMobile ? (
M
macroscopeappBotAug 16, 2026

The stash tab is suppressed for the composer's own top drawer, but not for the drawers that now attach from outside it, and it is 28px tall above the composer instead of the old 12px pill overhang:

  • ComposerBannerStack/ThreadSyncStatusPill use chat-composer-drawer-slot, whose negative margin ends their opaque area flush with the composer's top edge, so the tab's full -top-7 h-8 overhang paints over the banner row (right side, where its action button sits) rather than over the old 8px gap.
  • Opening the stash drawer covers its own toggle: ComposerCommandMenuLayer places the portal at bottom = rect.top + overlap and inset 1.375rem per side, so the pointer-events-auto layer spans the entire tab except a ~6px sliver past the drawer's right edge. Clicking the tab to collapse it no longer works even though it still reports aria-expanded="true".

Suggest plumbing the same "a drawer is already attached above" signal ChatView computes for attached into this component and suppressing the tab in that case, and lifting the tab above the drawer layer (or shortening its overhang) so it stays clickable while the stash drawer is open.

Posted via Macroscope β€” UI Consistency

M
mariaAug 19, 2026

Fixed with the explicit external drawer attachment state.

apps/web/src/components/chat/ChatComposer.tsx Β· Earlier revision 5b378af5 Β· right 2713–2713

Original diff
@@ -2684,82 +2694,49 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
       className="mx-auto w-full min-w-0 max-w-3xl"
       data-chat-composer-form="true"
     >
-      <div
-        className={cn(
-          "group rounded-[22px] p-px transition-colors duration-200",
-          composerProviderState.composerFrameClassName,
-        )}
-        onDragEnterCapture={composerMentionDragHandlers.onDragEnter}
-        onDragOverCapture={composerMentionDragHandlers.onDragOver}
-        onDragLeaveCapture={onComposerMentionDragLeaveCapture}
-        onDropCapture={composerMentionDragHandlers.onDrop}
-      >
+      {showComposerTopDrawer ? (
         <div
-          ref={composerSurfaceRef}
-          data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"}
-          className={cn(
-            "rounded-[20px] transition-[background-color] duration-200",
-            isDragOverComposer ? "bg-accent/45 ring-1 ring-primary/70" : null,
-            projectSelectionRequired ? "opacity-75" : null,
-            composerProviderState.composerSurfaceClassName,
-          )}
-          onFocusCapture={(event) => {
-            const activeElement = event.target;
-            if (
-              isComposerCollapsedMobile &&
-              activeElement instanceof HTMLElement &&
-              activeElement.closest('[data-chat-composer-collapsed-controls="true"]')
-            ) {
-              return;
-            }
-            if (composerBlurFrameRef.current !== null) {
-              window.cancelAnimationFrame(composerBlurFrameRef.current);
-              composerBlurFrameRef.current = null;
-            }
-            setIsComposerFocused(true);
-          }}
-          onBlurCapture={() => {
-            scheduleComposerCollapseCheck();
-          }}
+          className="chat-composer-top-drawer"
+          data-chat-composer-top-drawer="true"
+          data-variant={activePendingApproval ? "warning" : "info"}
         >
-          {!isComposerCollapsedMobile &&
-            (activePendingApproval ? (
-              <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
-                <ComposerPendingApprovalPanel
-                  approval={activePendingApproval}
-                  pendingCount={pendingApprovals.length}
-                />
-              </div>
-            ) : pendingUserInputs.length > 0 ? (
-              <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
-                <ComposerPendingUserInputPanel
-                  pendingUserInputs={pendingUserInputs}
-                  respondingRequestIds={respondingRequestIds}
-                  answers={activePendingDraftAnswers}
-                  questionIndex={activePendingQuestionIndex}
-                  onToggleOption={onSelectActivePendingUserInputOption}
-                  onAdvance={onAdvanceActivePendingUserInput}
-                />
-              </div>
-            ) : showPlanFollowUpPrompt && activeProposedPlan ? (
-              <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
-                <ComposerPlanFollowUpBanner
-                  key={activeProposedPlan.id}
-                  planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
+          {!isComposerCollapsedMobile && activePendingApproval ? (
+            <div className="flex min-w-0 items-center gap-1 px-3 py-1.5">
+              <ComposerPendingApprovalPanel
+                approval={activePendingApproval}
+                pendingCount={pendingApprovals.length}
+              />
+              <div className="flex shrink-0 items-center gap-0.5">
+                <ComposerPendingApprovalActions
+                  requestId={activePendingApproval.requestId}
+                  isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
+                  showCancel={false}

apps/web/src/components/chat/ChatComposer.tsx Β· right 2713–2713

M
macroscopeappBotAug 16, 2026

chat/ChatComposer.tsx:2713

With the footer now returning null whenever isComposerApprovalState is true, this showCancel={false} leaves the desktop approval state with no way to abort the turn: the previous footer row had "Cancel turn", and the stop button in ComposerFooterPrimaryActions is no longer rendered either (there is no keyboard interrupt binding). Decline only rejects the single tool call. Suggest keeping the cancel action in this compact row (it already fits the mobile branch below) unless the interrupt path is intentionally moving elsewhere.

No longer relevant as of fc5c94b7ca26c3defda146fbb7bbe61d5db79e00

apps/web/src/components/chat/ComposerPendingApprovalActions.tsx Β· Earlier revision 5b378af5 Β· right 27–27

Original diff
@@ -14,41 +15,48 @@ interface ComposerPendingApprovalActionsProps {
 export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({
   requestId,
   isResponding,
+  showCancel = true,
   onRespondToApproval,
 }: ComposerPendingApprovalActionsProps) {
   return (
     <>
+      {showCancel ? (
+        <Button
+          size="xs"
+          variant="ghost-muted"
+          className="h-5 px-1.5 text-[11px] font-normal"

apps/web/src/components/chat/ComposerPendingApprovalActions.tsx Β· right 27–27

M
macroscopeappBotAug 16, 2026

chat/ComposerPendingApprovalActions.tsx:27

size="xs" also emits sm:h-6 and sm:text-xs, and twMerge only drops the unprefixed h-7/text-sm from the same groups β€” the sm:-prefixed ones survive. So at sm and up these buttons render 24px tall with 12px text instead of the intended 20px/11px, which is exactly the viewport where the new desktop approval drawer row is shown.

The same h-5 px-1.5 text-[11px] geometry override is now repeated four times in this file and once in ChatView.tsx, and it also overrides the variant's base and hover text colors (text-foreground, text-destructive-foreground, whose hover override races ghost-muted's own [:hover,[data-pressed]]:text-foreground in emitted-CSS order rather than being deduped).

Consider putting this durable geometry in components/ui/button.tsx as a named size next to icon-micro (e.g. micro: "h-5 gap-1 rounded-md px-1.5 text-[11px] font-normal"), plus a ghost destructive tone for Decline, and keep only layout/context classes at the call sites. That also removes the sm: leak.

No longer relevant as of deb67121f12bddcbe8e8a6e48a776981b513fca7

apps/web/src/components/ChatView.tsx Β· Earlier revision 577902c5 Β· right 4473

Original diff
@@ -4457,19 +4457,20 @@ function ChatViewContent(props: ChatViewProps) {
       ),
       title: working
         ? liveCount > 0
-          ? `${liveCount} ${liveCount === 1 ? "agent" : "agents"} working in the background`
-          : "Background work running"
-        : "Monitoring in the background",
+          ? `${liveCount} ${liveCount === 1 ? "agent" : "agents"} working`
+          : "Background work"
+        : "Monitoring",
       actions: (
         <Button
-          size="xs"
-          variant="outline"
+          size="micro"
+          variant="ghost-muted"
           disabled={isStoppingBackgroundWork}
           onClick={() => void handleStopBackgroundWork()}
         >
           {isStoppingBackgroundWork ? "Stopping..." : "Stop"}
         </Button>
       ),
+      className: "px-3 pt-1.5 pb-[1.375rem] text-xs",
M
macroscopeappBotAug 16, 2026

components/ChatView.tsx:4473

pb-[1.375rem] compensates for the attached drawer's masked seam, but this item class is applied in both attachment modes. ComposerBannerStack is now rendered with attached={false} whenever the thread-sync pill is showing (lines 6403/6410), and in that branch the same class lands on the alert-glass rounded-[22px] floating pill, leaving it with ~6px top and ~22px bottom padding.

Suggest keeping the seam compensation in ComposerBannerStack's attached branch (next to px-3 pt-2) and passing only symmetric padding from the call site.

No longer relevant as of c2a57120390624a31b144abf77928ed0f450df1b

apps/web/src/components/chat/ChatComposer.tsx Β· Earlier revision 5b378af5 Β· right 150–153

Original diff
@@ -142,12 +142,20 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children:
     }
 
     const updatePosition = () => {
-      const rect = anchor.getBoundingClientRect();
+      const form = anchor.closest<HTMLElement>('[data-chat-composer-form="true"]');
+      const mainSurface = form?.querySelector<HTMLElement>(
+        '[data-chat-composer-main-surface="true"]',
+      );
+      const rect = (mainSurface ?? form ?? anchor).getBoundingClientRect();
+      const drawerInset = 22;
+      // One extra pixel prevents fractional layout coordinates from exposing
+      // the canvas between the drawer mask and the composer's foreground edge.
+      const composerOverlap = 17;

apps/web/src/components/chat/ChatComposer.tsx Β· right 150–153

M
macroscopeappBotAug 16, 2026

chat/ChatComposer.tsx:153

These two constants hardcode in px what index.css expresses in rem: the drawer surfaces are width: calc(100% - 2.75rem) (1.375rem per side) and overlap by --chat-composer-attachment-overlap: calc(1rem + 1px). appearanceFonts.ts sets root.style.fontSize from the interface font-size preference, so at any non-default size the portaled command/stash drawer no longer matches the width or the seam of the attached drawers (the strip rule right below has a comment about deliberately staying in rem for this reason). Suggest deriving both from the root font size:

suggestion
1 const rootFontSizePx =
2 Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16;
3 // Mirrors the drawer surfaces' `calc(100% - 2.75rem)` inset in index.css.
4 const drawerInset = 1.375 * rootFontSizePx;
5 // One extra pixel prevents fractional layout coordinates from exposing
6 // the canvas between the drawer mask and the composer's foreground edge.
7 // Mirrors --chat-composer-attachment-overlap: calc(1rem + 1px).
8 const composerOverlap = rootFontSizePx + 1;

No longer relevant as of deb67121f12bddcbe8e8a6e48a776981b513fca7

apps/web/src/components/chat/ComposerStashMenu.tsx Β· Earlier revision 015199d8 Β· right 156

Original diff
@@ -150,20 +134,33 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
                       {missingImageCount(entry) === 1 ? "" : "s"} dropped
                     </span>
                   ) : null}
-                  <span className="shrink-0 text-secondary-label text-xs">
+                  {entry.attachments.length > 0 ? (
+                    <span className="flex shrink-0 items-center -space-x-1.5">
+                      {entry.attachments.slice(0, 3).map((attachment) => (
+                        <img
+                          key={attachment.id}
+                          src={attachment.dataUrl}
+                          alt=""
+                          aria-hidden="true"
+                          className="size-5 rounded border border-border/70 object-cover"
+                        />
+                      ))}
+                    </span>
+                  ) : null}
+                  <span className="shrink-0 text-secondary-label text-xs max-sm:hidden">
                     {formatRelativeTimeLabel(entry.createdAt)}
                   </span>
                   <Button
                     variant="ghost"
                     size="icon-xs"
-                    className="shrink-0 opacity-0 transition-opacity group-hover/stash:opacity-100"
+                    className="pointer-events-none absolute top-1/2 right-2 z-10 -translate-y-1/2 bg-popover! text-foreground! opacity-0 shadow-none! transition-opacity hover:bg-popover! hover:text-foreground! group-hover/stash:pointer-events-auto group-hover/stash:opacity-100 group-focus-within/stash:pointer-events-auto group-focus-within/stash:opacity-100 focus-visible:pointer-events-auto focus-visible:bg-popover! focus-visible:text-foreground! focus-visible:opacity-100"
C
cursorBotAug 16, 2026

Stash delete blocked on touch

Medium Severity

The stash delete control stays pointer-events-none until hover or focus-within. Row onMouseDown calls preventDefault, so a tap never focuses the row or produces hover. On touch, the hit target stays the restore row, so users cannot delete a stashed prompt from the drawer.

Reviewed by Cursor Bugbot for commit 015199d800d3d2c7e7f7371a3c60a4048b1a231d. Configure here.

apps/web/src/index.css Β· Earlier revision 577902c5 Β· right 742–747

Original diff
@@ -680,7 +680,128 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
     outline: none;
   }
 
+  .chat-composer-stash-tab {
+    border-color: var(
+      --chat-composer-outline,
+      color-mix(in srgb, var(--foreground) 8%, transparent)
+    );
+    background: color-mix(
+      in srgb,
+      var(--chat-composer-glass-surface, var(--card)) var(--glass-opacity),
+      transparent
+    );
+    -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+    backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+    box-shadow: 0 -8px 24px -18px rgb(0 0 0 / 45%);
+
+    @variant dark {
+      box-shadow: 0 -10px 28px -18px rgb(0 0 0 / 80%);
+    }
+
+    @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
+      background: var(--chat-composer-glass-surface, var(--card));
+    }
+  }
+
+  /* Mirror the run-context strip above the composer. These surfaces deliberately
+     own no card fill or border themselves: a masked pseudo-element draws the
+     same inset glass, 16px outer corners, quiet outline, and open attachment
+     seam as the strip below. */
+  .chat-composer-drawer-surface,
+  .chat-composer-top-drawer {
+    --chat-composer-attachment-overlap: calc(1rem + 1px);
+    --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card));
+    --chat-composer-attached-outline: var(
+      --chat-composer-outline,
+      color-mix(in srgb, var(--foreground) 8%, transparent)
+    );
+    --chat-composer-attached-tint: transparent;
+    position: relative;
+    isolation: isolate;
+    border: 0 !important;
+    background: transparent !important;
+    box-shadow: none;
+
+    @variant dark {
+      --chat-composer-attached-surface: var(
+        --chat-composer-glass-surface,
+        color-mix(in srgb, var(--background) 96%, var(--color-white))
+      );
+      --chat-composer-attached-outline: var(
+        --chat-composer-outline,
+        color-mix(in srgb, var(--color-white) 5%, transparent)
+      );
+    }
+  }
+
+  .chat-composer-drawer-attached,
+  .chat-composer-top-drawer {
+    padding-bottom: var(--chat-composer-attachment-overlap);
+  }
+
+  .chat-composer-drawer-slot,
+  .chat-composer-top-drawer {
+    width: calc(100% - 2.75rem);
+    margin-inline: auto;
+    margin-bottom: calc(-1 * var(--chat-composer-attachment-overlap));
+  }

apps/web/src/index.css Β· right 742–747

M
macroscopeappBotAug 16, 2026

src/index.css:747

--chat-composer-attachment-overlap is only declared on .chat-composer-drawer-surface / .chat-composer-top-drawer (line 712) and on .chat-composer-glass-shell (line 804). ComposerBannerStack puts chat-composer-drawer-slot on its wrapper (ComposerBannerStack.tsx:109) while the surface class lives on the child Alert, and ChatView renders that wrapper as a sibling above .chat-composer-glass-shell (ChatView.tsx:6402-6413, shell at 6428) β€” so the variable is out of scope there. Custom properties inherit downward only, so calc(-1 * var(--chat-composer-attachment-overlap)) is invalid at computed-value time and margin-bottom resets to 0.

Result: the attached banner never pulls onto the composer, so its masked bottom 1rem + 1px band stays open over the canvas instead of being covered by the composer surface β€” an attached banner shows a transparent seam gap rather than joining the composer (ThreadSyncStatusPill is unaffected because it carries the surface class on the same element).

Suggest declaring the overlap on the slot itself so the value is defined wherever the geometry is used:

suggestion
1 .chat-composer-drawer-slot,
2 .chat-composer-top-drawer {
3 --chat-composer-attachment-overlap: calc(1rem + 1px);
4 width: calc(100% - 2.75rem);
5 margin-inline: auto;
6 margin-bottom: calc(-1 * var(--chat-composer-attachment-overlap));
7 }

No longer relevant as of c2a57120390624a31b144abf77928ed0f450df1b

apps/web/src/components/chat/ChatComposer.tsx Β· Earlier revision 5b037166 Β· right 2840–2867

Original diff
@@ -2829,269 +2834,415 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
               </div>
             </div>
           ) : null}
-
-          {showCollapsedMobilePromptRow ? (
-            <div className="flex items-center justify-between gap-2 px-3 py-2">
-              <button
-                type="button"
-                className={cn(
-                  "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none",
-                  (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim())
-                    ? "text-foreground"
-                    : "text-placeholder",
-                )}
-                onPointerDown={(event) => event.preventDefault()}
-                onClick={expandMobileComposer}
-                aria-label="Expand composer"
-              >
-                {activePendingProgress
-                  ? activePendingProgress.customAnswer ||
-                    "Type your own answer, or leave this blank to use the selected option"
-                  : prompt.trim() ||
-                    (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}
-              </button>
-              <button
-                type="button"
-                className="flex size-8 shrink-0 items-center justify-center rounded-full bg-message-action text-message-action-foreground hover:bg-message-action-hover disabled:opacity-30"
-                disabled={collapsedComposerPrimaryActionDisabled}
-                aria-label={collapsedComposerPrimaryActionLabel}
-                onPointerDown={(event) => event.preventDefault()}
-                onClick={(event) => {
-                  event.stopPropagation();
-                  submitComposer();
-                }}
-              >
-                <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
-                  <path
-                    d="M8 3L8 13M8 3L4 7M8 3L12 7"
-                    stroke="currentColor"
-                    strokeWidth="2"
-                    strokeLinecap="round"
-                    strokeLinejoin="round"
-                  />
-                </svg>
-              </button>
-            </div>
-          ) : null}
-
+        </div>
+      ) : null}
+      <div className="relative">
+        {!props.externalDrawerAttached && !showComposerTopDrawer && !isComposerCollapsedMobile ? (

apps/web/src/components/chat/ChatComposer.tsx Β· right 2840–2867

M
macroscopeappBotAug 19, 2026

Suppressing the tab on externalDrawerAttached also removes the only acknowledgement that ⌘S worked.

ComposerStashBadge is the sole pointer entry point to the stash (toggleStashMenu has no other call site) and the sole success signal for a stash β€” stashCurrentPrompt only toasts on failure (written/durable), so on the happy path the badge's count and pulse are the entire feedback (see its own doc comment: "On save the badge gives one quiet acknowledgement").

externalDrawerAttached is true for any composer banner (background liveness, woke, update) or the sync pill (ChatView.tsx:6251), and none of those states block stashing: ⌘S still clears the prompt and images, but with the tab gone there is no toast, no count, and no pointer way back into the stash. Touch has no ⌘S at all, and the stashCurrentPrompt empty-composer fallback only opens the menu when the composer is already empty.

Hiding the tab is right β€” it would collide with the drawer above it β€” so the smallest fix is to keep some affordance alive in that state rather than restoring the tab: e.g. surface the stash count/toggle in the footer row while the tab is suppressed, or toast on a successful stash when it isn't rendered.

Posted via Macroscope β€” UI Consistency

M
mariaAug 19, 2026

Fixed in 690bf89ec. The tab stays hidden beside attached drawers, but the same count, pulse, and toggle move into existing controls. Expanded, collapsed, and pending mobile layouts all keep a pointer entry, and the inline control remains clickable while the menu is open.

apps/web/src/components/chat/ComposerBannerStack.tsx Β· Earlier revision 5b037166 Β· right 202–202

Original diff
@@ -190,7 +197,12 @@ function ComposerBannerStackAlert({
   return (
     <Alert
       variant={item.variant}
-      className={cn("alert-glass rounded-[22px]", item.className)}
+      className={cn(
+        attached
+          ? "chat-composer-drawer-surface chat-composer-drawer-attached px-3 pt-2 pb-[calc(var(--chat-composer-attachment-overlap)_+_0.375rem)] text-xs"

apps/web/src/components/chat/ComposerBannerStack.tsx Β· right 202–202

M
macroscopeappBotAug 19, 2026

The surfaces that stack in the attached-drawer column disagree on their content inset from sm up.

This banner is px-3 and ThreadSyncStatusPill is px-3 (ThreadSyncStatusPill.tsx:11), while every body inside chat-composer-top-drawer uses the composer's px-3 sm:px-4: the approval row (ChatComposer.tsx:2737), the collapsed approval panel (2769), ComposerPlanFollowUpBanner.tsx:9, and ComposerPendingUserInputPanel.tsx:220. All of them share the same --chat-composer-drawer-inset column and are rendered directly on top of one another, so at β‰₯640px the banner/pill text starts 4px inside the top drawer's text, and content shifts sideways as the stack changes.

Suggest adopting the same px-3 sm:px-4 here and in ThreadSyncStatusPill.

suggestion
1 ? "chat-composer-drawer-surface chat-composer-drawer-attached px-3 pt-2 pb-[calc(var(--chat-composer-attachment-overlap)_+_0.375rem)] text-xs sm:px-4"

Posted via Macroscope β€” UI Consistency

apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx Β· Earlier revision 898187fe Β· right 30–34

Original diff
@@ -1,48 +1,44 @@
 import { memo } from "react";
 import { type PendingApproval } from "../../session-logic";
+import { cn } from "~/lib/utils";
 
 interface ComposerPendingApprovalPanelProps {
   approval: PendingApproval;
   pendingCount: number;
+  className?: string;
 }
 
 export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprovalPanel({
   approval,
   pendingCount,
+  className,
 }: ComposerPendingApprovalPanelProps) {
-  const approvalSummary =
+  const fallbackLabel =
     approval.requestKind === "command"
-      ? "Command approval requested"
+      ? "Command approval"
       : approval.requestKind === "file-read"
-        ? "File-read approval requested"
-        : "File-change approval requested";
-  const detailLabel =
+        ? "File read approval"
+        : "File change approval";
+  const detailAriaLabel =
     approval.requestKind === "command"
       ? "Command"
       : approval.requestKind === "file-read"
         ? "File to read"
         : "File change";
 
   return (
-    <div className="min-w-0 px-4 py-3.5 sm:px-5 sm:py-4">
-      <div className="flex flex-wrap items-center gap-2">
-        <span className="uppercase text-sm tracking-[0.2em]">PENDING APPROVAL</span>
-        <span className="text-sm font-medium">{approvalSummary}</span>
-        {pendingCount > 1 ? (
-          <span className="text-xs text-muted-foreground">1/{pendingCount}</span>
-        ) : null}
-      </div>
-      {approval.detail ? (
-        <div className="mt-3 min-w-0 max-w-full rounded-lg border border-border/65 bg-background/70 p-3">
-          <p className="text-xs font-medium text-muted-foreground">{detailLabel}</p>
-          <pre
-            aria-label={detailLabel}
-            className="mt-2 min-w-0 max-w-full max-h-40 overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-xs leading-relaxed text-foreground"
-            data-approval-detail="complete"
-          >
-            {approval.detail}
-          </pre>
-        </div>
+    <div className={cn("flex min-w-0 flex-1 items-center gap-2", className)}>
+      <code
+        aria-label={detailAriaLabel}
+        className="block max-h-20 min-w-0 flex-1 overflow-auto whitespace-pre font-mono text-[11px] text-foreground/85"
+        data-approval-detail="complete"

apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx Β· right 30–34

M
macroscopeappBotAug 16, 2026

chat/ComposerPendingApprovalPanel.tsx:34

aria-label on <code> is not exposed: <code> maps to ARIA role code, which prohibits an author-provided name, so assistive tech drops it. That was harmless before because the panel also rendered visible context (PENDING APPROVAL, Command approval requested, and the Command / File to read detail label); with all of those removed, a screen reader now reaches this drawer as a bare command string followed by Cancel / Decline / Always allow this session / Approve, with nothing stating that an approval is pending or which kind it is. The data-variant="warning" tint on the drawer is the only remaining cue, i.e. colour alone. fallbackLabel does not cover this β€” it only renders when approval.detail is null.

Suggest moving the name onto a container that accepts one (or adding an sr-only span with detailAriaLabel), keeping the compact row visually unchanged:

suggestion
1 <div
2 aria-label={detailAriaLabel}
3 className={cn("flex min-w-0 flex-1 items-center gap-2", className)}
4 role="group"
5 >
6 <code
7 className="block max-h-20 min-w-0 flex-1 overflow-auto whitespace-pre font-mono text-[11px] text-foreground/85"
8 data-approval-detail="complete"

No longer relevant as of 2843d45cb0b2f48f8466f4e520db49e927f17838

apps/web/src/components/chat/ChatComposer.tsx Β· Earlier revision 033615df Β· right 2856–2856

Original diff
@@ -2828,269 +2850,414 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
               </div>
             </div>
           ) : null}
-
-          {showCollapsedMobilePromptRow ? (
-            <div className="flex items-center justify-between gap-2 px-3 py-2">
-              <button
-                type="button"
-                className={cn(
-                  "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none",
-                  (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim())
-                    ? "text-foreground"
-                    : "text-placeholder",
-                )}
-                onPointerDown={(event) => event.preventDefault()}
-                onClick={expandMobileComposer}
-                aria-label="Expand composer"
-              >
-                {activePendingProgress
-                  ? activePendingProgress.customAnswer ||
-                    "Type your own answer, or leave this blank to use the selected option"
-                  : prompt.trim() ||
-                    (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}
-              </button>
-              <button
-                type="button"
-                className="flex size-8 shrink-0 items-center justify-center rounded-full bg-message-action text-message-action-foreground hover:bg-message-action-hover disabled:opacity-30"
-                disabled={collapsedComposerPrimaryActionDisabled}
-                aria-label={collapsedComposerPrimaryActionLabel}
-                onPointerDown={(event) => event.preventDefault()}
-                onClick={(event) => {
-                  event.stopPropagation();
-                  submitComposer();
-                }}
-              >
-                <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
-                  <path
-                    d="M8 3L8 13M8 3L4 7M8 3L12 7"
-                    stroke="currentColor"
-                    strokeWidth="2"
-                    strokeLinecap="round"
-                    strokeLinejoin="round"
-                  />
-                </svg>
-              </button>
-            </div>
-          ) : null}
-
+        </div>
+      ) : null}
+      <div className="relative">
+        {!showComposerTopDrawer && !topAttachmentOccupied && !isComposerCollapsedMobile ? (

apps/web/src/components/chat/ChatComposer.tsx Β· right 2856–2856

M
macroscopeappBotAug 17, 2026

chat/ChatComposer.tsx:2856

Adding !topAttachmentOccupied removes the only visible stash affordance in a fairly common state. embeddedStashBadge is only rendered inside the composer's own top drawer (pending approval / pending input / plan-ready), so when the seam is taken by an external attachment β€” composerBannerAttached && composerBannerItems.length > 0 or the thread-sync pill β€” nothing renders the badge at all, and both the click target and the stash count disappear.

That is not a rare state: backgroundLivenessBannerItem (ChatView.tsx:4454) keeps a Monitoring / N agents working banner up for the whole time a thread has background liveness, and woke-thread / branch-mismatch / system banners behave the same way. The only remaining route is ⌘S with an empty composer (stashCurrentPrompt falls through to setIsStashMenuOpen), which is undiscoverable.

Suggest keeping a badge in this case rather than dropping it β€” e.g. render embeddedStashBadge inside the composer surface (or hand it to the occupying banner as trailing content) when topAttachmentOccupied is true, so the tab is only traded for an inline badge, never for nothing.

No longer relevant as of 2843d45cb0b2f48f8466f4e520db49e927f17838

apps/web/src/components/chat/ChatComposer.tsx Β· Earlier revision 1fb883e9 Β· right 2921

Original diff
@@ -2828,269 +2828,414 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
               </div>
             </div>
           ) : null}
-
-          {showCollapsedMobilePromptRow ? (
-            <div className="flex items-center justify-between gap-2 px-3 py-2">
-              <button
-                type="button"
-                className={cn(
-                  "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none",
-                  (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim())
-                    ? "text-foreground"
-                    : "text-placeholder",
-                )}
-                onPointerDown={(event) => event.preventDefault()}
-                onClick={expandMobileComposer}
-                aria-label="Expand composer"
-              >
-                {activePendingProgress
-                  ? activePendingProgress.customAnswer ||
-                    "Type your own answer, or leave this blank to use the selected option"
-                  : prompt.trim() ||
-                    (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}
-              </button>
-              <button
-                type="button"
-                className="flex size-8 shrink-0 items-center justify-center rounded-full bg-message-action text-message-action-foreground hover:bg-message-action-hover disabled:opacity-30"
-                disabled={collapsedComposerPrimaryActionDisabled}
-                aria-label={collapsedComposerPrimaryActionLabel}
-                onPointerDown={(event) => event.preventDefault()}
-                onClick={(event) => {
-                  event.stopPropagation();
-                  submitComposer();
-                }}
-              >
-                <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
-                  <path
-                    d="M8 3L8 13M8 3L4 7M8 3L12 7"
-                    stroke="currentColor"
-                    strokeWidth="2"
-                    strokeLinecap="round"
-                    strokeLinejoin="round"
-                  />
-                </svg>
-              </button>
-            </div>
-          ) : null}
-
+        </div>
+      ) : null}
+      <div className="relative">
+        {!showComposerTopDrawer && !isComposerCollapsedMobile ? (
+          <ComposerStashBadge
+            count={stashQueue.length}
+            menuOpen={isStashMenuOpen}
+            pulseKey={stashPulse.key}
+            pulsing={stashPulse.active}
+            onToggleMenu={toggleStashMenu}
+          />
+        ) : null}
+        <div
+          data-chat-composer-main-surface="true"
+          className={cn(
+            "group relative z-10 rounded-[22px] p-px transition-colors duration-200",
+            composerProviderState.composerFrameClassName,
+          )}
+        >
           <div
-            ref={setComposerMenuAnchor}
+            ref={composerSurfaceRef}
+            data-chat-composer-surface="true"
+            data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"}
             className={cn(
-              "relative px-3 pb-2 sm:px-4",
-              hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4",
-              isComposerCollapsedMobile && "hidden",
+              "rounded-[20px] transition-[background-color] duration-200",
+              isDragOverComposer ? "bg-accent/45 ring-1 ring-primary/70" : null,
+              projectSelectionRequired ? "opacity-75" : null,
+              composerProviderState.composerSurfaceClassName,
             )}
           >
-            <ComposerStashBadge
-              count={stashQueue.length}
-              pulseKey={stashPulse.key}
-              pulsing={stashPulse.active}
-              menuOpen={isStashMenuOpen}
-              onToggleMenu={toggleStashMenu}
-            />
-
-            {isStashMenuOpen && !composerMenuOpen && !isComposerApprovalState && (
-              <ComposerCommandMenuLayer anchor={composerMenuAnchor}>
-                <ComposerStashMenu
-                  entries={stashQueue}
-                  onRestore={restoreStashEntry}
-                  onDelete={deleteStashEntry}
-                  onClose={() => setIsStashMenuOpen(false)}
-                />
-              </ComposerCommandMenuLayer>
-            )}
-
-            {composerMenuOpen && !isComposerApprovalState && (
-              <ComposerCommandMenuLayer anchor={composerMenuAnchor}>
-                <ComposerCommandMenu
-                  items={composerMenuItems}
-                  resolvedTheme={resolvedTheme}
-                  isLoading={isComposerMenuLoading}
-                  triggerKind={composerTriggerKind}
-                  groupSlashCommandSections={
-                    composerTrigger?.kind === "slash-command" &&
-                    composerTrigger.query.trim().length === 0
-                  }
-                  emptyStateText={composerMenuEmptyState}
-                  activeItemId={activeComposerMenuItem?.id ?? null}
-                  onHighlightedItemChange={onComposerMenuItemHighlighted}
-                  onSelect={onSelectComposerItem}
-                />
-              </ComposerCommandMenuLayer>
-            )}
-
-            {!isComposerCollapsedMobile &&
-              !isComposerApprovalState &&
-              pendingUserInputs.length === 0 &&
-              composerPreviewAnnotations.length > 0 && (
-                <ComposerPreviewAnnotationCards
-                  annotations={composerPreviewAnnotations}
-                  images={composerImages}
-                  onRemove={(annotationId) =>
-                    removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId)
-                  }
-                  onExpandImage={(imageId) => {
-                    const preview = buildExpandedImagePreview(composerImages, imageId);
-                    if (preview) onExpandImage(preview);
+            {showCollapsedMobilePromptRow ? (
+              <div className="flex items-center justify-between gap-2 px-3 py-2">
+                <button
+                  type="button"
+                  className={cn(
+                    "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none",
+                    (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim())
+                      ? "text-foreground"
+                      : "text-placeholder",
+                  )}
+                  onPointerDown={(event) => event.preventDefault()}
+                  onClick={expandMobileComposer}
+                  aria-label="Expand composer"
+                >
+                  {activePendingProgress
+                    ? activePendingProgress.customAnswer ||
+                      "Type your own answer, or leave this blank to use the selected option"
+                    : prompt.trim() ||
+                      (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}
+                </button>
+                <button
+                  type="button"
+                  className="flex size-8 shrink-0 items-center justify-center rounded-full bg-message-action text-message-action-foreground hover:bg-message-action-hover disabled:opacity-30"
+                  disabled={collapsedComposerPrimaryActionDisabled}
+                  aria-label={collapsedComposerPrimaryActionLabel}
+                  onPointerDown={(event) => event.preventDefault()}
+                  onClick={(event) => {
+                    event.stopPropagation();
+                    submitComposer();
                   }}
-                  className="mb-3"
-                />
+                >
+                  <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
+                    <path
+                      d="M8 3L8 13M8 3L4 7M8 3L12 7"
+                      stroke="currentColor"
+                      strokeWidth="2"
+                      strokeLinecap="round"
+                      strokeLinejoin="round"
+                    />
+                  </svg>
+                </button>
+              </div>
+            ) : null}
+
+            <div
+              ref={setComposerMenuAnchor}
+              className={cn(
+                "relative px-3 pb-2 sm:px-4",
+                "pt-3.5 sm:pt-4",
+                isComposerCollapsedMobile && "hidden",
+              )}
+            >
+              {isStashMenuOpen && !composerMenuOpen && !isComposerApprovalState && (
+                <ComposerCommandMenuLayer anchor={composerMenuAnchor}>
+                  <ComposerStashMenu
+                    entries={stashQueue}
+                    onRestore={restoreStashEntry}
+                    onDelete={deleteStashEntry}
+                    onClose={() => setIsStashMenuOpen(false)}
+                  />
+                </ComposerCommandMenuLayer>
C
cursorBotAug 17, 2026

Stash menu survives pending input

Medium Severity

Hiding the stash badge whenever showComposerTopDrawer is true (including pending user input) does not close or gate the stash menu. An already-open menu stays mounted because render only checks !isComposerApprovalState, and with the badge gone there is no click target to dismiss it. Choosing a row still runs restoreStashEntry, which writes the normal composer draft rather than the active pending answerβ€”the broken affordance this PR aimed to hide.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit 1fb883e96bf0c037c34316e0fd240ae68c34f939. Configure here.

apps/web/src/index.css Β· Earlier revision 033615df Β· right 1491–1499

Original diff
@@ -1255,6 +1479,26 @@ html[data-theme-id] .chat-composer-glass-shell {
   }
 }
 
+html[data-theme-id] :is(.chat-composer-drawer-surface, .chat-composer-top-drawer) {
+  --chat-composer-attached-surface: var(--app-theme-surface-raised);
+}
+
+html[data-theme-id]
+  :is(.chat-composer-drawer-surface, .chat-composer-top-drawer):is(
+    :not([data-variant]),
+    [data-variant="default"]
+  ) {
+  --chat-composer-attached-outline: var(--app-theme-toolbar-border);
+
+  @variant dark {
+    --chat-composer-attached-outline: color-mix(
+      in srgb,
+      var(--app-theme-input) 30%,
+      var(--background)
+    );
+  }

apps/web/src/index.css Β· right 1491–1499

M
macroscopeappBotAug 17, 2026

src/index.css:1499

This bridge hardcodes the outline instead of preferring the composer's own edge token, so per-theme overrides of that edge no longer reach the attached drawers. The layered default is --chat-composer-attached-outline: var(--chat-composer-outline, …), which in-shell drawers (.chat-composer-top-drawer) inherit from .chat-composer-glass-shell; this unlayered rule replaces that indirection with a fixed value.

Concretely, under html[data-theme-id="t3-chat"] in dark mode the composer edge is #241e28 (line 1506, explicitly chosen over the derived value), while the drawer attached directly to it resolves to color-mix(in srgb, var(--app-theme-input) 30%, var(--background)) β€” two different edge colors meeting at the seam that is supposed to read as one surface.

Keeping the indirection with the theme value as the fallback preserves the out-of-shell/portaled drawers (banner stack, sync pill, command and stash menus) that genuinely need it:

suggestion
1 --chat-composer-attached-outline: var(
2 --chat-composer-outline,
3 var(--app-theme-toolbar-border)
4 );
5
6 @variant dark {
7 --chat-composer-attached-outline: var(
8 --chat-composer-outline,
9 color-mix(in srgb, var(--app-theme-input) 30%, var(--background))
10 );
11 }

No longer relevant as of 2843d45cb0b2f48f8466f4e520db49e927f17838

apps/web/src/index.css Β· Earlier revision c2a57120 Β· right 928–937

Original diff
@@ -775,7 +912,63 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
     content: "";
   }
 
-  .chat-composer-glass-shell-with-context .chat-composer-glass-host::after {
+  /* A top attachment cannot share the shell's single clipped backdrop: it
+     needs the composer surface and outline to paint in front of its overlap.
+     Split the glass into exact surfaces while a drawer or tab is present; the
+     default composer keeps the cheaper continuous layer. */
+  .chat-composer-glass-shell:has(:is(.chat-composer-top-drawer, .chat-composer-stash-tab))::before,
+  .chat-composer-glass-host:has(:is(.chat-composer-top-drawer, .chat-composer-stash-tab))::after {
+    display: none;
+  }
+
+  .chat-composer-glass-host:has(:is(.chat-composer-top-drawer, .chat-composer-stash-tab)) {
+    box-shadow: none;
+  }
+
+  .chat-composer-glass-host:has(:is(.chat-composer-top-drawer, .chat-composer-stash-tab))
+    [data-chat-composer-main-surface="true"] {
+    background: color-mix(
+      in srgb,
+      var(--chat-composer-glass-surface) var(--glass-opacity),
+      transparent
+    );
+    -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+    backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+    box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);

apps/web/src/index.css Β· right 928–937

M
macroscopeappBotAug 16, 2026

src/index.css:937

This is now the only thing painting the composer fill when a top drawer or stash tab is present (the shell ::before and host ::after are display: none just above), but it is inside @layer components while .ultrathink-frame { background: transparent } (~line 2149) is unlayered β€” unlayered author rules beat every layer regardless of specificity.

composerFrameClassName puts ultrathink-frame on this exact [data-chat-composer-main-surface="true"] element (composerProviderState.tsx:75), so with an Ultrathink: prompt plus a pending-approval/plan drawer or a stash tab the composer loses its card fill entirely and only keeps the backdrop-filter, leaving the timeline blurrily visible through the composer body in both light and dark.

Smallest fixes: paint the split fill on the inner [data-chat-composer-surface="true"] element (which never receives composerFrameClassName), or drop the redundant background: transparent from .ultrathink-frame so the layered fill can apply. Note ::before on this element is already owned by .ultrathink-frame::before, so the fill cannot move to that pseudo-element either.

No longer relevant as of 5cc14855f9c5202274881bc5315e8e6b82120513

apps/web/src/components/ChatView.tsx Β· Earlier revision 577902c5 Β· right 4473

Original diff
@@ -4457,19 +4457,20 @@ function ChatViewContent(props: ChatViewProps) {
       ),
       title: working
         ? liveCount > 0
-          ? `${liveCount} ${liveCount === 1 ? "agent" : "agents"} working in the background`
-          : "Background work running"
-        : "Monitoring in the background",
+          ? `${liveCount} ${liveCount === 1 ? "agent" : "agents"} working`
+          : "Background work"
+        : "Monitoring",
       actions: (
         <Button
-          size="xs"
-          variant="outline"
+          size="micro"
+          variant="ghost-muted"
           disabled={isStoppingBackgroundWork}
           onClick={() => void handleStopBackgroundWork()}
         >
           {isStoppingBackgroundWork ? "Stopping..." : "Stop"}
         </Button>
       ),
+      className: "px-3 pt-1.5 pb-[1.375rem] text-xs",
M
macroscopeappBotAug 16, 2026

components/ChatView.tsx:4473

pb-[1.375rem] compensates for the attached drawer's masked seam, but this item class is applied in both attachment modes. ComposerBannerStack is now rendered with attached={false} whenever the thread-sync pill is showing (lines 6403/6410), and in that branch the same class lands on the alert-glass rounded-[22px] floating pill, leaving it with ~6px top and ~22px bottom padding.

Suggest keeping the seam compensation in ComposerBannerStack's attached branch (next to px-3 pt-2) and passing only symmetric padding from the call site.

No longer relevant as of d070c59252c99f7ed54e42f5ee4ea7c81fa27bcb

apps/web/src/components/chat/ChatComposer.tsx Β· Earlier revision 577902c5 Β· right 2815

Original diff
@@ -2828,269 +2809,432 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
               </div>
             </div>
           ) : null}
-
-          {showCollapsedMobilePromptRow ? (
-            <div className="flex items-center justify-between gap-2 px-3 py-2">
-              <button
-                type="button"
-                className={cn(
-                  "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none",
-                  (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim())
-                    ? "text-foreground"
-                    : "text-placeholder",
-                )}
-                onPointerDown={(event) => event.preventDefault()}
-                onClick={expandMobileComposer}
-                aria-label="Expand composer"
-              >
-                {activePendingProgress
-                  ? activePendingProgress.customAnswer ||
-                    "Type your own answer, or leave this blank to use the selected option"
-                  : prompt.trim() ||
-                    (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}
-              </button>
-              <button
-                type="button"
-                className="flex size-8 shrink-0 items-center justify-center rounded-full bg-message-action text-message-action-foreground hover:bg-message-action-hover disabled:opacity-30"
-                disabled={collapsedComposerPrimaryActionDisabled}
-                aria-label={collapsedComposerPrimaryActionLabel}
-                onPointerDown={(event) => event.preventDefault()}
-                onClick={(event) => {
-                  event.stopPropagation();
-                  submitComposer();
-                }}
-              >
-                <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
-                  <path
-                    d="M8 3L8 13M8 3L4 7M8 3L12 7"
-                    stroke="currentColor"
-                    strokeWidth="2"
-                    strokeLinecap="round"
-                    strokeLinejoin="round"
-                  />
-                </svg>
-              </button>
-            </div>
-          ) : null}
-
+        </div>
+      ) : null}
+      <div className="relative">
+        {!showComposerTopDrawer && !isComposerCollapsedMobile ? (
M
macroscopeappBotAug 16, 2026

The stash tab is suppressed for the composer's own top drawer, but not for the drawers that now attach from outside it, and it is 28px tall above the composer instead of the old 12px pill overhang:

  • ComposerBannerStack/ThreadSyncStatusPill use chat-composer-drawer-slot, whose negative margin ends their opaque area flush with the composer's top edge, so the tab's full -top-7 h-8 overhang paints over the banner row (right side, where its action button sits) rather than over the old 8px gap.
  • Opening the stash drawer covers its own toggle: ComposerCommandMenuLayer places the portal at bottom = rect.top + overlap and inset 1.375rem per side, so the pointer-events-auto layer spans the entire tab except a ~6px sliver past the drawer's right edge. Clicking the tab to collapse it no longer works even though it still reports aria-expanded="true".

Suggest plumbing the same "a drawer is already attached above" signal ChatView computes for attached into this component and suppressing the tab in that case, and lifting the tab above the drawer layer (or shortening its overhang) so it stays clickable while the stash drawer is open.

Posted via Macroscope β€” UI Consistency

M
mariaAug 19, 2026

Fixed. ChatComposer now receives the real external attachment state and hides the stash tab for both banner and sync drawers.

apps/web/src/index.css Β· Earlier revision 577902c5 Β· right 798

Original diff
@@ -680,7 +680,128 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
     outline: none;
   }
 
+  .chat-composer-stash-tab {
+    border-color: var(
+      --chat-composer-outline,
+      color-mix(in srgb, var(--foreground) 8%, transparent)
+    );
+    background: color-mix(
+      in srgb,
+      var(--chat-composer-glass-surface, var(--card)) var(--glass-opacity),
+      transparent
+    );
+    -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+    backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+    box-shadow: 0 -8px 24px -18px rgb(0 0 0 / 45%);
+
+    @variant dark {
+      box-shadow: 0 -10px 28px -18px rgb(0 0 0 / 80%);
+    }
+
+    @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
+      background: var(--chat-composer-glass-surface, var(--card));
+    }
+  }
+
+  /* Mirror the run-context strip above the composer. These surfaces deliberately
+     own no card fill or border themselves: a masked pseudo-element draws the
+     same inset glass, 16px outer corners, quiet outline, and open attachment
+     seam as the strip below. */
+  .chat-composer-drawer-surface,
+  .chat-composer-top-drawer {
+    --chat-composer-attachment-overlap: calc(1rem + 1px);
+    --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card));
+    --chat-composer-attached-outline: var(
+      --chat-composer-outline,
+      color-mix(in srgb, var(--foreground) 8%, transparent)
+    );
+    --chat-composer-attached-tint: transparent;
+    position: relative;
+    isolation: isolate;
+    border: 0 !important;
+    background: transparent !important;
+    box-shadow: none;
+
+    @variant dark {
+      --chat-composer-attached-surface: var(
+        --chat-composer-glass-surface,
+        color-mix(in srgb, var(--background) 96%, var(--color-white))
+      );
+      --chat-composer-attached-outline: var(
+        --chat-composer-outline,
+        color-mix(in srgb, var(--color-white) 5%, transparent)
+      );
+    }
+  }
+
+  .chat-composer-drawer-attached,
+  .chat-composer-top-drawer {
+    padding-bottom: var(--chat-composer-attachment-overlap);
+  }
+
+  .chat-composer-drawer-slot,
+  .chat-composer-top-drawer {
+    width: calc(100% - 2.75rem);
+    margin-inline: auto;
+    margin-bottom: calc(-1 * var(--chat-composer-attachment-overlap));
+  }
+
+  :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)::before {
+    pointer-events: none;
+    position: absolute;
+    z-index: -1;
+    inset: 0;
+    border: 1px solid var(--chat-composer-attached-outline);
+    border-radius: 16px 16px 0 0;
+    background:
+      linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)),
+      color-mix(in srgb, var(--chat-composer-attached-surface) var(--glass-opacity), transparent);
+    -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+    backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+    -webkit-mask-image: linear-gradient(
+      to top,
+      transparent 0 var(--chat-composer-attachment-overlap),
+      black var(--chat-composer-attachment-overlap)
+    );
+    mask-image: linear-gradient(
+      to top,
+      transparent 0 var(--chat-composer-attachment-overlap),
+      black var(--chat-composer-attachment-overlap)
+    );
+    box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);
+    content: "";
+
+    @variant dark {
+      background:
+        linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)),
+        linear-gradient(
+          to top,
+          transparent 0 var(--chat-composer-attachment-overlap),
+          rgb(0 0 0 / 18%) var(--chat-composer-attachment-overlap),
+          transparent calc(var(--chat-composer-attachment-overlap) + 10px)
+        ),
+        color-mix(in srgb, var(--chat-composer-attached-surface) var(--glass-opacity), transparent);
+      box-shadow: 0 14px 32px -18px rgb(0 0 0 / 75%);
+    }
+
+    @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
+      background:
+        linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)),
+        var(--chat-composer-attached-surface);
+    }
+  }
+
+  .chat-composer-top-drawer {
+    z-index: 0;
+  }
+
+  .chat-composer-top-drawer[data-variant="warning"] {
M
macroscopeappBotAug 16, 2026

src/index.css:798

The warning outline is dropped under every custom theme. This rule is inside @layer components, while the theme bridge at the bottom of the file (html[data-theme-id] :is(.chat-composer-drawer-surface, .chat-composer-top-drawer), ~line 1446) is unlayered, so it wins over any layered rule regardless of specificity and resets --chat-composer-attached-outline to --app-theme-toolbar-border. With a theme active the pending-approval drawer keeps its 8% tint (the theme rule does not set --chat-composer-attached-tint) but renders the same neutral outline as the info drawer.

Suggest either exempting the variant in the theme rule (html[data-theme-id] :is(...):not([data-variant="warning"])) or routing the warning edge through a separate variable the theme bridge does not assign, so the state override survives the theme cascade.

No longer relevant as of c2a57120390624a31b144abf77928ed0f450df1b

apps/web/src/components/ChatView.tsx Β· Earlier revision 6202e181 Β· right 2326

Original diff
@@ -2318,6 +2318,12 @@ function ChatViewContent(props: ChatViewProps) {
     latestTurnSettled &&
     hasActionableProposedPlan(activeProposedPlan);
   const activePendingApproval = pendingApprovals[0] ?? null;
+  const composerOwnsTopAttachment =
+    activePendingApproval !== null ||
+    pendingUserInputs.length > 0 ||
+    (showPlanFollowUpPrompt && activeProposedPlan !== null);
+  const composerBannerAttached =
+    (!threadSyncPhase || activeEnvironmentUnavailable) && !composerOwnsTopAttachment;
C
cursorBotAug 16, 2026

Plan attachment ownership mismatch

Medium Severity

composerOwnsTopAttachment treats a ready plan as owning the composer seam, but showComposerTopDrawer only mounts the plan drawer when the mobile composer is expanded. On a collapsed mobile plan-ready thread, banners and the sync pill detach even though no top drawer is present, so status chrome floats with the wrong attached layout.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit 6202e181afdd31d428d033539690798ec19df044. Configure here.

apps/web/src/components/chat/ChatComposer.tsx Β· Earlier revision f578cd18 Β· left 2715

Original diff
@@ -2684,82 +2694,48 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
       className="mx-auto w-full min-w-0 max-w-3xl"
       data-chat-composer-form="true"
     >
-      <div
-        className={cn(
-          "group rounded-[22px] p-px transition-colors duration-200",
-          composerProviderState.composerFrameClassName,
-        )}
-        onDragEnterCapture={composerMentionDragHandlers.onDragEnter}
-        onDragOverCapture={composerMentionDragHandlers.onDragOver}
-        onDragLeaveCapture={onComposerMentionDragLeaveCapture}
-        onDropCapture={composerMentionDragHandlers.onDrop}
-      >
+      {showComposerTopDrawer ? (
         <div
-          ref={composerSurfaceRef}
-          data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"}
-          className={cn(
-            "rounded-[20px] transition-[background-color] duration-200",
-            isDragOverComposer ? "bg-accent/45 ring-1 ring-primary/70" : null,
-            projectSelectionRequired ? "opacity-75" : null,
-            composerProviderState.composerSurfaceClassName,
-          )}
-          onFocusCapture={(event) => {
-            const activeElement = event.target;
-            if (
-              isComposerCollapsedMobile &&
-              activeElement instanceof HTMLElement &&
-              activeElement.closest('[data-chat-composer-collapsed-controls="true"]')
-            ) {
-              return;
-            }
-            if (composerBlurFrameRef.current !== null) {
C
cursorBotAug 16, 2026

Approval actions overflow expanded composer

Medium Severity

The expanded approval row puts the full command and all four actions in one non-wrapping flex line inside the narrower top drawer. Focusing the composer on a small screen leaves the stacked, wrapping mobile layout and uses this row instead, so Always allow this session and the other actions can overflow or crowd the command out of reach.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit f578cd181ba69e9b184700af236590cf18ab4229. Configure here.

apps/web/src/components/chat/ChatComposer.tsx Β· Earlier revision 9fe4696e Β· right 2731–2732

Original diff
@@ -2681,85 +2696,73 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
     <form
       ref={composerFormRef}
       onSubmit={submitComposer}
+      onFocusCapture={(event) => {
+        const activeElement = event.target;
+        if (
+          isComposerCollapsedMobile &&
+          activeElement instanceof HTMLElement &&
+          activeElement.closest('[data-chat-composer-collapsed-controls="true"]')
+        ) {
+          return;
+        }
+        if (composerBlurFrameRef.current !== null) {
+          window.cancelAnimationFrame(composerBlurFrameRef.current);
+          composerBlurFrameRef.current = null;
+        }
+        setIsComposerFocused(true);
+      }}
+      onBlurCapture={() => {
+        scheduleComposerCollapseCheck();
+      }}
+      onDragEnterCapture={composerMentionDragHandlers.onDragEnter}
+      onDragOverCapture={composerMentionDragHandlers.onDragOver}
+      onDragLeaveCapture={onComposerMentionDragLeaveCapture}
+      onDropCapture={composerMentionDragHandlers.onDrop}
       className="mx-auto w-full min-w-0 max-w-3xl"
       data-chat-composer-form="true"
     >
-      <div
-        className={cn(
-          "group rounded-[22px] p-px transition-colors duration-200",
-          composerProviderState.composerFrameClassName,
-        )}
-        onDragEnterCapture={composerMentionDragHandlers.onDragEnter}
-        onDragOverCapture={composerMentionDragHandlers.onDragOver}
-        onDragLeaveCapture={onComposerMentionDragLeaveCapture}
-        onDropCapture={composerMentionDragHandlers.onDrop}
-      >
+      {showComposerTopDrawer ? (
         <div
-          ref={composerSurfaceRef}
-          data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"}
-          className={cn(
-            "rounded-[20px] transition-[background-color] duration-200",
-            isDragOverComposer ? "bg-accent/45 ring-1 ring-primary/70" : null,
-            projectSelectionRequired ? "opacity-75" : null,
-            composerProviderState.composerSurfaceClassName,
-          )}
-          onFocusCapture={(event) => {
-            const activeElement = event.target;
-            if (
-              isComposerCollapsedMobile &&
-              activeElement instanceof HTMLElement &&
-              activeElement.closest('[data-chat-composer-collapsed-controls="true"]')
-            ) {
-              return;
-            }
-            if (composerBlurFrameRef.current !== null) {
-              window.cancelAnimationFrame(composerBlurFrameRef.current);
-              composerBlurFrameRef.current = null;
-            }
-            setIsComposerFocused(true);
-          }}
-          onBlurCapture={() => {
-            scheduleComposerCollapseCheck();
-          }}
+          className="chat-composer-top-drawer"
+          data-chat-composer-top-drawer="true"
+          data-variant={activePendingApproval ? "warning" : "info"}
         >
-          {!isComposerCollapsedMobile &&
-            (activePendingApproval ? (
-              <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
-                <ComposerPendingApprovalPanel
-                  approval={activePendingApproval}
-                  pendingCount={pendingApprovals.length}
-                />
-              </div>
-            ) : pendingUserInputs.length > 0 ? (
-              <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
-                <ComposerPendingUserInputPanel
-                  pendingUserInputs={pendingUserInputs}
-                  respondingRequestIds={respondingRequestIds}
-                  answers={activePendingDraftAnswers}
-                  questionIndex={activePendingQuestionIndex}
-                  onToggleOption={onSelectActivePendingUserInputOption}
-                  onAdvance={onAdvanceActivePendingUserInput}
-                />
-              </div>
-            ) : showPlanFollowUpPrompt && activeProposedPlan ? (
-              <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
-                <ComposerPlanFollowUpBanner
-                  key={activeProposedPlan.id}
-                  planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
+          {!isComposerCollapsedMobile && activePendingApproval ? (
+            <div className="flex min-w-0 flex-wrap items-center gap-1 px-3 py-1.5">

apps/web/src/components/chat/ChatComposer.tsx Β· right 2731–2732

M
macroscopeappBotAug 19, 2026

chat/ChatComposer.tsx:2731

This row is the only body in the top drawer that stops at px-3; every sibling state uses the composer's px-3 sm:px-4 column β€” the plan banner (ComposerPlanFollowUpBanner, px-3 py-2 sm:px-4), the collapsed approval branch just below (line 2763, px-3 pt-2 sm:px-4), and the pending-input panel (px-1 sm:px-2 header + px-3 sm:px-4 panel). So from 640px up the approval command sits 4px closer to the drawer edge than whatever replaces it, and the content shifts sideways when the drawer switches state.

Suggest matching the shared column:

suggestion
1 <div className="flex min-w-0 flex-wrap items-center gap-1 px-3 py-1.5 sm:px-4">

No longer relevant as of f5bd5efd9a28cf7025073c6c9e76bf24aeec678d

M
mariaAug 19, 2026

Fixed by matching the drawer content column with sm:px-4.

apps/web/src/components/ChatView.tsx Β· Earlier revision d070c592 Β· right 6419

Original diff
@@ -6398,10 +6405,18 @@ function ChatViewContent(props: ChatViewProps) {
                           activeProjectTitle={activeProject?.title ?? null}
                         />
                       </div>
-                      <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
+                      <ComposerBannerStack
+                        attached={composerBannerAttached}
+                        className="relative z-0"
+                        items={composerBannerItems}
+                      />
                     </div>
                   ) : (
-                    <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
+                    <ComposerBannerStack
+                      attached={composerBannerAttached}
+                      className="relative z-0"
+                      items={composerBannerItems}
+                    />
C
cursorBotAug 16, 2026

Sync drawer stacks on top drawer

Medium Severity

composerBannerAttached already treats sync and composer-owned top state as competing for the same attachment seam, but ThreadSyncStatusPill still renders whenever sync is active. During syncing with a pending approval, user-input, or plan drawer, both the sync pill and chat-composer-top-drawer apply chat-composer-drawer-slot overlap, so two attached drawers stack on the composer and fight for the same inset seam.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit d070c59252c99f7ed54e42f5ee4ea7c81fa27bcb. Configure here.

apps/web/src/components/chat/ThreadSyncStatusPill.tsx Β· Earlier revision e70b6689 Β· right 22–22

Original diff
@@ -1,14 +1,27 @@
 import { LoaderCircleIcon } from "lucide-react";
 
 import { threadSyncLabel, type ThreadSyncPhase } from "../../threadSync";
+import { cn } from "~/lib/utils";
 
-export function ThreadSyncStatusPill({ phase }: { readonly phase: ThreadSyncPhase }) {
+export function ThreadSyncStatusPill({
+  attached = true,
+  phase,
+}: {
+  readonly attached?: boolean;
+  readonly phase: ThreadSyncPhase;
+}) {
   const label = threadSyncLabel(phase);
 
   return (
     <div
       aria-label={label}
-      className="pointer-events-none mx-auto mb-2 flex w-fit max-w-full items-center gap-2 rounded-full border border-border/60 bg-card/95 px-3 py-1.5 text-foreground text-xs font-medium shadow-sm"
+      className={cn(
+        "pointer-events-none flex max-w-[calc(48rem-2.75rem)] items-center gap-2 px-3 text-foreground text-xs font-medium",
+        attached
+          ? "chat-composer-drawer-surface chat-composer-drawer-attached chat-composer-drawer-slot pt-2"
+          : "alert-glass mx-auto mb-2 w-[calc(100%-2.75rem)] rounded-[22px] py-2",

apps/web/src/components/chat/ThreadSyncStatusPill.tsx Β· right 22–22

M
macroscopeappBotAug 16, 2026

chat/ThreadSyncStatusPill.tsx:22

The detached pill loses its edge: alert-glass only supplies a translucent --background fill plus a backdrop blur, and this branch drops the previous border border-border/60 and shadow-sm with no replacement. Over the chat canvas (also --background) the bar ends up with no visible boundary, and it is inconsistent with the sibling detached surface β€” ComposerBannerStack's detached branch is an Alert, whose base border (colored by the * { border-border } base rule) keeps its outline under the same alert-glass rounded-[22px] treatment.

Suggest restoring the border/shadow on the detached branch only; the attached branch draws its own outline through chat-composer-drawer-surface::before.

suggestion
1 : "alert-glass mx-auto mb-2 w-[calc(100%-2.75rem)] rounded-[22px] border border-border/60 py-2 shadow-sm",

No longer relevant as of e47d5a4d35b18c2e62e23e26c2f4806da4030b39

apps/web/src/components/chat/ChatComposer.tsx Β· File comment

M
macroscopeappBotAug 16, 2026

🟑 Medium

https://github.com/pingdotgg/t3code/blob/fc5c94b7ca26c3defda146fbb7bbe61d5db79e00/apps/web/src/components/chat/ChatComposer.tsx#L2691

File-tree mentions are not inserted when dropped over the top drawer in plan follow-up state, because the drawer is outside the element that receives composerMentionDragHandlers. The drawer still appears to be part of the composer, so attach the drag handlers to a wrapper that contains both the drawer and main surface.

βœ… Resolved in deb67121f12bddcbe8e8a6e48a776981b513fca7

apps/web/src/components/chat/ComposerStashBadge.tsx Β· Earlier revision fc5c94b7 Β· right 27

Original diff
@@ -25,29 +24,29 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {
       type="button"
       data-prompt-stash-badge="true"
       aria-label={`Stashed prompts: ${props.count}. Open stash.`}
-      aria-expanded={props.menuOpen}
+      aria-expanded="false"
M
macroscopeappBotAug 16, 2026

🟑 Medium chat/ComposerStashBadge.tsx:27

aria-expanded="false" remains false after onToggleMenu opens the stash menu, so assistive technologies report the visible popup as collapsed. Pass the actual isStashMenuOpen state through ComposerStashBadge (replacing the removed menuOpen prop) and use it for aria-expanded.

βœ… Resolved in 5cc14855f9c5202274881bc5315e8e6b82120513

apps/web/src/components/chat/ChatComposer.tsx Β· Earlier revision f578cd18 Β· right 2704–2704

Original diff
@@ -2684,82 +2694,48 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
       className="mx-auto w-full min-w-0 max-w-3xl"
       data-chat-composer-form="true"
     >
-      <div
-        className={cn(
-          "group rounded-[22px] p-px transition-colors duration-200",
-          composerProviderState.composerFrameClassName,
-        )}
-        onDragEnterCapture={composerMentionDragHandlers.onDragEnter}
-        onDragOverCapture={composerMentionDragHandlers.onDragOver}
-        onDragLeaveCapture={onComposerMentionDragLeaveCapture}
-        onDropCapture={composerMentionDragHandlers.onDrop}
-      >
+      {showComposerTopDrawer ? (
         <div
-          ref={composerSurfaceRef}
-          data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"}
-          className={cn(
-            "rounded-[20px] transition-[background-color] duration-200",
-            isDragOverComposer ? "bg-accent/45 ring-1 ring-primary/70" : null,
-            projectSelectionRequired ? "opacity-75" : null,
-            composerProviderState.composerSurfaceClassName,
-          )}
-          onFocusCapture={(event) => {
-            const activeElement = event.target;
-            if (
-              isComposerCollapsedMobile &&
-              activeElement instanceof HTMLElement &&
-              activeElement.closest('[data-chat-composer-collapsed-controls="true"]')
-            ) {
-              return;
-            }
-            if (composerBlurFrameRef.current !== null) {
-              window.cancelAnimationFrame(composerBlurFrameRef.current);
-              composerBlurFrameRef.current = null;
-            }
-            setIsComposerFocused(true);
-          }}
-          onBlurCapture={() => {
-            scheduleComposerCollapseCheck();
-          }}
+          className="chat-composer-top-drawer"
+          data-chat-composer-top-drawer="true"
+          data-variant={activePendingApproval ? "warning" : "info"}
         >
-          {!isComposerCollapsedMobile &&
-            (activePendingApproval ? (
-              <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
-                <ComposerPendingApprovalPanel
-                  approval={activePendingApproval}
-                  pendingCount={pendingApprovals.length}
-                />
-              </div>
-            ) : pendingUserInputs.length > 0 ? (
-              <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
-                <ComposerPendingUserInputPanel
-                  pendingUserInputs={pendingUserInputs}
-                  respondingRequestIds={respondingRequestIds}
-                  answers={activePendingDraftAnswers}
-                  questionIndex={activePendingQuestionIndex}
-                  onToggleOption={onSelectActivePendingUserInputOption}
-                  onAdvance={onAdvanceActivePendingUserInput}
-                />
-              </div>
-            ) : showPlanFollowUpPrompt && activeProposedPlan ? (
-              <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
-                <ComposerPlanFollowUpBanner
-                  key={activeProposedPlan.id}
-                  planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
+          {!isComposerCollapsedMobile && activePendingApproval ? (
+            <div className="flex min-w-0 items-center gap-1 px-3 py-1.5">

apps/web/src/components/chat/ChatComposer.tsx Β· right 2704–2704

M
macroscopeappBotAug 16, 2026

chat/ChatComposer.tsx:2704

The approval actions used to sit in their own flex flex-wrap footer row; here they are a shrink-0 sibling on a single line, and .chat-composer-top-drawer has no overflow clip. The four buttons now min out around 310px (Always allow this session alone is ~145px at text-[11px]), while the drawer is only composer width - 2.75rem wide, so once the composer narrows (right panel plus terminal open β€” the footer's own compact breakpoint is 620px) the buttons spill past the drawer's rounded edge instead of wrapping. Adding flex-wrap lets the button group drop to a second line once the command column is squeezed out.

suggestion
1 <div className="flex min-w-0 flex-wrap items-center gap-1 px-3 py-1.5">

No longer relevant as of deb67121f12bddcbe8e8a6e48a776981b513fca7

apps/web/src/index.css Β· Earlier revision 898187fe Β· right 1493

Original diff
@@ -1255,6 +1479,26 @@ html[data-theme-id] .chat-composer-glass-shell {
   }
 }
 
+html[data-theme-id] :is(.chat-composer-drawer-surface, .chat-composer-top-drawer) {
+  --chat-composer-attached-surface: var(--app-theme-surface-raised);
+}
+
+html[data-theme-id]
+  :is(.chat-composer-drawer-surface, .chat-composer-top-drawer):is(
+    :not([data-variant]),
+    [data-variant="default"]
+  ) {
+  --chat-composer-attached-outline: var(--app-theme-toolbar-border);
+
+  @variant dark {
M
macroscopeappBotAug 16, 2026

🟑 Medium src/index.css:1493

In dark t3-chat, attached default drawers render a generic color-mix(...) outline while the main composer edge renders #241e28, so the seam has mismatched outlines. The dark @variant here overrides the inherited --chat-composer-outline; extend the T3-specific override to attached drawers or derive this value from --chat-composer-outline.

No longer relevant as of 2843d45cb0b2f48f8466f4e520db49e927f17838

apps/web/src/components/chat/ComposerCommandMenu.tsx Β· Earlier revision f578cd18 Β· right 155–155

Original diff
@@ -234,22 +151,11 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
           theme={props.resolvedTheme}
         />
       ) : null}
-      {props.item.type === "slash-command" ? (
-        <BotIcon className="size-4 shrink-0 text-icon-muted" />
-      ) : null}
-      {props.item.type === "provider-slash-command" ? (
-        <span className="inline-flex size-4 shrink-0 items-center justify-center text-icon-muted">
-          <SkillGlyph className="size-3.5" />
+      <span className="flex min-w-0 flex-1 items-baseline gap-3">
+        <span className="shrink-0 font-sans text-xs font-medium text-foreground">

apps/web/src/components/chat/ComposerCommandMenu.tsx Β· right 155–155

M
macroscopeappBotAug 16, 2026

chat/ComposerCommandMenu.tsx:155

Hardcoding text-foreground on the label breaks the highlighted row's color pairing: the row sets bg-accent! text-accent-foreground! (line 135), but a child's own color declaration wins over the inherited one, so the label keeps --foreground on an --accent background. --accent-foreground is contrast-solved against --accent per theme (themePalette.ts readableThemeForeground / updateThemeColorFamily("accentSurface") can return pure white/black while text stays the other polarity), so a custom palette can render this label near-unreadable when highlighted. The description span next to it already carries its own muted tone, so the label can simply inherit.

suggestion
1 <span className="shrink-0 font-sans text-xs font-medium">

No longer relevant as of deb67121f12bddcbe8e8a6e48a776981b513fca7

apps/web/src/components/chat/ChatComposer.tsx Β· Earlier revision f7134054 Β· right 1171

Original diff
@@ -1143,13 +1159,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
 
   const isComposerApprovalState = activePendingApproval !== null;
   const activePendingUserInput = pendingUserInputs[0] ?? null;
-  const hasComposerHeader =
+  const showComposerTopDrawer =
     isComposerApprovalState ||
     pendingUserInputs.length > 0 ||
-    (showPlanFollowUpPrompt && activeProposedPlan !== null);
+    (!isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan !== null);
   const showCollapsedMobilePromptRow =
     isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0;
 
+  useEffect(() => {
+    onTopDrawerVisibilityChange?.(showComposerTopDrawer);
+  }, [onTopDrawerVisibilityChange, showComposerTopDrawer]);
C
cursorBotAug 16, 2026

Plan drawer attachment state lags

Medium Severity

Plan follow-up ownership of the composer attachment seam depends on composerTopDrawerVisible, which is updated from ChatComposer in a passive useEffect. Parent ComposerBannerStack / ThreadSyncStatusPill attached flags therefore lag the actual plan drawer by at least one paint, so banners and sync can briefly fight the plan drawer for the seam (or stay detached after the drawer collapses).

Additional Locations (2)

Reviewed by Cursor Bugbot for commit f713405400c94e8e5e4258e0a60e6b8ba6bc2c3b. Configure here.

apps/web/src/index.css Β· Earlier revision 577902c5 Β· right 1446–1495

Original diff
@@ -1255,6 +1443,19 @@ html[data-theme-id] .chat-composer-glass-shell {
   }
 }
 
+html[data-theme-id] :is(.chat-composer-drawer-surface, .chat-composer-top-drawer) {

apps/web/src/index.css Β· right 1446–1495

M
macroscopeappBotAug 16, 2026

🟑 Medium src/index.css:1446

Warning chat-composer-top-drawer[data-variant="warning"] elements always receive --chat-composer-attached-outline from this theme rule, so their warning-colored border is replaced with the ordinary themed outline whenever data-theme-id is present. Exclude the warning variant from this selector (or explicitly override it) so warning drawers retain their warning border.

No longer relevant as of c2a57120390624a31b144abf77928ed0f450df1b