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' +)