mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Merge pull request #73711 from NousResearch/bb/composer-micro-actions
Composer contribution seams: micro-action pills and an underside strip
This commit is contained in:
commit
ded2314910
9 changed files with 389 additions and 46 deletions
|
|
@ -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<ComposerAttachmentProvid
|
|||
.map(c => ({ 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]
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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<string, undefined | unknown[]>
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HTMLFormElement | null>(null)
|
||||
const composerSurfaceRef = useRef<HTMLDivElement | null>(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()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="relative w-full rounded-[inherit]">
|
||||
|
|
@ -1168,6 +1180,17 @@ export function ChatBar({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<div className={cn(composerFloatingStrip, 'pt-1.5 empty:hidden')} data-slot="composer-no-drag">
|
||||
<ContribSlot area={COMPOSER_AREAS.underside} />
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverRoot>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 | string>(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 (
|
||||
<button
|
||||
className={PILL}
|
||||
disabled={action.disabled || Boolean(runningId)}
|
||||
key={action.id}
|
||||
onClick={() => void run(action)}
|
||||
type="button"
|
||||
>
|
||||
{glyph && <Codicon className="shrink-0 opacity-70" name={glyph} size="0.75rem" spinning={running} />}
|
||||
<span className="truncate">{action.label}</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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: <BillingBanner sessionId={sessionId} /> })
|
||||
}
|
||||
|
||||
// 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 ? <ActionBadges actions={actions} sessionId={sessionId} /> : null
|
||||
|
||||
const visible = sections.length > 0 || Boolean(actionStrip)
|
||||
const stackRef = useRef<HTMLDivElement | null>(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. */}
|
||||
<div
|
||||
className={cn(
|
||||
composerDockCard('top'),
|
||||
// Inset (mx-2) so the stack reads slightly narrower than the composer
|
||||
// surface below it — the original look.
|
||||
'mx-2 overflow-hidden rounded-b-none border-b border-b-transparent pt-0.5',
|
||||
'transition-opacity duration-200 ease-out',
|
||||
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
|
||||
{/* FIRST in the lane and OUTSIDE the scroller, so nothing can ever sit
|
||||
above the pills — not the status card, not the billing wall — and a
|
||||
long todo list can't scroll them out of view. Outside the card too:
|
||||
they carry their own fill, so they must not paint on its background. */}
|
||||
{actionStrip && (
|
||||
<div
|
||||
className={cn(
|
||||
composerFloatingStrip,
|
||||
'shrink-0 pb-1.5 transition-opacity duration-200 ease-out',
|
||||
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
{actionStrip}
|
||||
</div>
|
||||
)}
|
||||
{/* Everything else scrolls under them. */}
|
||||
<div className="min-h-0 overflow-y-auto">
|
||||
{/* 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 && (
|
||||
<div
|
||||
className={cn(
|
||||
composerDockCard('top'),
|
||||
// Inset (mx-2) so the stack reads slightly narrower than the composer
|
||||
// surface below it — the original look.
|
||||
'mx-2 overflow-hidden rounded-b-none border-b border-b-transparent pt-0.5',
|
||||
'transition-opacity duration-200 ease-out',
|
||||
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
{sections.map(section => (
|
||||
<div key={section.key}>{section.node}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{sections.map(section => (
|
||||
<div key={section.key}>{section.node}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
|
|
|
|||
69
apps/desktop/src/store/composer-actions.ts
Normal file
69
apps/desktop/src/store/composer-actions.ts
Normal file
|
|
@ -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> | void
|
||||
}
|
||||
|
||||
export const $composerActionsBySession = atom<Record<string, ComposerAction[]>>({})
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue