feat(desktop): composer status stack, live subagent windows, editable prompts (#44630)
* feat(desktop): session-scoped status stack + kill new-window theme flash Stack subagents, background tasks, and the queue into one collapsible "sink" above the composer, reusing the queue's chrome so every status reads as one piece. Extracts shared StatusSection / StatusRow / TerminalOutput primitives and a unified $statusItemsBySession store (subagents mirrored, background owned here, merged + grouped for render). Renames BrailleSpinner → GlyphSpinner now that it drives more than braille. Separately, fix the white flash on every new/cmd-clicked window: macOS `vibrancy` paints an NSVisualEffectView that follows the OS appearance and ignores `backgroundColor`, so a dark app on a light-mode Mac flashed white until the renderer painted over it. Pin `nativeTheme.themeSource` to the app theme (persisted to userData so cold launches paint right before the renderer loads), hold windows with `show:false` until `ready-to-show`, and pre-paint the themed background via an inline script before the bundle runs. * feat(desktop): dock the slash popover to the composer via one shared fill var The slash·@ popover (and ? help) now docks onto the composer's edge with the same chrome as the queue/status stack — rounded outer corners, fused borderless edge, no shadow — but keeps its own narrow width. Surface + drawer paint a single --composer-fill var; the state ladder (rest / scrolled / focused / drawer-open) lives once in styles.css on [data-slot='composer-root']. The :has() drawer-open rule is last and forces an opaque fill, since translucent glass sampling different backdrops (thread vs fade gradient) can never match. This replaces the focus-within !important override that repainted the surface behind every previous matching attempt. Also drop the chevron column from the project file tree — the folder open/closed icon already carries the expand state. * feat(desktop): base inset for file tree rows (post-chevron alignment) * feat(desktop): wire the status stack's background tasks to the real process registry The background group was UI-only (dev-mock seeded). Now it's live e2e: - tui_gateway: new session-scoped `process.list` (registry snapshot filtered by the session's session_key, plus a 4KB output tail for the inline terminal viewer) and `process.kill` (single process, ownership-checked — unlike process.stop's kill_all). - Renderer: `reconcileBackgroundProcesses` syncs snapshots into the store layout-stably — rows keep their position when state flips (never re-sort), new processes append, unchanged rows keep object identity so memoised rows skip re-rendering, and a dismissed-set stops the registry's retained finished procs from resurrecting X-ed rows. - Refresh triggers: session open, terminal/process tool.complete, status.update(kind=process) from the gateway's notification poller, and a 5s poll armed only while a running row is visible (catches silent exits). - Stop = real `process.kill` + optimistic dismiss; Dismiss = client-side with resurrection guard. - Re-keyed the stack to the RUNTIME session id: it was keyed by the stored session id, where neither subagent events nor process.list would ever land. - Deleted dev-status-mocks.ts (__hermesStatusMocks) — no more seed shit. Reconcile invariants covered in store/composer-status.test.ts. * feat(desktop): todos + openable subagents in the status stack, self-healing file tree - todo lists move out of the inline chat panel into the composer status stack (checklist icon, dashed ring = pending, spinner = in progress, check = done), fed live from todo tool events and seeded from history on session open - subagent rows carry the child's real session id end-to-end (delegate_tool → gateway → renderer) so clicking one opens ITS session window - status stack publishes its measured height so the thread's bottom clearance grows with it; card paints the shared --composer-fill so focused/scrolled states match the composer exactly - file tree self-heals: ENOENT roots retry on a 3s cadence + Try again button, and the main process expands ~ in IPC paths (gateway cwds arrive as ~/...) - composer drag-drop of tree entries inserts inline refs instead of attachments * fix(desktop): file tree falls back to the workspace dir when a session's cwd is gone Sessions record their launch cwd; deleted worktrees leave that path dead, so opening such a session swapped the tree from the default workspace to a directory that ENOENTs forever — the 3s retry just spun on it. On a root read error the tree now asks main to sanitize the cwd (prefers the configured default project dir), displays that fallback, and quietly re-probes the original path so it switches back if the dir reappears. * feat(desktop): working restore-checkpoint button on past user prompts The discard icon on hover of a past user bubble was decorative — clicking did nothing. It's now a real control: a confirmation dialog explains that everything after the prompt is removed, then the session rewinds to that turn and reruns the same prompt (prompt.submit with truncate_before_user_ordinal, the same mechanism the edit composer uses). Failures rethrow into the dialog's inline error instead of toasting. * fix(desktop): show the restore-checkpoint button on the latest user prompt too Restoring the most recent prompt is just 'retry this turn' — no reason to exclude it. Stop still takes the slot while the turn is running. * fix(desktop): finished todo lists clear themselves out of the status stack A list whose every item is completed/cancelled lingers ~4s so the final checkmark is visible, then the todo group drops out of the stack. A fresh active list arriving within the linger cancels the scheduled clear. * chore(desktop): drop dead editableCheckpoint copy, terser restore confirm * fix(desktop): rewind clears the abandoned timeline's todos + background Restoring to (or editing) an earlier prompt rewinds the conversation, but the todos and background processes spawned by the now-discarded turns kept showing in the status stack — and the real background processes kept running. Both rewind paths now clear the session's todo rows and kill + drop its background processes before the fresh run repopulates them. Also drops the click-to-edit clamp transition, which flashed a half-expanded bubble on the way into the edit composer. * feat(desktop): user messages are always editable; edit/restore revert mid-stream The bubble is now always click-to-edit — even while a turn streams — instead of going inert during a run. Sending an edit acts like restore: it rewinds to that prompt and re-runs with the new text. Both edit and restore can fire mid-stream now; the gateway refuses prompt.submit while a turn runs (4009 "session busy"), so they interrupt the live turn first and retry the submit until the cooperative interrupt winds it down. Restore (re-run as-is) shows on every prompt except the latest running one, which keeps the Stop button. * fix(desktop): label preview-pane ⌘L selections with the filename, not "zsh" The terminal owns a global ⌘/Ctrl+L "send selection to composer" shortcut, so selecting text in the file preview pane and hitting it fell through to the terminal handler — which imported the right text but labelled the composer ref "zsh:N lines" off the shell name. When the selection isn't an xterm selection, label it with the previewed file instead. * fix(desktop): ⌘L on a preview line selection inserts the @line ref, like dragging The source preview lets you select lines in the gutter and drag them into the composer as an @line:path:start-end ref. ⌘/Ctrl+L now does the same when a line selection is active — it drops the identical ref instead of falling through to the terminal's global handler (which grabbed the native text selection and sent a bogus terminal block). Capture-phase + stopPropagation so it wins; with a line selection there's no native selection, so the terminal handler stays out of it. * chore: gitignore apps/desktop/demo/ scratch output The desktop demo prompt writes demo/*.txt during recorded walkthroughs; it's throwaway, never part of the app. Ignore it so it stops cluttering git status. * feat(desktop): subagent watch windows, hard stop, sidebar hygiene Child-session mirror for live subagent windows, delegate sessions tagged and excluded from the sidebar, composer focus/stop polish, and WS stall resilience on the gateway transport. * refactor: DRY delegate SQL + trim status-stack noise Extract shared listable-child and delegate-delete helpers in hermes_state, collapse cancelRun busy release, and cut comment bloat in resume/status paths. * fix(desktop): hide orphaned subagent sessions in sidebar Cascade-delete all ephemeral children on parent delete (not just tagged rows), run v16 backfill to tag legacy orphans, and record new delegates as source=subagent. * fix: restore orphan contract for untagged children + lazy session eviction Cascade-delete only _delegate_from-tagged rows (v16 backfill covers legacy), walk marker chains recursively with FK-safe orphaning, gate lazy watch sessions out of the still-starting eviction exemption via an explicit flag, pass session_id to _make_agent only when resuming, and hide source=subagent from session search. * fix(gateway): gate child mirror off upgraded sessions + age out stale run entries Review findings: the mirror could interleave synthetic events with a real native stream once a watch window upgrades (prompt.submit builds an agent), and a lost subagent.complete left _active_child_runs pinning running=true forever. Mirror now stops when the live session owns an agent; liveness reads ignore entries older than an hour. * fix(gateway): reject prompt.submit into a watch session while its child runs A lazy watch session's running flag is False (the run lives in the parent turn), so typing mid-run sailed past the busy guard and built a second agent racing the in-flight child on the same stored session. Busy error until the run completes; afterwards the submit upgrades into a normal conversation. * refactor(gateway): DRY watch-resume payload + compose listable-child SQL Fold the duplicated child-run busy overlay into one _reuse_live_payload helper across both resume reuse paths, collapse the twin mirror early-returns, and build _LISTABLE_CHILD_SQL from _BRANCH_CHILD_SQL instead of restating it. * fix(desktop): clip horizontal overflow on sidebar scroll areas Add overflow-x-hidden alongside overflow-y-auto on session list scrollers and the shared SidebarContent primitive — vertical scroll unchanged.
This commit is contained in:
@@ -6,6 +6,7 @@ import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useSt
|
||||
|
||||
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { KbdCombo } from '@/components/ui/kbd'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
@@ -229,7 +230,10 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
value={draft}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[0.6875rem] text-muted-foreground/85">{copy.shortcut}</span>
|
||||
<span className="inline-flex items-center gap-1 text-[0.6875rem] text-muted-foreground/85">
|
||||
<KbdCombo combo="mod+enter" size="sm" />
|
||||
{copy.shortcutSuffix}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasChoices && (
|
||||
<Button
|
||||
|
||||
@@ -216,36 +216,6 @@ function assistantTodoMessage(
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantReasoningTodoMessage(
|
||||
todos: Array<{ content: string; id: string; status: 'cancelled' | 'completed' | 'in_progress' | 'pending' }>
|
||||
): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-reasoning-todo-1',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'reasoning', text: 'Let me make a quick todo list.' },
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'todo-1',
|
||||
toolName: 'todo',
|
||||
args: { todos },
|
||||
argsText: JSON.stringify({ todos }),
|
||||
result: { todos }
|
||||
},
|
||||
{ type: 'text', text: 'Done — fake list created.' }
|
||||
],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function StreamingHarness() {
|
||||
const [messages, setMessages] = useState<ThreadMessage[]>([userMessage()])
|
||||
const [isRunning, setIsRunning] = useState(true)
|
||||
@@ -718,7 +688,7 @@ describe('assistant-ui streaming renderer', () => {
|
||||
expect(container.textContent).toContain('Interim answer.')
|
||||
})
|
||||
|
||||
it('renders live todo rows during a running turn', () => {
|
||||
it('does not render an inline todo panel — todos live in the composer status stack', () => {
|
||||
const { container } = render(
|
||||
<TodoHarness
|
||||
message={assistantTodoMessage([
|
||||
@@ -728,52 +698,6 @@ describe('assistant-ui streaming renderer', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
const ui = within(container)
|
||||
|
||||
expect(container.querySelector('[data-slot="aui_todo-hoisted"]')).toBeTruthy()
|
||||
expect(ui.getAllByText('Boil water').length).toBeGreaterThan(0)
|
||||
expect(ui.getByText('Gather ingredients')).toBeTruthy()
|
||||
expect(ui.queryByText(/pending/i)).toBeNull()
|
||||
expect(ui.queryByRole('button', { name: /todo/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders archived todos after turn completion regardless of pending state', () => {
|
||||
const first = render(
|
||||
<TodoHarness message={assistantTodoMessage([{ content: 'Boil water', id: 'boil', status: 'pending' }], false)} />
|
||||
)
|
||||
|
||||
const ui = within(first.container)
|
||||
|
||||
expect(ui.getAllByText('Boil water').length).toBeGreaterThan(0)
|
||||
|
||||
first.unmount()
|
||||
|
||||
const second = render(
|
||||
<TodoHarness
|
||||
message={assistantTodoMessage([{ content: 'Serve latte', id: 'serve', status: 'completed' }], false)}
|
||||
/>
|
||||
)
|
||||
|
||||
const archivedUi = within(second.container)
|
||||
|
||||
expect(archivedUi.getAllByText('Serve latte').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('hoists todo outside the thinking disclosure when reasoning is present', () => {
|
||||
const { container } = render(
|
||||
<TodoHarness
|
||||
message={assistantReasoningTodoMessage([
|
||||
{ content: 'Buy oats', id: 'oats', status: 'completed' },
|
||||
{ content: "Reply to Sam's email", id: 'email', status: 'in_progress' }
|
||||
])}
|
||||
/>
|
||||
)
|
||||
|
||||
const todoPanel = container.querySelector('[data-slot="aui_todo-hoisted"]')
|
||||
const thinkingDisclosure = container.querySelector('[data-slot="aui_thinking-disclosure"]')
|
||||
|
||||
expect(todoPanel).toBeTruthy()
|
||||
expect(thinkingDisclosure).toBeTruthy()
|
||||
expect(Boolean(thinkingDisclosure?.contains(todoPanel as Node))).toBe(false)
|
||||
expect(container.querySelector('[data-slot="aui_todo-hoisted"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -58,7 +58,6 @@ import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
||||
import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
|
||||
import { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer'
|
||||
import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool'
|
||||
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback'
|
||||
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
|
||||
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
|
||||
@@ -70,6 +69,7 @@ import { ImageGenerationPlaceholder } from '@/components/chat/image-generation-p
|
||||
import { Intro, type IntroProps } from '@/components/chat/intro'
|
||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -136,6 +136,7 @@ export const Thread: FC<{
|
||||
loading?: ThreadLoadingState
|
||||
onBranchInNewChat?: (messageId: string) => void
|
||||
onCancel?: () => Promise<void> | void
|
||||
onRestoreToMessage?: (messageId: string) => Promise<void> | void
|
||||
sessionId?: string | null
|
||||
sessionKey?: string | null
|
||||
}> = ({
|
||||
@@ -146,6 +147,7 @@ export const Thread: FC<{
|
||||
loading,
|
||||
onBranchInNewChat,
|
||||
onCancel,
|
||||
onRestoreToMessage,
|
||||
sessionId = null,
|
||||
sessionKey
|
||||
}) => {
|
||||
@@ -154,9 +156,9 @@ export const Thread: FC<{
|
||||
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
|
||||
SystemMessage,
|
||||
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
|
||||
UserMessage: () => <UserMessage onCancel={onCancel} />
|
||||
UserMessage: () => <UserMessage onCancel={onCancel} onRestoreToMessage={onRestoreToMessage} />
|
||||
}),
|
||||
[cwd, gateway, onBranchInNewChat, onCancel, sessionId]
|
||||
[cwd, gateway, onBranchInNewChat, onCancel, onRestoreToMessage, sessionId]
|
||||
)
|
||||
|
||||
const emptyPlaceholder = intro ? (
|
||||
@@ -216,7 +218,6 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
const content = useAuiState(s => s.message.content)
|
||||
const messageText = messageContentText(content)
|
||||
const hoistedTodos = useMemo(() => todosFromMessageContent(content), [content])
|
||||
|
||||
const previewTargets = useMemo(() => {
|
||||
if (!messageText || !/(https?:\/\/|file:\/\/)/i.test(messageText)) {
|
||||
@@ -246,7 +247,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground"
|
||||
data-slot="aui_assistant-message-content"
|
||||
>
|
||||
{hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />}
|
||||
{/* Todos render in the composer status stack now, not inline. */}
|
||||
<MessagePrimitive.Parts components={MESSAGE_PARTS_COMPONENTS} />
|
||||
{messageStatus === 'running' && <StreamStallIndicator activity={`${content.length}:${messageText.length}`} />}
|
||||
{previewTargets.length > 0 && (
|
||||
@@ -737,11 +738,46 @@ const USER_ACTION_ICON_BUTTON_CLASS =
|
||||
const USER_ACTION_ICON_SIZE = '0.6875rem'
|
||||
const StopGlyph = <IconPlayerStopFilled aria-hidden className="size-3.5 -translate-y-px" />
|
||||
|
||||
// Background-process notifications are injected into the conversation as user
|
||||
// messages (the agent must react to them, and message-role alternation forbids
|
||||
// a synthetic system row mid-loop). They are NOT something the human typed, so
|
||||
// render them as a compact system-style notice instead of a user bubble.
|
||||
// Shape: see tools/process_registry.py format_process_notification().
|
||||
const PROCESS_NOTIFICATION_RE = /^\[IMPORTANT: Background process [\s\S]*\]$/
|
||||
|
||||
const ProcessNotificationNote: FC<{ text: string }> = ({ text }) => {
|
||||
const body = text.replace(/^\[IMPORTANT:\s*/, '').replace(/\]$/, '')
|
||||
const newline = body.indexOf('\n')
|
||||
const headline = (newline === -1 ? body : body.slice(0, newline)).trim()
|
||||
const detail = newline === -1 ? '' : body.slice(newline + 1).trim()
|
||||
|
||||
return (
|
||||
<div className="flex max-w-[min(86%,44rem)] flex-col gap-0.5 self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Codicon className="shrink-0 text-muted-foreground/55" name="terminal" size="0.75rem" />
|
||||
<span className="wrap-anywhere">{headline}</span>
|
||||
</span>
|
||||
{detail && (
|
||||
<details className="pl-[1.3125rem]">
|
||||
<summary className="cursor-pointer select-none text-muted-foreground/45 hover:text-muted-foreground/70">
|
||||
output
|
||||
</summary>
|
||||
<pre className="mt-0.5 max-h-48 overflow-auto whitespace-pre-wrap font-mono text-[0.625rem] leading-4 text-muted-foreground/55">
|
||||
{detail}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const UserMessage: FC<{
|
||||
onCancel?: () => Promise<void> | void
|
||||
}> = ({ onCancel }) => {
|
||||
onRestoreToMessage?: (messageId: string) => Promise<void> | void
|
||||
}> = ({ onCancel, onRestoreToMessage }) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false)
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
const content = useAuiState(s => s.message.content)
|
||||
const messageText = messageContentText(content)
|
||||
@@ -791,15 +827,32 @@ const UserMessage: FC<{
|
||||
|
||||
useResizeObserver(measureClamp, clampInnerRef)
|
||||
|
||||
// Injected background-process notification, not a human prompt — render the
|
||||
// compact system-style notice (after all hooks above have run).
|
||||
if (PROCESS_NOTIFICATION_RE.test(messageText.trim())) {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="flex w-full min-w-0 flex-col items-stretch"
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
<ProcessNotificationNote text={messageText.trim()} />
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const hasBody = messageText.trim().length > 0
|
||||
const isLatestUser = messageId === latestUserId
|
||||
const showStop = isLatestUser && threadRunning && Boolean(onCancel)
|
||||
const showRestore = !isLatestUser && !threadRunning
|
||||
// Restore (re-run this exact prompt) is available everywhere the Stop button
|
||||
// isn't — including mid-stream on older prompts, since the action interrupts
|
||||
// the live turn before rewinding.
|
||||
const showRestore = !showStop && Boolean(onRestoreToMessage) && hasBody
|
||||
|
||||
const bubbleClassName = cn(
|
||||
USER_BUBBLE_BASE_CLASS,
|
||||
'border-(--ui-stroke-tertiary) pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors',
|
||||
!threadRunning && 'cursor-pointer hover:border-(--ui-stroke-secondary)'
|
||||
'cursor-pointer pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors',
|
||||
'border-(--ui-stroke-tertiary) hover:border-(--ui-stroke-secondary)'
|
||||
)
|
||||
|
||||
const bubbleContent = (
|
||||
@@ -828,21 +881,19 @@ const UserMessage: FC<{
|
||||
<ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions">
|
||||
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
|
||||
<div className="relative w-full">
|
||||
{threadRunning ? (
|
||||
<div className={bubbleClassName}>{bubbleContent}</div>
|
||||
) : (
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<button
|
||||
aria-label={copy.editMessage}
|
||||
className={bubbleClassName}
|
||||
onClick={() => triggerHaptic('selection')}
|
||||
title={copy.editMessage}
|
||||
type="button"
|
||||
>
|
||||
{bubbleContent}
|
||||
</button>
|
||||
</ActionBarPrimitive.Edit>
|
||||
)}
|
||||
{/* Always editable — clicking opens the edit composer even while a
|
||||
turn streams; sending the edit reverts (interrupt + rewind). */}
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<button
|
||||
aria-label={copy.editMessage}
|
||||
className={bubbleClassName}
|
||||
onClick={() => triggerHaptic('selection')}
|
||||
title={copy.editMessage}
|
||||
type="button"
|
||||
>
|
||||
{bubbleContent}
|
||||
</button>
|
||||
</ActionBarPrimitive.Edit>
|
||||
{(showStop || showRestore) && (
|
||||
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
|
||||
{showStop ? (
|
||||
@@ -860,13 +911,20 @@ const UserMessage: FC<{
|
||||
{StopGlyph}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary)"
|
||||
title={copy.editableCheckpoint}
|
||||
<button
|
||||
aria-label={copy.restoreCheckpoint}
|
||||
className={cn('pointer-events-auto size-6', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
setRestoreConfirmOpen(true)
|
||||
}}
|
||||
title={copy.restoreFromHere}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="discard" size="0.875rem" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -894,6 +952,17 @@ const UserMessage: FC<{
|
||||
</BranchPickerPrimitive.Root>
|
||||
</div>
|
||||
</ActionBarPrimitive.Root>
|
||||
{showRestore && (
|
||||
<ConfirmDialog
|
||||
confirmLabel={copy.restoreConfirm}
|
||||
description={copy.restoreBody}
|
||||
destructive
|
||||
onClose={() => setRestoreConfirmOpen(false)}
|
||||
onConfirm={() => onRestoreToMessage?.(messageId)}
|
||||
open={restoreConfirmOpen}
|
||||
title={copy.restoreTitle}
|
||||
/>
|
||||
)}
|
||||
</StickyHumanMessageContainer>
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { type FC } from 'react'
|
||||
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Loader2Icon } from '@/lib/icons'
|
||||
import { parseTodos, type TodoItem, type TodoStatus } from '@/lib/todos'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function todosFromMessageContent(content: unknown): TodoItem[] {
|
||||
if (!Array.isArray(content)) {
|
||||
return []
|
||||
}
|
||||
|
||||
let latest: null | TodoItem[] = null
|
||||
|
||||
for (const part of content) {
|
||||
if (!part || typeof part !== 'object') {
|
||||
continue
|
||||
}
|
||||
|
||||
const row = part as Record<string, unknown>
|
||||
|
||||
if (row.type !== 'tool-call' || row.toolName !== 'todo') {
|
||||
continue
|
||||
}
|
||||
|
||||
const parsed = parseTodos(row.result) ?? parseTodos(row.args)
|
||||
|
||||
if (parsed !== null) {
|
||||
latest = parsed
|
||||
}
|
||||
}
|
||||
|
||||
return latest ?? []
|
||||
}
|
||||
|
||||
const headerLabel = (todos: readonly TodoItem[]): string =>
|
||||
todos.find(t => t.status === 'in_progress')?.content ??
|
||||
todos.find(t => t.status === 'pending')?.content ??
|
||||
todos.at(-1)?.content ??
|
||||
'Tasks'
|
||||
|
||||
const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label }) => {
|
||||
if (status === 'in_progress') {
|
||||
return (
|
||||
<span
|
||||
aria-label={`In progress: ${label}`}
|
||||
className="grid size-[1.1rem] shrink-0 place-items-center rounded-full border border-ring/65 bg-[color-mix(in_srgb,var(--dt-ring)_14%,transparent)]"
|
||||
>
|
||||
<Loader2Icon className="size-3 animate-spin text-ring" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const checked = status === 'completed'
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
aria-label={label}
|
||||
checked={checked}
|
||||
className={cn(
|
||||
'size-[1.1rem] shrink-0 rounded-full border-border/80 pointer-events-none disabled:cursor-default disabled:opacity-100',
|
||||
checked &&
|
||||
'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground [&_[data-slot=checkbox-indicator]_svg]:size-3',
|
||||
status === 'cancelled' && 'border-muted-foreground/40'
|
||||
)}
|
||||
disabled
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => {
|
||||
if (!todos.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const label = headerLabel(todos)
|
||||
|
||||
return (
|
||||
<section
|
||||
className="mt-1 mb-3 inline-block w-fit max-w-full overflow-hidden rounded-2xl border border-border/70 bg-card align-top shadow-[0_1px_2px_0_hsl(var(--foreground)/0.04),0_1px_4px_-1px_hsl(var(--foreground)/0.06)]"
|
||||
data-slot="aui_todo-hoisted"
|
||||
>
|
||||
<header className="px-3 pt-3 pb-2">
|
||||
<span
|
||||
className="block max-w-full truncate text-[0.85rem] font-semibold leading-tight tracking-tight text-foreground"
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</header>
|
||||
<ul className="grid min-w-0 gap-0.5 px-3 pb-3">
|
||||
{todos.map(todo => (
|
||||
<li
|
||||
// Active row at full presence; everything else fades. Opacity on
|
||||
// the row so the checkbox glyph dims with the text.
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-3 py-1.5 transition-opacity',
|
||||
todo.status === 'in_progress' ? 'opacity-100' : 'opacity-45'
|
||||
)}
|
||||
key={todo.id}
|
||||
>
|
||||
<Checkmark label={todo.content} status={todo.status} />
|
||||
<span className="min-w-0 wrap-anywhere text-[0.8rem] leading-[1.2rem] text-foreground">{todo.content}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -12,9 +12,9 @@ import { DiffLines } from '@/components/chat/diff-lines'
|
||||
import { DisclosureRow } from '@/components/chat/disclosure-row'
|
||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { ToolIcon } from '@/components/ui/tool-icon'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
|
||||
@@ -100,7 +100,7 @@ function rawTechnicalTrace(args: unknown, result: unknown): string {
|
||||
function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode {
|
||||
if (status === 'running') {
|
||||
return (
|
||||
<BrailleSpinner
|
||||
<GlyphSpinner
|
||||
ariaLabel={copy.statusRunning}
|
||||
className="size-3.5 shrink-0 text-[0.95rem] text-(--ui-text-tertiary)"
|
||||
spinner="breathe"
|
||||
@@ -114,10 +114,7 @@ function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode {
|
||||
|
||||
if (status === 'warning') {
|
||||
return (
|
||||
<AlertCircle
|
||||
aria-label={copy.statusRecovered}
|
||||
className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
<AlertCircle aria-label={copy.statusRecovered} className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* The composer surface and everything docked to it (slash·@ popover, `?` help)
|
||||
* paint ONE shared `--composer-fill` var. The state ladder (rest / scrolled /
|
||||
* focused / drawer-open) lives in styles.css on `[data-slot='composer-root']`,
|
||||
* so the two layers can never disagree — drawer-open forces an opaque fill via
|
||||
* `:has()`, because translucent glass sampling different backdrops (thread vs
|
||||
* fade gradient) renders as different colors even with identical tints.
|
||||
*/
|
||||
export const composerFill = 'bg-(--composer-fill)'
|
||||
|
||||
/** Backdrop treatment for the composer input surface. Harmless when the fill
|
||||
* goes opaque (drawer open) — nothing shows through to blur. */
|
||||
export const composerSurfaceGlass = cn(
|
||||
'backdrop-blur-[0.75rem] backdrop-saturate-[1.12] [-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
|
||||
'transition-[background-color] duration-150 ease-out'
|
||||
)
|
||||
|
||||
const composerDockEdge = (edge: 'bottom' | 'top') =>
|
||||
cn('border border-border/65', edge === 'top' ? 'rounded-t-2xl border-b-0' : 'rounded-b-2xl border-t-0')
|
||||
|
||||
/** Glassy docked card — the status stack / queue. Paints the SAME
|
||||
* `--composer-fill` as the surface, so rest / scrolled / focused / drawer-open
|
||||
* all match the composer by construction. */
|
||||
export const composerDockCard = (edge: 'bottom' | 'top' = 'top') =>
|
||||
cn(composerDockEdge(edge), composerFill, composerSurfaceGlass)
|
||||
|
||||
/** Fused docked card — completion drawers. Shares `--composer-fill` with the
|
||||
* composer surface, which goes opaque while a drawer is open. */
|
||||
export const composerFusedDockCard = (edge: 'bottom' | 'top' = 'top') => cn(composerDockEdge(edge), composerFill)
|
||||
@@ -0,0 +1,68 @@
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface StatusRowProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
/** Leading glyph slot (spinner / status dot / selection circle). */
|
||||
leading?: ReactNode
|
||||
/** Makes the whole row activatable (adds `cursor-pointer` + keyboard a11y).
|
||||
* Trailing-slot buttons should `stopPropagation` so they don't also fire it. */
|
||||
onActivate?: () => void
|
||||
/** Right-aligned actions. Revealed on row hover/focus unless `trailingVisible`. */
|
||||
trailing?: ReactNode
|
||||
trailingVisible?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared row chrome for everything in the composer status stack — status items
|
||||
* (subagents, background) AND queued prompts. Fixed height, a leading glyph
|
||||
* slot, flexible content, and a trailing actions slot that reveals on hover.
|
||||
* Hover background matches the session sidebar. Consumers fill the three slots;
|
||||
* they never re-implement the row container.
|
||||
*/
|
||||
export function StatusRow({
|
||||
children,
|
||||
className,
|
||||
leading,
|
||||
onActivate,
|
||||
trailing,
|
||||
trailingVisible = false
|
||||
}: StatusRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/status-row flex min-h-6 items-center gap-2 rounded-md px-1.5 py-1 hover:bg-(--ui-row-hover-background)',
|
||||
onActivate && 'cursor-pointer',
|
||||
className
|
||||
)}
|
||||
onClick={onActivate}
|
||||
onKeyDown={
|
||||
onActivate
|
||||
? event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onActivate()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
role={onActivate ? 'button' : undefined}
|
||||
tabIndex={onActivate ? 0 : undefined}
|
||||
>
|
||||
<span className="flex size-3.5 shrink-0 items-center justify-center">{leading}</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">{children}</div>
|
||||
{trailing && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-0.5',
|
||||
!trailingVisible && 'opacity-0 group-hover/status-row:opacity-100 group-focus-within/status-row:opacity-100'
|
||||
)}
|
||||
>
|
||||
{trailing}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { type ReactNode, useState } from 'react'
|
||||
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
|
||||
interface StatusSectionProps {
|
||||
/** Optional right-aligned actions (text links / micro buttons). Pass
|
||||
* `Button` with `size="micro"` + `variant="text"` or `"link"`. */
|
||||
accessory?: ReactNode
|
||||
children: ReactNode
|
||||
defaultCollapsed?: boolean
|
||||
/** Optional glyph between the caret and the label (e.g. a `Codicon`). */
|
||||
icon?: ReactNode
|
||||
label: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* One collapsible group inside the composer status stack. Pure chrome — header
|
||||
* (caret + label) + body — styled to match the queue exactly so every status
|
||||
* (queue, subagents, background) reads as one piece. The stack supplies the
|
||||
* outer card and the dividers between groups; this owns only its own collapse.
|
||||
*/
|
||||
export function StatusSection({ accessory, children, defaultCollapsed = true, icon, label }: StatusSectionProps) {
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-1 pr-1">
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 px-2 py-1 text-left text-xs font-normal text-muted-foreground/92 transition-colors hover:text-foreground/90"
|
||||
onClick={() => setCollapsed(open => !open)}
|
||||
type="button"
|
||||
>
|
||||
<DisclosureCaret className="shrink-0" open={!collapsed} size="1em" />
|
||||
{icon && <span className="flex shrink-0 items-center">{icon}</span>}
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
{accessory && <div className="flex shrink-0 items-center gap-1">{accessory}</div>}
|
||||
</div>
|
||||
{!collapsed && <div className="px-1 pb-0.5">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TerminalOutputProps {
|
||||
className?: string
|
||||
text: string
|
||||
}
|
||||
|
||||
const NEAR_BOTTOM_PX = 24
|
||||
|
||||
/**
|
||||
* Tiny read-only terminal viewer: monospace, non-wrapping (long lines scroll
|
||||
* horizontally), vertical scroll past `max-h`. Jumps to the bottom on mount,
|
||||
* then tails — sticking to the bottom as `text` grows, but only when the user
|
||||
* is already near the bottom so scrolling up to read earlier output isn't
|
||||
* interrupted.
|
||||
*
|
||||
* Self-contained so any surface (status rows, tool calls, inspectors) can drop
|
||||
* in a stdout/stderr box without re-implementing the scroll logic.
|
||||
*/
|
||||
export function TerminalOutput({ className, text }: TerminalOutputProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
// On open: jump straight to the latest output (no animation, before paint).
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current
|
||||
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}, [])
|
||||
|
||||
// On growth: tail only when already pinned near the bottom.
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
|
||||
if (el && el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}, [text])
|
||||
|
||||
return (
|
||||
<div className={cn('max-h-16 overflow-auto overscroll-contain', className)} ref={ref}>
|
||||
<pre className="w-max min-w-full font-mono text-[0.5625rem] leading-[0.85rem] whitespace-pre text-muted-foreground/70">
|
||||
{text}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { getGlobalModelOptions } from '@/hermes'
|
||||
@@ -69,9 +69,7 @@ export function ModelVisibilityDialog({
|
||||
next.delete(key)
|
||||
|
||||
// Check if this was the last real model for this provider.
|
||||
const remainingForProvider = [...next].some(
|
||||
k => k.startsWith(`${provider.slug}::`) && !isProviderSentinel(k)
|
||||
)
|
||||
const remainingForProvider = [...next].some(k => k.startsWith(`${provider.slug}::`) && !isProviderSentinel(k))
|
||||
|
||||
if (!remainingForProvider) {
|
||||
next.add(sentinel)
|
||||
@@ -110,7 +108,7 @@ export function ModelVisibilityDialog({
|
||||
<div className="max-h-[55vh] overflow-y-auto pb-1">
|
||||
{providers.length === 0 ? (
|
||||
<div className="px-3 py-5 text-center text-xs text-muted-foreground">
|
||||
{modelOptions.isPending ? <BrailleSpinner className="mx-auto text-sm" /> : copy.noAuthenticatedProviders}
|
||||
{modelOptions.isPending ? <GlyphSpinner className="mx-auto text-sm" /> : copy.noAuthenticatedProviders}
|
||||
</div>
|
||||
) : (
|
||||
providers.map(provider => {
|
||||
|
||||
@@ -4,6 +4,9 @@ import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Text+icon actions underline the label on hover, not the glyph.
|
||||
const TEXT_ACTION_ICON = '[&_.codicon]:no-underline [&_svg]:no-underline'
|
||||
|
||||
// Text buttons are square (no radius) and sized by padding + line-height — no
|
||||
// fixed heights — so they stay snug and scale with content. Only icon buttons
|
||||
// (inherently square) carry the shared 4px radius.
|
||||
@@ -22,13 +25,13 @@ const buttonVariants = cva(
|
||||
secondary:
|
||||
'bg-(--ui-bg-quaternary) text-(--ui-text-primary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
|
||||
ghost: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
|
||||
link: 'text-primary underline-offset-4 decoration-current/20 hover:underline',
|
||||
link: `text-primary underline-offset-4 decoration-current/20 hover:underline ${TEXT_ACTION_ICON}`,
|
||||
// Boxless inline-text action (no bg/border). Quiet by default — reads as
|
||||
// muted label text, underlines on hover (e.g. "Cancel", "Clear").
|
||||
text: 'text-muted-foreground underline-offset-4 hover:text-foreground hover:underline',
|
||||
text: `text-muted-foreground underline-offset-4 hover:text-foreground hover:underline ${TEXT_ACTION_ICON}`,
|
||||
// Emphasized inline-text action: bold + always-underlined link. Use for
|
||||
// the actionable affordance in a row ("Change", "Set", "Open logs", …).
|
||||
textStrong: 'font-semibold text-muted-foreground underline underline-offset-4 hover:text-foreground'
|
||||
textStrong: `font-semibold text-muted-foreground underline underline-offset-4 hover:text-foreground ${TEXT_ACTION_ICON}`
|
||||
},
|
||||
size: {
|
||||
default: 'px-3 py-1.5 has-[>svg]:px-2.5',
|
||||
@@ -39,6 +42,9 @@ const buttonVariants = cva(
|
||||
// variants when the button must sit inline in a heading or sentence
|
||||
// (replaces ad-hoc `h-auto px-0 py-0` overrides).
|
||||
inline: 'h-auto gap-1 p-0 has-[>svg]:px-0',
|
||||
// Status-stack headers, table footers — 12px text actions beside a label.
|
||||
micro:
|
||||
"h-auto gap-0.5 px-1 py-0 text-xs leading-4 font-normal has-[>svg]:px-0.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
icon: 'size-9 rounded-[4px]',
|
||||
'icon-xs': "size-6 rounded-[4px] [&_svg:not([class*='size-'])]:size-3",
|
||||
'icon-sm': 'size-8 rounded-[4px]',
|
||||
|
||||
+15
-13
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
|
||||
import spinners, { type BrailleSpinnerName as SpinnerName } from 'unicode-animations'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type { SpinnerName }
|
||||
|
||||
interface NormalisedSpinner {
|
||||
frames: readonly string[]
|
||||
interval: number
|
||||
@@ -10,10 +12,10 @@ interface NormalisedSpinner {
|
||||
|
||||
// Some spinners ship multi-character frames. Pull the first cell so each
|
||||
// frame fits in one monospace box — matches how the TUI uses them.
|
||||
const FRAMES_BY_NAME: Record<BrailleSpinnerName, NormalisedSpinner> = (() => {
|
||||
const out = {} as Record<BrailleSpinnerName, NormalisedSpinner>
|
||||
const FRAMES_BY_NAME: Record<SpinnerName, NormalisedSpinner> = (() => {
|
||||
const out = {} as Record<SpinnerName, NormalisedSpinner>
|
||||
|
||||
for (const name of Object.keys(spinners) as BrailleSpinnerName[]) {
|
||||
for (const name of Object.keys(spinners) as SpinnerName[]) {
|
||||
const raw = spinners[name]
|
||||
|
||||
out[name] = {
|
||||
@@ -25,21 +27,21 @@ const FRAMES_BY_NAME: Record<BrailleSpinnerName, NormalisedSpinner> = (() => {
|
||||
return out
|
||||
})()
|
||||
|
||||
interface BrailleSpinnerProps {
|
||||
interface GlyphSpinnerProps {
|
||||
ariaLabel?: string
|
||||
className?: string
|
||||
spinner?: BrailleSpinnerName
|
||||
spinner?: SpinnerName
|
||||
}
|
||||
|
||||
/**
|
||||
* One-char braille spinner driven by `unicode-animations`. Mirrors the
|
||||
* spinner used by the Ink TUI so the desktop and terminal experiences
|
||||
* read the same visually. Renders inside an `inline-flex` cell with
|
||||
* `leading-none` and `items-center` so it sits vertically centred inside
|
||||
* its parent's line-box (e.g. the 1.1rem disclosure row).
|
||||
* One-char glyph spinner driven by `unicode-animations` (braille, orbit, scan,
|
||||
* etc. — pick any `spinner` name). Mirrors the spinner used by the Ink TUI so
|
||||
* the desktop and terminal experiences read the same visually. Renders inside
|
||||
* an `inline-flex` cell with `leading-none` and `items-center` so it sits
|
||||
* vertically centred inside its parent's line-box.
|
||||
*/
|
||||
export function BrailleSpinner({ ariaLabel = 'Loading', className, spinner = 'breathe' }: BrailleSpinnerProps) {
|
||||
const spin = FRAMES_BY_NAME[spinner] ?? FRAMES_BY_NAME.breathe!
|
||||
export function GlyphSpinner({ ariaLabel = 'Loading', className, spinner = 'braille' }: GlyphSpinnerProps) {
|
||||
const spin = FRAMES_BY_NAME[spinner] ?? FRAMES_BY_NAME.braille!
|
||||
const [frame, setFrame] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1,37 +1,108 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
|
||||
import { comboTokens } from '@/lib/keybinds/combo'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
|
||||
const COMPACT_KEY = /^[\p{L}\p{N}⌘⌥⇧⌃↵⇥⌫↑↓←→@/?]$/u
|
||||
|
||||
const kbdSurface = [
|
||||
'border-[color-mix(in_srgb,var(--ui-stroke-secondary)_75%,transparent)]',
|
||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_94%,var(--dt-foreground)_6%)]',
|
||||
'text-[color-mix(in_srgb,var(--dt-foreground)_58%,transparent)]',
|
||||
'shadow-[0_1px_0_0_color-mix(in_srgb,var(--ui-stroke-tertiary)_85%,transparent),0_1px_2px_0_color-mix(in_srgb,var(--dt-foreground)_7%,transparent)]'
|
||||
]
|
||||
|
||||
const kbdVariants = cva(
|
||||
'inline-flex shrink-0 items-center justify-center border [font-family:var(--dt-font-kbd)] font-normal leading-none select-none',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: kbdSurface,
|
||||
ghost: [
|
||||
...kbdSurface,
|
||||
'text-[color-mix(in_srgb,var(--dt-foreground)_38%,transparent)]',
|
||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_72%,var(--dt-foreground)_3%)]',
|
||||
'border-[color-mix(in_srgb,var(--ui-stroke-tertiary)_80%,transparent)]'
|
||||
],
|
||||
capturing: [
|
||||
'border-[color-mix(in_srgb,var(--theme-primary)_50%,var(--ui-stroke-secondary))]',
|
||||
'bg-[color-mix(in_srgb,var(--theme-primary)_10%,var(--ui-bg-elevated))]',
|
||||
'text-[color-mix(in_srgb,var(--theme-primary)_88%,transparent)]',
|
||||
'shadow-none'
|
||||
],
|
||||
inverted: [
|
||||
'border-[color-mix(in_srgb,currentColor_22%,transparent)]',
|
||||
'bg-[color-mix(in_srgb,currentColor_12%,transparent)]',
|
||||
'text-[color-mix(in_srgb,currentColor_88%,transparent)]',
|
||||
'shadow-[0_1px_0_0_color-mix(in_srgb,currentColor_18%,transparent)]'
|
||||
]
|
||||
},
|
||||
size: {
|
||||
sm: 'rounded-[0.2rem] text-[0.625rem]',
|
||||
md: 'rounded-[0.25rem] text-[0.6875rem]'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function kbdShapeClass(label: string, size: 'sm' | 'md' | null | undefined): string {
|
||||
const compact = COMPACT_KEY.test(label)
|
||||
|
||||
if (size === 'sm') {
|
||||
return compact ? 'size-[1.125rem] px-0' : 'h-[1.125rem] min-w-[1.125rem] px-1'
|
||||
}
|
||||
|
||||
return compact ? 'size-[1.375rem] px-0' : 'h-[1.375rem] min-w-[1.375rem] px-1.5'
|
||||
}
|
||||
|
||||
interface KbdProps extends React.ComponentProps<'kbd'>, VariantProps<typeof kbdVariants> {}
|
||||
|
||||
function Kbd({ children, className, size, variant, ...props }: KbdProps) {
|
||||
const label = typeof children === 'string' ? children : ''
|
||||
|
||||
return (
|
||||
<kbd
|
||||
className={cn(
|
||||
'inline-grid h-4 min-w-4 place-items-center rounded-sm border border-border/70 bg-muted/45 px-1 font-mono text-[0.5625rem] font-medium leading-none text-muted-foreground shadow-xs',
|
||||
className
|
||||
)}
|
||||
className={cn(kbdVariants({ size, variant }), kbdShapeClass(label, size), className)}
|
||||
data-slot="kbd"
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
|
||||
interface KbdGroupProps extends Omit<React.ComponentProps<'span'>, 'children'> {
|
||||
interface KbdGroupProps extends Omit<React.ComponentProps<'span'>, 'children'>, VariantProps<typeof kbdVariants> {
|
||||
keys: string[]
|
||||
}
|
||||
|
||||
function KbdGroup({ className, keys, ...props }: KbdGroupProps) {
|
||||
function KbdGroup({ className, keys, size, variant, ...props }: KbdGroupProps) {
|
||||
return (
|
||||
<span
|
||||
aria-label={keys.join(' ')}
|
||||
className={cn('inline-flex shrink-0 items-center gap-0.5 opacity-55', className)}
|
||||
className={cn('inline-flex shrink-0 items-center gap-1', className)}
|
||||
data-slot="kbd-group"
|
||||
{...props}
|
||||
>
|
||||
{keys.map(key => (
|
||||
<Kbd key={key}>{key}</Kbd>
|
||||
{keys.map((key, index) => (
|
||||
<Kbd key={`${key}-${index}`} size={size} variant={variant}>
|
||||
{key}
|
||||
</Kbd>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup }
|
||||
interface KbdComboProps extends Omit<KbdGroupProps, 'keys'> {
|
||||
combo: string
|
||||
}
|
||||
|
||||
function KbdCombo({ combo, ...props }: KbdComboProps) {
|
||||
return <KbdGroup keys={comboTokens(combo)} {...props} />
|
||||
}
|
||||
|
||||
export { Kbd, KbdCombo, KbdGroup, kbdVariants }
|
||||
|
||||
@@ -339,7 +339,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
|
||||
'flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto group-data-[collapsible=icon]:overflow-hidden',
|
||||
className
|
||||
)}
|
||||
data-sidebar="content"
|
||||
|
||||
Reference in New Issue
Block a user