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/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-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. */ 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' +) 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) +}