From 9b01c74f000ee00d7fc22ef7e4231f10bd462691 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 19:20:00 -0500 Subject: [PATCH] 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 000000000000..a8da94f04116 --- /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 c6b9af53b737..b4655ffd50cb 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 74f9a7459f65..633d70fc7798 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 000000000000..9493bff12596 --- /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 41c84bfdaace..41e4b1d35f8c 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 ca02cdea8d6d..59084ca764c7 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' +)