From 1cefad55230b511cbbe6a05aaaa46e5a4932195e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 19:19:52 -0500 Subject: [PATCH 1/3] feat(desktop): composer.microActions + composer.underside contribution areas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new seams on the composer, both through the existing contribution registry: a data area whose providers resolve badge descriptors per session, and a render area for a chrome-free strip below the surface. Core registers nothing in either — they stay empty until something contributes. Providers resolve from live session context rather than registering static entries, so a contribution can be conditional without the reactive when() the registry deliberately doesn't offer. --- apps/desktop/src/app/chat/composer/contrib.ts | 62 +++++++++++++++-- apps/desktop/src/store/composer-actions.ts | 69 +++++++++++++++++++ 2 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/store/composer-actions.ts diff --git a/apps/desktop/src/app/chat/composer/contrib.ts b/apps/desktop/src/app/chat/composer/contrib.ts index 893f5174c20..b7cba9d89e6 100644 --- a/apps/desktop/src/app/chat/composer/contrib.ts +++ b/apps/desktop/src/app/chat/composer/contrib.ts @@ -3,13 +3,16 @@ * through the SAME registry schema as every other surface (statusbar, titlebar, * panes, layouts): * - * render areas (`render`): composer.top — banner strip above the input - * composer.bottom — row below the input grid - * composer.leading — inline after the "+" menu - * composer.actions — inline before the model pill + * render areas (`render`): composer.top — banner strip above the input + * composer.bottom — row below the input grid + * composer.underside — floating strip BELOW the + * whole composer (no chrome) + * composer.leading — inline after the "+" menu + * composer.actions — inline before the model pill * - * data kinds (`data`): composer.middleware (ComposerMiddleware) - * composer.attachments (ComposerAttachmentProvider) + * data kinds (`data`): composer.middleware (ComposerMiddleware) + * composer.attachments (ComposerAttachmentProvider) + * composer.microActions (ComposerMicroActionProvider) * * Core keeps ownership of the transcript, input, and submit engine — these * seams AUGMENT the composer, they never replace it. Middleware runs as an @@ -17,17 +20,23 @@ * draft, pass it through, or cancel the send by returning null. */ +import { useMemo } from 'react' + import { useContributions } from '@/contrib/react/use-contributions' import { registry } from '@/contrib/registry' +import type { TodoItem } from '@/lib/todos' import type { ComposerAttachment } from '@/store/composer' +import type { ComposerAction } from '@/store/composer-actions' export const COMPOSER_AREAS = { top: 'composer.top', bottom: 'composer.bottom', + underside: 'composer.underside', leading: 'composer.leading', actions: 'composer.actions', middleware: 'composer.middleware', - attachments: 'composer.attachments' + attachments: 'composer.attachments', + microActions: 'composer.microActions' } as const export interface ComposerDraft { @@ -92,3 +101,42 @@ export function useComposerAttachmentProviders(): Array ({ key: `${c.source ?? 'core'}:${c.id}`, ...(c.data as ComposerAttachmentProvider) })) .filter(p => Boolean(p.label && p.run)) } + +/** + * Payload of a `composer.microActions` data contribution — the pill strip at + * the top of the composer's overlay lane. + * + * `resolve` is called with the live session context and returns the badges to + * show right now, or `[]` for "nothing from me". Returning a list rather than + * a static badge is what lets a provider be conditional ("only while idle", + * "only with unfinished tasks") without a reactive `when()`, which the + * registry deliberately doesn't offer. + */ +export interface ComposerMicroActionProvider { + resolve: (ctx: ComposerMicroActionContext) => ComposerAction[] +} + +/** What a micro-action provider gets to branch on. Deliberately small: every + * field here is a standing compatibility promise to the plugins using it. */ +export interface ComposerMicroActionContext { + /** A turn is currently running in this session. */ + busy: boolean + sessionId: string + /** Live todo list for the session (empty when there is none). */ + todos: readonly TodoItem[] +} + +/** Micro-action providers, memoised against the registry's own stable + * snapshot — the strip re-resolves on every composer render, so a fresh array + * here would defeat that. */ +export function useComposerMicroActionProviders(): ComposerMicroActionProvider[] { + const contributions = useContributions(COMPOSER_AREAS.microActions) + + return useMemo( + () => + contributions + .map(c => c.data as ComposerMicroActionProvider) + .filter(p => typeof p?.resolve === 'function'), + [contributions] + ) +} diff --git a/apps/desktop/src/store/composer-actions.ts b/apps/desktop/src/store/composer-actions.ts new file mode 100644 index 00000000000..b181a45f3be --- /dev/null +++ b/apps/desktop/src/store/composer-actions.ts @@ -0,0 +1,69 @@ +import { atom } from 'nanostores' + +/** + * Composer micro actions — the floating pill strip at the top of the composer's + * overlay lane. + * + * A badge is a label and a `run`; what it does is entirely the registrar's + * business. Core ships none — the strip is a seam, filled by contributions to + * the `composer.microActions` area (see `composer/contrib.ts`). + * + * Session-scoped like every other stack feed: keyed by RUNTIME session id, and + * written immutably per key so `useSessionSlice` bails out on other sessions' + * churn. + */ +export interface ComposerAction { + /** Stable within a session; also the render key. */ + id: string + label: string + /** Codicon name rendered before the label. */ + icon?: string + disabled?: boolean + /** What the badge does. Anything: submit a prompt, open a pane, call the + * gateway. Receives the session it was registered for. */ + run: (sessionId: string) => Promise | void +} + +export const $composerActionsBySession = atom>({}) + +/** + * Two badge sets that would render identically. + * + * `run` is excluded on purpose: it's a fresh closure on every resolve, so + * including it would make every comparison false and defeat the bail-out + * below. The invariant that buys: a provider must not change what `run` DOES + * without also changing a visible field. + */ +const same = (a: readonly ComposerAction[], b: readonly ComposerAction[]) => + a.length === b.length && + a.every((x, i) => { + const y = b[i]! + + return x.id === y.id && x.label === y.label && x.icon === y.icon && x.disabled === y.disabled + }) + +/** + * Publish this session's badge set. + * + * A no-op resolve must not write: providers are re-resolved on every composer + * render, and an equal-but-fresh array would re-render the stack on every + * keystroke. An unchanged set keeps its previous reference, so the store stays + * quiet. + */ +export function setComposerActions(sid: string, actions: ComposerAction[]) { + const current = $composerActionsBySession.get() + + if (!sid || (current[sid] ? same(current[sid], actions) : actions.length === 0)) { + return + } + + const next = { ...current } + + if (actions.length > 0) { + next[sid] = actions + } else { + delete next[sid] + } + + $composerActionsBySession.set(next) +} From 9b01c74f000ee00d7fc22ef7e4231f10bd462691 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 19:20:00 -0500 Subject: [PATCH 2/3] feat(desktop): render the micro-action and underside strips Pills pin to the top of the composer's overlay lane, outside the status card and outside its scroller, so nothing stacks above them and a long todo list can't scroll them away. The underside slot sits below the surface. Both share one grid constant and one parent, so their left edges match by construction rather than by matching numbers in two files. The strips take pointer events while their empty space falls through to the pop-out drag region, which keeps the composer draggable by the band its badges live in. --- .../chat/composer/hooks/use-micro-actions.ts | 47 +++++++++++ .../composer/hooks/use-status-presence.ts | 38 +++++---- apps/desktop/src/app/chat/composer/index.tsx | 27 ++++++- .../composer/status-stack/action-badges.tsx | 78 +++++++++++++++++++ .../app/chat/composer/status-stack/index.tsx | 75 +++++++++++++----- .../src/components/chat/composer-dock.ts | 29 +++++++ 6 files changed, 256 insertions(+), 38 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-micro-actions.ts create mode 100644 apps/desktop/src/app/chat/composer/status-stack/action-badges.tsx diff --git a/apps/desktop/src/app/chat/composer/hooks/use-micro-actions.ts b/apps/desktop/src/app/chat/composer/hooks/use-micro-actions.ts new file mode 100644 index 00000000000..a8da94f0411 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-micro-actions.ts @@ -0,0 +1,47 @@ +import { useEffect } from 'react' + +import { useSessionSlice } from '@/lib/use-session-slice' +import { setComposerActions } from '@/store/composer-actions' +import { $todosBySession } from '@/store/todos' + +import { type ComposerMicroActionContext, useComposerMicroActionProviders } from '../contrib' + +/** + * Resolve every registered micro-action provider for this session and publish + * the result to `$composerActionsBySession`, which the pill strip renders. + * + * Core registers nothing, so the strip stays empty until something contributes + * to `composer.microActions`. Providers are pure functions of the session + * context and the set is recomputed rather than mutated, so there are no + * ordering games between registrars and a provider that stops returning a + * badge withdraws it. One that throws is skipped, so a broken plugin loses + * only its own badge. + */ +export function useComposerMicroActions(sessionId: null | string, busy: boolean) { + const todos = useSessionSlice($todosBySession, sessionId) + const providers = useComposerMicroActionProviders() + + useEffect(() => { + if (!sessionId) { + return + } + + const ctx: ComposerMicroActionContext = { busy, sessionId, todos } + + setComposerActions( + sessionId, + providers.flatMap(provider => { + try { + return provider.resolve(ctx) ?? [] + } catch { + return [] + } + }) + ) + }, [busy, providers, sessionId, todos]) + + // Withdraw on unmount / session switch ONLY. Clearing in the resolve effect's + // cleanup would publish an empty set before every republish — two store + // writes and two stack re-renders for what is usually a no-op. + useEffect(() => (sessionId ? () => setComposerActions(sessionId, []) : undefined), [sessionId]) +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-status-presence.ts b/apps/desktop/src/app/chat/composer/hooks/use-status-presence.ts index c6b9af53b73..b4655ffd50c 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-status-presence.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-status-presence.ts @@ -1,26 +1,37 @@ import { useSyncExternalStore } from 'react' +import { $composerActionsBySession } from '@/store/composer-actions' import { $statusItemsBySession } from '@/store/composer-status' import { $previewStatusBySession } from '@/store/preview-status' +/** Structural view of the three per-session feeds — they hold different item + * types, and all this hook needs from each is "does this key have rows". */ +interface PresenceFeed { + get(): Record + listen(listener: () => void): () => void +} + +const FEEDS: PresenceFeed[] = [$statusItemsBySession, $composerActionsBySession, $previewStatusBySession] + const subscribe = (onChange: () => void) => { - const offItems = $statusItemsBySession.listen(onChange) - const offPreviews = $previewStatusBySession.listen(onChange) + const offs = FEEDS.map(feed => feed.listen(onChange)) return () => { - offItems() - offPreviews() + for (const off of offs) { + off() + } } } /** - * Whether a session has any status items or previews, as a coarse *edge*: the - * boolean only flips when the stack appears/disappears. ChatBar uses it to - * toggle a styling data-attr — subscribing to the whole `$statusItemsBySession` - * (a `computed` that rebuilds the entire map) / `$previewStatusBySession` maps - * re-rendered the ~1.4k ChatBar on every per-item mutation (a subagent tick, a - * 5s background poll) and on churn in OTHER sessions. The boolean snapshot bails - * out of all of that, re-rendering only on the actual show/hide transition. + * Whether a session has any status items, micro actions, or previews, as a + * coarse *edge*: the boolean only flips when the stack appears/disappears. + * ChatBar uses it to toggle a styling data-attr — subscribing to the whole + * `$statusItemsBySession` (a `computed` that rebuilds the entire map) / + * `$previewStatusBySession` maps re-rendered the ~1.4k ChatBar on every + * per-item mutation (a subagent tick, a 5s background poll) and on churn in + * OTHER sessions. The boolean snapshot bails out of all of that, re-rendering + * only on the actual show/hide transition. */ export function useSessionStatusPresence(sessionId: string | null): boolean { return useSyncExternalStore(subscribe, () => { @@ -28,9 +39,6 @@ export function useSessionStatusPresence(sessionId: string | null): boolean { return false } - return ( - ($statusItemsBySession.get()[sessionId]?.length ?? 0) > 0 || - ($previewStatusBySession.get()[sessionId]?.length ?? 0) > 0 - ) + return FEEDS.some(feed => (feed.get()[sessionId]?.length ?? 0) > 0) }) } diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 74f9a7459f6..633d70fc779 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -2,7 +2,7 @@ import { ComposerPrimitive } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useMemo, useRef } from 'react' -import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock' +import { composerFill, composerFloatingStrip, composerSurfaceGlass } from '@/components/chat/composer-dock' import { Button } from '@/components/ui/button' import { Slot as ContribSlot } from '@/contrib/react/slot' import { useI18n } from '@/i18n' @@ -48,6 +48,7 @@ import { useComposerTrigger } from './hooks/use-composer-trigger' import { useComposerUndo } from './hooks/use-composer-undo' import { useComposerUrlDialog } from './hooks/use-composer-url-dialog' import { useComposerVoice } from './hooks/use-composer-voice' +import { useComposerMicroActions } from './hooks/use-micro-actions' import { useSlashCompletions } from './hooks/use-slash-completions' import { useSessionStatusPresence } from './hooks/use-status-presence' import { chipTypedPathOnSpace, pathifyRefs } from './path-refs' @@ -133,6 +134,10 @@ export function ChatBar({ // every per-item status mutation or other sessions' churn (see the hook). const statusPresent = useSessionStatusPresence(statusSessionId) + // Publishes contributed micro actions for this session; the status stack + // renders them as the pill strip at the top of the overlay lane. + useComposerMicroActions(statusSessionId, busy) + const composerRef = useRef(null) const composerSurfaceRef = useRef(null) @@ -1076,7 +1081,14 @@ export function ChatBar({ className={cn('pointer-events-auto absolute inset-0', dragging ? 'cursor-grabbing' : 'cursor-grab')} data-dragging={dragging ? '' : undefined} data-slot="composer-drag-region" - onDoubleClick={handleComposerToggle} + onDoubleClick={event => { + // The pill strips paint above this region; a double-click that + // lands on one must not float the composer. onPointerDown goes + // through gestureTargetOk, but this handler doesn't. + if (!(event.target as Element).closest('[data-slot="composer-no-drag"]')) { + handleComposerToggle() + } + }} /> )}
@@ -1168,6 +1180,17 @@ export function ChatBar({
+ {/* Underside: a floating strip BELOW the whole composer surface. + Chrome-free by design — contributions bring their own pill/skin, + like the micro-action strip above. In flow (the root is + bottom-anchored, so this grows the composer upward and stays on + screen) but OUTSIDE the surface, so it escapes the surface's + clipping, border, and scroll fade. Shares the micro-action + strip's grid so the two bracket the composer on one vertical + line. Renders nothing until something contributes. */} +
+ +
diff --git a/apps/desktop/src/app/chat/composer/status-stack/action-badges.tsx b/apps/desktop/src/app/chat/composer/status-stack/action-badges.tsx new file mode 100644 index 00000000000..9493bff1259 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/status-stack/action-badges.tsx @@ -0,0 +1,78 @@ +import { memo, useState } from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { cn } from '@/lib/utils' +import type { ComposerAction } from '@/store/composer-actions' +import { notifyError } from '@/store/notifications' + +/** + * Floating pill — the treatment the thread's jump/approval button uses for a + * control that sits over scrolling content: full radius, hairline border, the + * shared composer fill behind a blur so thread text never bleeds through. + * Sized against the composer's own control height so a row of pills lines up + * with the chrome it floats above. + * + * NEVER `pointer-events-none`, not even when disabled. The pop-out drag region + * is an `absolute` sibling behind these pills, so a pill that stops taking + * pointer events hands the hit test straight to it — and a dead-looking badge + * becomes a grab handle that floats the composer. + */ +const PILL = cn( + 'inline-flex h-(--composer-control-size) max-w-56 shrink-0 cursor-pointer items-center gap-1.5 rounded-full px-2.5', + 'border border-border/65 bg-(--composer-fill) backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]', + 'text-xs font-normal text-(--ui-text-secondary) transition-colors', + 'hover:bg-(--chrome-action-hover) hover:text-foreground', + 'disabled:cursor-default disabled:opacity-50 disabled:hover:bg-(--composer-fill)', + 'focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50' +) + +/** + * The micro-action pills. Layout-free on purpose — the composer owns the strip + * (`composerFloatingStrip`), this owns only the pills, so the strip above the + * surface and the `composer.underside` strip below it can't drift apart. + */ +export const ActionBadges = memo(function ActionBadges({ + actions, + sessionId +}: { + actions: ComposerAction[] + sessionId: string +}) { + // A pill can kick off async work (a gateway call, a submit). Track which one + // is in flight so it can spin and lock instead of double-firing. + const [runningId, setRunningId] = useState(null) + + const run = async (action: ComposerAction) => { + if (runningId) { + return + } + + setRunningId(action.id) + + try { + await action.run(sessionId) + } catch (error) { + notifyError(error, action.label) + } finally { + setRunningId(null) + } + } + + return actions.map(action => { + const running = runningId === action.id + const glyph = running ? 'loading' : action.icon + + return ( + + ) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx index 41c84bfdaac..41e4b1d35f8 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -6,7 +6,7 @@ import { blurComposerInput } from '@/app/chat/composer/focus' import { chatSurfaceRoot, clearSurfaceVar, setSurfaceVar, STATUS_STACK_VAR } from '@/app/chat/surface-vars' import { AGENTS_ROUTE } from '@/app/routes' import { BillingBanner } from '@/components/billing-banner' -import { composerDockCard } from '@/components/chat/composer-dock' +import { composerDockCard, composerFloatingStrip } from '@/components/chat/composer-dock' import { StatusSection } from '@/components/chat/status-section' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' @@ -15,6 +15,7 @@ import { type Translations, useI18n } from '@/i18n' import { useSessionSlice } from '@/lib/use-session-slice' import { cn } from '@/lib/utils' import { $billingBlock } from '@/store/billing-block' +import { $composerActionsBySession } from '@/store/composer-actions' import { $statusItemsBySession, type ComposerStatusItem, @@ -29,6 +30,7 @@ import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview import { $threadScrolledUp } from '@/store/thread-scroll' import { openSessionInNewWindow } from '@/store/windows' +import { ActionBadges } from './action-badges' import { PreviewStatusRow } from './preview-row' import { StatusItemRow } from './status-row' @@ -93,6 +95,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro // items actually changed. const items = useSessionSlice($statusItemsBySession, sessionId) const previews = useSessionSlice($previewStatusBySession, sessionId) + const actions = useSessionSlice($composerActionsBySession, sessionId) const scrolledUp = useStore($threadScrolledUp) const billing = useStore($billingBlock) @@ -151,6 +154,10 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro sections.push({ key: 'billing', node: }) } + // Micro actions ride at the top of the stack — the one block you press + // rather than read. Rendered OUTSIDE the card (see `actionStrip`) so the + // pills float; a blocked account still gets the billing wall above them. + for (const group of groups) { sections.push({ key: group.type, @@ -208,7 +215,13 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro sections.push({ key: 'queue', node: queue }) } - const visible = sections.length > 0 + // Micro actions are the TOP-MOST thing in the whole overlay lane — above the + // status card, above the billing wall, above everything. They're the only + // rows up here you press instead of read, so nothing may ever stack on top + // of them. Rendered outside the card (below) so the pills float. + const actionStrip = actions.length > 0 && sessionId ? : null + + const visible = sections.length > 0 || Boolean(actionStrip) const stackRef = useRef(null) // The stack is out of flow (overlays the thread), so the composer's measured @@ -260,29 +273,49 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro // Sits in the overlay lane above the composer. The composer root has pt-2 // before the actual surface; translate by that amount so the stack returns // to its original attachment point without intruding into the repo strip. - className="absolute inset-x-0 bottom-full z-3 max-h-[40vh] translate-y-2 overflow-y-auto" + className="absolute inset-x-0 bottom-full z-3 flex max-h-[40vh] flex-col translate-y-2" onPointerDownCapture={() => blurComposerInput()} ref={stackRef} > - {/* The card paints the shared --composer-fill (rest / scrolled / focused - all match the composer surface by construction); on scroll we only - ghost the CONTENT — element opacity on the card would kill the blur. - Rounded top, square bottom; the bottom border is TRANSPARENT — the - composer surface's visible top border (which sits at a higher z) is the - single shared seam, so the two read as one fused capsule. */} -
+ {actionStrip} +
+ )} + {/* Everything else scrolls under them. */} +
+ {/* The card paints the shared --composer-fill (rest / scrolled / focused + all match the composer surface by construction); on scroll we only + ghost the CONTENT — element opacity on the card would kill the blur. + Rounded top, square bottom; the bottom border is TRANSPARENT — the + composer surface's visible top border (which sits at a higher z) is the + single shared seam, so the two read as one fused capsule. */} + {sections.length > 0 && ( +
+ {sections.map(section => ( +
{section.node}
+ ))} +
)} - > - {sections.map(section => ( -
{section.node}
- ))}
) diff --git a/apps/desktop/src/components/chat/composer-dock.ts b/apps/desktop/src/components/chat/composer-dock.ts index ca02cdea8d6..59084ca764c 100644 --- a/apps/desktop/src/components/chat/composer-dock.ts +++ b/apps/desktop/src/components/chat/composer-dock.ts @@ -33,3 +33,32 @@ export const composerPanelCard = cn( 'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]', composerSurfaceGlass ) + +/** + * Shared grid for the chrome-free floating strips that bracket the composer — + * the micro-action pills above the surface and the `composer.underside` slot + * below it. + * + * Both strips are in-flow children of the SAME box (the composer root's + * content box), which is the whole point: they previously lived in different + * parents — the pills inside the status stack's absolute overlay lane, the + * chip in the root — so "no padding" resolved to two different left edges and + * they never lined up. Same parent, no inset, one constant: the left edges are + * identical by construction, not by matching numbers in two places. + * + * `relative z-1` at the call sites is load-bearing, not styling. The pop-out + * drag region is an `absolute` sibling, and positioned elements paint above + * static in-flow ones whatever the DOM order — so without a stacking context + * these strips sit UNDER it and their contents never receive hover or clicks + * (the region does, and hatches). + * + * The strip is full-width so a contribution can push itself to the right + * (`ml-auto`), but it is `pointer-events-none` with its CHILDREN re-enabled: + * the chips are interactive, while the empty space between and beside them + * falls through to the drag region and stays grab area. That combination is + * why the composer is still draggable by the band its badges live in. + */ +export const composerFloatingStrip = cn( + 'relative z-1 flex w-full flex-wrap items-center gap-1.5', + 'pointer-events-none [&>*]:pointer-events-auto' +) From 1e355116a1ff3efffb163ce41218055b19ba4f4c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 19:20:07 -0500 Subject: [PATCH 3/3] fix(desktop): keep composer chrome out of the pop-out gesture The pill strips live inside the composer root, so their box sits within the drag region and their gaps read as grab area. Exclude anything marked composer-no-drag, matching how buttons and menu items are already excluded. --- .../src/app/chat/composer/hooks/use-popout-drag.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts index e4a53889e5b..4af64604043 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts @@ -49,7 +49,15 @@ function gestureTargetOk(target: EventTarget | null) { return false } - return !target.closest('button, a, input, textarea, select, [role="menuitem"], [data-radix-popper-content-wrapper]') + // `composer-no-drag`: chrome that lives inside the composer root but isn't + // part of the draggable frame — the floating pill strips. The pills are + // `button`s and already excluded, but the strip's own box (the gaps between + // pills) isn't, so without this a press landing between two badges still + // drags. The strips are `w-fit`, so this costs the grab band only the width + // of the badges themselves. + return !target.closest( + 'button, a, input, textarea, select, [role="menuitem"], [data-radix-popper-content-wrapper], [data-slot="composer-no-drag"]' + ) } /** Floating composer's 5px outer frame — grab here to drag without long-press. */