mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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.
This commit is contained in:
parent
1cefad5523
commit
9b01c74f00
6 changed files with 256 additions and 38 deletions
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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'
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue