opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine

hermes --tui launches the native OpenTUI engine (Bun) when
HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config);
Ink stays the default and the shipping path is untouched.

- _resolve_tui_engine() (env > config > ink); refuses opentui on
  Windows/Termux (no Bun) -> falls back to ink with a notice.
- _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step).
- _bun_bin() with HERMES_BUN override.
- Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host
  must not bootstrap Node).
- Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun
  is JSC; the V8 flag errors/ignores).

Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI ->
real Python gateway streamed a real reply. No-flag default still ink.
This commit is contained in:
alt-glitch
2026-06-08 11:11:54 +00:00
parent 24f74eb888
commit 2bd9c9b881
741 changed files with 17733 additions and 79889 deletions
+3 -3
View File
@@ -18,7 +18,7 @@ import {
} from '@/components/ui/pagination'
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { Tip } from '@/components/ui/tooltip'
import { getSessionMessages, listAllProfileSessions } from '@/hermes'
import { getSessionMessages, listSessions } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
@@ -388,8 +388,8 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
setRefreshing(true)
try {
const sessions = (await listAllProfileSessions(30, 1)).sessions
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id, session.profile)))
const sessions = (await listSessions(30, 1)).sessions
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id)))
const nextArtifacts: ArtifactRecord[] = []
results.forEach((result, index) => {
@@ -3,9 +3,8 @@ import { useStore } from '@nanostores/react'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { AlertCircle, FileText, FolderOpen, ImageIcon, Link, Loader2, Terminal } from '@/lib/icons'
import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
@@ -32,9 +31,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
const c = t.composer
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
const cwd = useStore($currentCwd)
const isUploading = attachment.uploadState === 'uploading'
const hasUploadError = attachment.uploadState === 'error'
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' && !isUploading
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal'
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
async function openPreview() {
@@ -62,15 +59,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
throw new Error(c.couldNotPreview(attachment.label))
}
// We already hold the image bytes (the card thumbnail) — render those
// directly so a screenshot/clipboard image previews even when its only
// on-disk copy is a transient path the renderer can't re-read.
const withBytes =
attachment.kind === 'image' && attachment.previewUrl
? { ...preview, dataUrl: attachment.previewUrl, previewKind: 'image' as const }
: preview
setCurrentSessionPreviewTarget(withBytes, 'manual', target)
setCurrentSessionPreviewTarget(preview, 'manual', target)
} catch (error) {
notifyError(error, c.previewUnavailable)
}
@@ -80,51 +69,30 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
<Tip label={attachment.path || attachment.detail || attachment.label}>
<div className="group/attachment relative min-w-0 shrink-0">
<button
aria-busy={isUploading || undefined}
aria-label={canPreview ? c.previewLabel(attachment.label) : attachment.label}
className={cn(
'flex max-w-56 items-center gap-2 rounded-2xl border bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.18)] transition-colors disabled:cursor-default',
hasUploadError
? 'border-destructive/45 hover:border-destructive/60'
: 'border-border/60 hover:border-primary/35 hover:bg-accent/45'
)}
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
disabled={!canPreview}
onClick={() => void openPreview()}
type="button"
>
<span className="relative grid size-8 shrink-0 place-items-center overflow-hidden rounded-lg border border-border/55 bg-muted/35 text-muted-foreground">
{attachment.previewUrl && attachment.kind === 'image' ? (
<img
alt={attachment.label}
className="size-full object-cover"
draggable={false}
src={attachment.previewUrl}
/>
) : (
{attachment.previewUrl && attachment.kind === 'image' ? (
<img
alt={attachment.label}
className="size-8 shrink-0 border border-border/70 object-cover"
draggable={false}
src={attachment.previewUrl}
/>
) : (
<span className="grid size-8 shrink-0 place-items-center border border-border/55 bg-muted/35 text-muted-foreground">
<Icon className="size-3.5" />
)}
{isUploading && (
<span className="absolute inset-0 grid place-items-center bg-background/60 backdrop-blur-[1px]">
<Loader2 className="size-3.5 animate-spin text-foreground/75" />
</span>
)}
{hasUploadError && (
<span className="absolute inset-0 grid place-items-center bg-destructive/15">
<AlertCircle className="size-3.5 text-destructive" />
</span>
)}
</span>
</span>
)}
<span className="min-w-0">
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
{attachment.label}
</span>
{detail && (
<span
className={cn(
'block truncate text-[0.62rem] leading-3.5',
hasUploadError ? 'text-destructive/80' : 'text-muted-foreground/65'
)}
>
<span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">
{detail}
</span>
)}
@@ -3,25 +3,32 @@ import { ComposerPrimitive } from '@assistant-ui/react'
import type { ReactNode } from 'react'
export const COMPLETION_DRAWER_CLASS = [
'absolute bottom-[calc(100%+0.375rem)] left-0 z-50',
'w-80 max-w-[calc(100vw-2rem)]',
'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
'rounded-xl border border-(--ui-stroke-secondary)',
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
'p-1 text-xs text-popover-foreground shadow-lg',
'absolute bottom-[calc(100%+0.25rem)] left-0 z-50',
'w-60 max-w-[calc(100vw-2rem)]',
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
'rounded-lg border border-(--ui-stroke-secondary)',
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]',
'p-1 text-xs text-popover-foreground shadow-md',
'backdrop-blur-md'
].join(' ')
export const COMPLETION_DRAWER_BELOW_CLASS = [
'absolute left-0 top-[calc(100%+0.375rem)] z-50',
'w-80 max-w-[calc(100vw-2rem)]',
'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
'rounded-xl border border-(--ui-stroke-secondary)',
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
'p-1 text-xs text-popover-foreground shadow-lg',
'absolute left-0 top-[calc(100%+0.25rem)] z-50',
'w-60 max-w-[calc(100vw-2rem)]',
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
'rounded-lg border border-(--ui-stroke-secondary)',
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]',
'p-1 text-xs text-popover-foreground shadow-md',
'backdrop-blur-md'
].join(' ')
export const COMPLETION_DRAWER_ROW_CLASS = [
'relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1',
'w-full min-w-0 text-left text-xs outline-hidden transition-colors',
'hover:bg-(--ui-bg-tertiary)',
'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground'
].join(' ')
export function ComposerCompletionDrawer({
adapter,
ariaLabel,
@@ -4,7 +4,6 @@ import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, Layers3, Loader2, Square, SteeringWheel } from '@/lib/icons'
import { formatCombo } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import type { ConversationStatus } from './hooks/use-voice-conversation'
@@ -63,7 +62,6 @@ export function ComposerControls({
}) {
const { t } = useI18n()
const c = t.composer
const steerLabel = `${c.steer} (${formatCombo('mod+enter')})`
if (conversation.active) {
return <ConversationPill {...conversation} disabled={disabled} />
@@ -75,9 +73,9 @@ export function ComposerControls({
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
{canSteer && (
<Tip label={steerLabel}>
<Tip label={c.steer}>
<Button
aria-label={steerLabel}
aria-label={c.steer}
className={GHOST_ICON_BTN}
disabled={disabled}
onClick={onSteer}
@@ -1,189 +0,0 @@
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { useRef, useState } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
// No global setupFiles registers auto-cleanup, so unmount between tests —
// otherwise a second render() leaks the first editor and getByTestId('editor')
// matches multiple nodes.
afterEach(cleanup)
// Faithful mirror of index.tsx's Enter wiring (handleEditorKeyDown's Enter
// branch + submitDraft), driven through REAL DOM keydown events on a
// contentEditable.
//
// Regression repro for #39630: pressing Enter right after typing (fast typing /
// IME) did nothing. The composer state (`draft` from useAuiState) and its
// derived `hasComposerPayload` lag the DOM by a render, so the keydown handler
// read empty state and either dropped the message, drained a queued prompt
// instead of sending, or (while busy) refused to queue. The fix reads the live
// editor text — `hasLivePayload` in the handler and a DOM re-sync at the top of
// submitDraft — so the just-typed text always wins.
//
// We model the race deterministically the way the IME repro does: mutate the
// editor's textContent WITHOUT firing an input event, so the React `draft`
// state stays stale while the DOM already holds the text.
function Harness({
busy = false,
queued = [],
onSubmit,
onQueue,
onCancel,
onDrain
}: {
busy?: boolean
queued?: readonly string[]
onSubmit: (text: string) => void
onQueue: (text: string) => void
onCancel: () => void
onDrain: () => void
}) {
const editorRef = useRef<HTMLDivElement>(null)
const draftRef = useRef('')
// Mirrors `useAuiState(s => s.composer.text)` — updated only via setText, so
// it lags the DOM until React re-renders (the source of the bug).
const [draft, setDraft] = useState('')
const attachments: unknown[] = []
const composerPlainText = (el: HTMLElement) => el.textContent ?? ''
const setText = (next: string) => {
draftRef.current = next
setDraft(next)
}
const submitDraft = () => {
const editor = editorRef.current
if (editor) {
const domText = composerPlainText(editor)
if (domText !== draftRef.current) {
draftRef.current = domText
setDraft(domText)
}
}
const text = draftRef.current
const payloadPresent = text.trim().length > 0 || attachments.length > 0
if (busy) {
if (payloadPresent) {
onQueue(text)
} else {
onCancel()
}
} else if (!payloadPresent && queued.length > 0) {
onDrain()
} else if (payloadPresent) {
onSubmit(text)
}
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
if (!busy && !hasLivePayload && queued.length > 0) {
onDrain()
return
}
if (busy && !hasLivePayload) {
return
}
submitDraft()
}
}
// `draft` is read so the lint/compiler treats the stale-state mirror as live;
// the assertions prove the handler never relies on it.
void draft
return (
<div
contentEditable
data-testid="editor"
onInput={event => setText(composerPlainText(event.currentTarget))}
onKeyDown={handleKeyDown}
ref={editorRef}
suppressContentEditableWarning
/>
)
}
describe('composer Enter submit — live DOM vs stale composer state (#39630)', () => {
it('sends the just-typed text on Enter even when composer state has not synced', async () => {
const onSubmit = vi.fn()
const { getByTestId } = render(
<Harness onCancel={vi.fn()} onDrain={vi.fn()} onQueue={vi.fn()} onSubmit={onSubmit} />
)
const editor = getByTestId('editor')
// Fast typing: the DOM has the text but NO input event fired, so `draft`
// state is still empty (the exact stale-state race).
await act(async () => {
editor.textContent = 'hello world'
fireEvent.keyDown(editor, { key: 'Enter' })
})
expect(onSubmit).toHaveBeenCalledWith('hello world')
})
it('queues a fast-typed message while busy instead of draining the queue or cancelling', async () => {
const onQueue = vi.fn()
const onDrain = vi.fn()
const onCancel = vi.fn()
const { getByTestId } = render(
<Harness busy onCancel={onCancel} onDrain={onDrain} onQueue={onQueue} onSubmit={vi.fn()} queued={['queued-1']} />
)
const editor = getByTestId('editor')
await act(async () => {
editor.textContent = 'urgent follow-up'
fireEvent.keyDown(editor, { key: 'Enter' })
})
expect(onQueue).toHaveBeenCalledWith('urgent follow-up')
expect(onDrain).not.toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
})
it('treats an empty Enter while busy as a no-op (never an accidental Stop)', async () => {
const onCancel = vi.fn()
const onSubmit = vi.fn()
const onQueue = vi.fn()
const { getByTestId } = render(
<Harness busy onCancel={onCancel} onDrain={vi.fn()} onQueue={onQueue} onSubmit={onSubmit} />
)
const editor = getByTestId('editor')
await act(async () => {
editor.textContent = ''
fireEvent.keyDown(editor, { key: 'Enter' })
})
expect(onCancel).not.toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
expect(onQueue).not.toHaveBeenCalled()
})
it('drains the next queued prompt on Enter when idle with a truly empty editor', async () => {
const onDrain = vi.fn()
const onSubmit = vi.fn()
const { getByTestId } = render(
<Harness onCancel={vi.fn()} onDrain={onDrain} onQueue={vi.fn()} onSubmit={onSubmit} queued={['queued-1']} />
)
const editor = getByTestId('editor')
await act(async () => {
editor.textContent = ''
fireEvent.keyDown(editor, { key: 'Enter' })
})
expect(onDrain).toHaveBeenCalledTimes(1)
expect(onSubmit).not.toHaveBeenCalled()
})
})
@@ -5,13 +5,6 @@ export interface CompletionEntry {
text: string
display?: unknown
meta?: unknown
/** Optional section label (e.g. "Commands", "Skills"). The popover renders a
* header whenever this changes between consecutive items, so the fetcher must
* emit entries already grouped contiguously. */
group?: string
/** Optional completion-action id. When set, picking the item runs that action
* (e.g. opening an overlay) instead of inserting a chip + waiting for submit. */
action?: string
}
export interface CompletionPayload {
@@ -2,17 +2,12 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-u
import { useCallback } from 'react'
import type { HermesGateway } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import {
type CommandsCatalogLike,
desktopSkinSlashCompletions,
desktopSlashDescription,
type DesktopThemeCommandOption,
filterDesktopCommandsCatalog,
isDesktopSlashExtensionCommand,
isDesktopSlashSuggestion
} from '@/lib/desktop-slash-commands'
import { $sessions } from '@/store/session'
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
@@ -21,10 +16,7 @@ interface SlashItemMetadata extends Record<string, string> {
command: string
display: string
meta: string
group: string
rawText: string
/** Completion-action id; empty for ordinary insert-a-chip completions. */
action: string
}
function textValue(value: unknown, fallback = ''): string {
@@ -46,21 +38,12 @@ function commandText(value: string): string {
return value.startsWith('/') ? value : `/${value}`
}
/** How many recent sessions to surface inline before the "Browse all…" entry. */
const SESSION_INLINE_LIMIT = 7
/** Live `/` completions backed by the gateway's `complete.slash` RPC. */
export function useSlashCompletions(options: {
gateway: HermesGateway | null
/** Desktop theme list — `/skin` is owned client-side, so its arg completions
* come from here, not the backend (whose skin list is CLI/TUI-only). */
skinThemes?: DesktopThemeCommandOption[]
activeSkin?: string
}): {
export function useSlashCompletions(options: { gateway: HermesGateway | null }): {
adapter: Unstable_TriggerAdapter
loading: boolean
} {
const { gateway, skinThemes, activeSkin } = options
const { gateway } = options
const enabled = Boolean(gateway)
const fetcher = useCallback(
@@ -71,136 +54,34 @@ export function useSlashCompletions(options: {
const text = `/${query}`
// The desktop owns /skin entirely (client-side theme context). Surface its
// theme list inside this single popover instead of a bespoke one, and skip
// the backend skin completions (which describe CLI/TUI skins that don't
// apply here). Matches once we're past `/skin ` into the arg stage.
const skinArg = /^\/skin\s+(.*)$/is.exec(text)
if (skinArg && skinThemes) {
const items = desktopSkinSlashCompletions(skinThemes, activeSkin ?? '', skinArg[1] ?? '').map(entry => ({
text: entry.text,
display: entry.display,
meta: entry.meta,
group: 'Themes'
}))
return { items, query }
}
// /resume (and its aliases) completes recent sessions inline — the same
// client-side list the picker overlay shows — instead of the backend
// (whose /resume opens an interactive TUI picker we can't render here).
const sessionArg = /^\/(?:resume|sessions|switch)\s+(.*)$/is.exec(text)
if (sessionArg) {
const needle = (sessionArg[1] ?? '').trim().toLowerCase()
const matches = (
needle
? $sessions.get().filter(
session =>
sessionTitle(session).toLowerCase().includes(needle) ||
(session.preview ?? '').toLowerCase().includes(needle) ||
session.id.toLowerCase().includes(needle)
)
: $sessions.get()
).slice(0, SESSION_INLINE_LIMIT)
const items: CompletionEntry[] = matches.map(session => ({
text: `/resume ${session.id}`,
display: sessionTitle(session),
meta: (session.preview ?? '').trim(),
group: 'Sessions'
}))
// Trailing "more" affordance (Cursor-style): picking it opens the full
// session picker overlay directly. `text` stays a bare `/resume` so that
// submitting it (Enter) still opens the overlay if the action is skipped.
items.push({
text: '/resume',
display: 'Browse all sessions…',
meta: '',
group: 'Sessions',
action: 'session-picker'
})
return { items, query }
}
try {
if (!query) {
const catalog = filterDesktopCommandsCatalog(await gateway.request<CommandsCatalogLike>('commands.catalog'))
// Prefer the categorized layout so the popover renders section headers
// (Session, Tools & Skills, ...). Fall back to the flat list when the
// backend didn't categorize.
const sections = catalog.categories?.length
? catalog.categories
: [{ name: '', pairs: catalog.pairs ?? [] }]
const items = sections.flatMap(section =>
section.pairs.map(([command, meta]) => ({
text: command,
display: command,
group: section.name || undefined,
meta
}))
)
const items = (catalog.pairs ?? []).map(([command, meta]) => ({
text: command,
display: command,
meta
}))
return { items, query }
}
const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>(
'complete.slash',
{ text }
)
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.slash', { text })
// Arg-completion items (replace_from > 1) carry just the arg stub —
// e.g. complete.slash returns `{text: "alice"}` for `/personality alic`
// with replace_from = 14. Rewrite those entries so the popover inserts
// the full `/personality alice` token instead of stranding `/alice`.
const replaceFrom = typeof result.replace_from === 'number' ? result.replace_from : 1
const isArgCompletion = replaceFrom > 1
const prefix = isArgCompletion ? text.slice(0, replaceFrom) : ''
const decorated = (result.items ?? [])
.map(item => {
if (!isArgCompletion) {
return item
}
const argText = typeof item.text === 'string' ? item.text : ''
return { ...item, text: `${prefix}${argText}` }
})
.filter(item => isArgCompletion || isDesktopSlashSuggestion(item.text))
const items = (result.items ?? [])
.filter(item => isDesktopSlashSuggestion(item.text))
.map(item => ({
...item,
// Arg suggestions (e.g. `/handoff <platform>`) live under one
// header; otherwise split skills out from built-in commands.
group: isArgCompletion ? 'Options' : isDesktopSlashExtensionCommand(item.text) ? 'Skills' : 'Commands',
// Arg items carry their own meta (the personality/toolset/platform
// blurb). Only command rows get the registry description — looking
// one up for `/personality none` would clobber it with the parent
// command's text.
meta: isArgCompletion ? textValue(item.meta) : desktopSlashDescription(item.text, textValue(item.meta))
meta: desktopSlashDescription(item.text, textValue(item.meta))
}))
// Keep each group contiguous so headers render once: Commands before
// Skills (stable within a group, preserving backend relevance order).
const groupOrder = ['Commands', 'Skills', 'Options']
const items = isArgCompletion
? decorated
: [...decorated].sort((a, b) => groupOrder.indexOf(a.group) - groupOrder.indexOf(b.group))
return { items, query }
} catch {
return { items: [], query }
}
},
[gateway, skinThemes, activeSkin]
[gateway]
)
const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => {
@@ -212,8 +93,6 @@ export function useSlashCompletions(options: {
command,
display,
meta,
group: textValue(entry.group),
action: textValue(entry.action),
// Provide rawText so hermesDirectiveFormatter.serialize uses the
// direct-insertion path instead of the legacy @type:id fallback.
// Without this, the item.id (which includes a "|index" suffix for
+56 -283
View File
@@ -13,25 +13,17 @@ import {
useState
} from 'react'
import { hermesDirectiveFormatter, type SlashChipKind } from '@/components/assistant-ui/directive-text'
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { Button } from '@/components/ui/button'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { chatMessageText } from '@/lib/chat-messages'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import {
$composerAttachments,
clearComposerAttachments,
clearSessionDraft,
type ComposerAttachment,
stashSessionDraft,
takeSessionDraft
} from '@/store/composer'
import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
import {
browseBackward,
browseForward,
@@ -48,11 +40,10 @@ import {
shouldAutoDrainOnSettle,
updateQueuedPrompt
} from '@/store/composer-queue'
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
import { $gatewayState, $messages } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { useTheme } from '@/themes'
import { extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from '../hooks/use-composer-actions'
import { extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions'
import { AttachmentList } from './attachments'
import { ContextMenu } from './context-menu'
@@ -73,7 +64,7 @@ import { useVoiceConversation } from './hooks/use-voice-conversation'
import { useVoiceRecorder } from './hooks/use-voice-recorder'
import {
dragHasAttachments,
droppedFileInlineRefs,
droppedFileInlineRef,
type InlineRefInput,
insertInlineRefsIntoEditor
} from './inline-refs'
@@ -83,9 +74,9 @@ import {
placeCaretEnd,
refChipElement,
renderComposerContents,
RICH_INPUT_SLOT,
slashChipElement
RICH_INPUT_SLOT
} from './rich-editor'
import { SkinSlashPopover } from './skin-slash-popover'
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
import { ComposerTriggerPopover } from './trigger-popover'
import type { ChatBarProps } from './types'
@@ -104,30 +95,6 @@ const COMPOSER_FADE_BACKGROUND =
const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)]
/** Completion items can carry an `action` (set in use-slash-completions) that
* runs a side effect on pick instead of inserting a chip — e.g. the session
* picker's "Browse all…" entry opens the overlay. Table-driven so new action
* items are a registry row, not a composer branch. */
const COMPLETION_ACTIONS: Record<string, () => void> = {
'session-picker': () => setSessionPickerOpen(true)
}
/** Map a picked `/` completion to its pill accent. Driven by the completion
* group set in use-slash-completions (Skills / Themes / Commands|Options). */
function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind {
const group = (item.metadata as { group?: unknown } | undefined)?.group
if (group === 'Skills') {
return 'skill'
}
if (group === 'Themes') {
return 'theme'
}
return 'command'
}
interface QueueEditState {
attachments: ComposerAttachment[]
draft: string
@@ -137,10 +104,6 @@ interface QueueEditState {
const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a }))
// Quiet period after the last keystroke before persisting the draft;
// unmount/pagehide flushes bypass it.
const DRAFT_PERSIST_DEBOUNCE_MS = 400
export function ChatBar({
busy,
cwd,
@@ -182,9 +145,6 @@ export function ChatBar({
const editorRef = useRef<HTMLDivElement | null>(null)
const draftRef = useRef(draft)
const previousBusyRef = useRef(busy)
const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null)
const activeQueueSessionKeyRef = useRef(activeQueueSessionKey)
activeQueueSessionKeyRef.current = activeQueueSessionKey
const drainingQueueRef = useRef(false)
const urlInputRef = useRef<HTMLInputElement | null>(null)
@@ -196,17 +156,14 @@ export function ChatBar({
const [dragActive, setDragActive] = useState(false)
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
const [focusRequestId, setFocusRequestId] = useState(0)
const queueEditRef = useRef(queueEdit)
queueEditRef.current = queueEdit
const dragDepthRef = useRef(0)
const composingRef = useRef(false) // true during IME composition (CJK input)
const lastSpokenIdRef = useRef<string | null>(null)
const narrow = useMediaQuery('(max-width: 30rem)')
const { availableThemes, themeName } = useTheme()
const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
const slash = useSlashCompletions({ activeSkin: themeName, gateway: gateway ?? null, skinThemes: availableThemes })
const slash = useSlashCompletions({ gateway: gateway ?? null })
const stacked = expanded || narrow || tight
const trimmedDraft = draft.trim()
@@ -214,12 +171,10 @@ export function ChatBar({
const canSubmit = busy || hasComposerPayload
const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
// Steer only makes sense mid-turn, text-only (the gateway can't carry images
// into a tool result) and never for a slash command (those execute inline).
const canSteer =
busy && !!onSteer && attachments.length === 0 && trimmedDraft.length > 0 && !SLASH_COMMAND_RE.test(trimmedDraft)
const showHelpHint = draft === '?'
const { t } = useI18n()
@@ -507,6 +462,12 @@ export function ChatBar({
})
}, [])
const selectSkinSlashCommand = (command: string) => {
draftRef.current = command
aui.composer().setText(command)
requestMainFocus()
}
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
@@ -659,50 +620,16 @@ export function ChatBar({
return
}
// Action items (e.g. "Browse all sessions…") run a side effect instead of
// inserting a chip: strip the typed trigger token, then fire the action.
const completionAction = (item.metadata as { action?: unknown } | undefined)?.action
const runAction = typeof completionAction === 'string' ? COMPLETION_ACTIONS[completionAction] : undefined
if (runAction) {
const current = composerPlainText(editor)
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
renderComposerContents(editor, prefix)
placeCaretEnd(editor)
draftRef.current = composerPlainText(editor)
aui.composer().setText(draftRef.current)
closeTrigger()
runAction()
requestMainFocus()
return
}
const serialized = hermesDirectiveFormatter.serialize(item)
const starter = serialized.endsWith(':')
// Picking a bare arg-taking command (e.g. `/personality`) shouldn't commit
// it — expand to its options step so the popover shows the inline list, just
// as typing `/personality ` by hand would. A serialized value with a space is
// already an arg pick (`/personality alice`), so it commits normally.
const command = (item.metadata as { command?: string } | undefined)?.command ?? ''
const expandsToArgs =
trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command)
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
// No pill while expanding — the bare command stays plain text until an arg
// is picked, at which point a single pill is emitted for the full command.
const slashKind = !expandsToArgs && trigger.kind === '/' ? slashChipKindForItem(item) : null
const keepTriggerOpen = starter || expandsToArgs
const finish = () => {
draftRef.current = composerPlainText(editor)
aui.composer().setText(draftRef.current)
requestMainFocus()
keepTriggerOpen ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
}
const sel = window.getSelection()
@@ -712,20 +639,7 @@ export function ChatBar({
if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) {
const current = composerPlainText(editor)
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
if (slashKind) {
// Two-step arg picks (e.g. `/handoff` pill already inserted, now picking
// the platform) land here because the caret sits past a contenteditable
// chip. Rebuild the prefix and re-emit a single pill for the full command.
renderComposerContents(editor, prefix)
editor.append(slashChipElement(serialized, slashKind), document.createTextNode(' '))
placeCaretEnd(editor)
return finish()
}
renderComposerContents(editor, `${prefix}${text}`)
renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
placeCaretEnd(editor)
return finish()
@@ -736,13 +650,8 @@ export function ChatBar({
replaceRange.setEnd(node, offset)
replaceRange.deleteContents()
const chip = slashKind
? slashChipElement(serialized, slashKind)
: directive
? refChipElement(directive[1], directive[2])
: null
if (chip) {
if (directive) {
const chip = refChipElement(directive[1], directive[2])
const space = document.createTextNode(' ')
const fragment = document.createDocumentFragment()
fragment.append(chip, space)
@@ -905,16 +814,7 @@ export function ChatBar({
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
// Decide from the DOM, not React state. `hasComposerPayload` is derived
// from the AUI composer state, which lags the latest keystroke by a
// render, so on fast typing / IME the just-typed text isn't in state yet.
// Without the live read, a real message typed while prompts are queued
// would drain the queue instead of sending. submitDraft() re-syncs and
// sends the live editor text.
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
if (!busy && !hasLivePayload && queuedPrompts.length > 0) {
if (!busy && !hasComposerPayload && queuedPrompts.length > 0) {
void drainNextQueued()
return
@@ -922,10 +822,7 @@ export function ChatBar({
// Empty Enter while busy is a no-op — interrupting is explicit (Stop/Esc),
// never a stray Enter after sending. With a payload, submitDraft queues it.
// Gate on the live DOM payload (not the render-lagged composer state) so a
// message typed fast / via IME while busy still reaches submitDraft() and
// gets queued instead of being mistaken for an empty Enter.
if (busy && !hasLivePayload) {
if (busy && !hasComposerPayload) {
return
}
@@ -1022,25 +919,24 @@ export function ChatBar({
return
}
// In-app drags (project tree / gutter) are workspace-relative paths the
// gateway resolves directly, so they stay inline @file:/@line: refs. OS
// drops are absolute local paths a remote gateway can't read (and images
// need byte upload for vision), so route them through the upload pipeline.
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
const refs = droppedFileInlineRefs(inAppRefs, cwd)
if (Array.from(event.dataTransfer.types || []).includes(HERMES_PATHS_MIME)) {
const refs = candidates
.map(candidate => droppedFileInlineRef(candidate, cwd))
.filter((ref): ref is string => Boolean(ref))
if (refs.length && insertInlineRefs(refs)) {
triggerHaptic('selection')
if (insertInlineRefs(refs)) {
triggerHaptic('selection')
}
return
}
if (osDrops.length) {
void Promise.resolve(onAttachDroppedItems(osDrops)).then(attached => {
if (attached) {
triggerHaptic('selection')
requestMainFocus()
}
})
}
void Promise.resolve(onAttachDroppedItems(candidates)).then(attached => {
if (attached) {
triggerHaptic('selection')
requestMainFocus()
}
})
}
const handleInputDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
@@ -1060,7 +956,11 @@ export function ChatBar({
const candidates = extractDroppedFiles(event.dataTransfer)
if (!candidates.length) {
const refs = candidates
.map(candidate => droppedFileInlineRef(candidate, cwd))
.filter((ref): ref is string => Boolean(ref))
if (!refs.length) {
return
}
@@ -1068,27 +968,9 @@ export function ChatBar({
event.stopPropagation()
resetDragState()
// Dropping straight onto the text box used to inline-ref *every* file —
// including OS/Finder drops, whose absolute local path a remote gateway
// can't read and whose image bytes never reached vision. Split by origin:
// in-app drags stay inline refs; OS drops go through the upload pipeline.
// (When no upload handler is wired, fall back to inline refs for all.)
const attach = onAttachDroppedItems
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
const refs = droppedFileInlineRefs(attach ? inAppRefs : candidates, cwd)
if (refs.length && insertInlineRefs(refs)) {
if (insertInlineRefs(refs)) {
triggerHaptic('selection')
}
if (attach && osDrops.length) {
void Promise.resolve(attach(osDrops)).then(attached => {
if (attached) {
triggerHaptic('selection')
requestMainFocus()
}
})
}
}
const clearDraft = useCallback(() => {
@@ -1113,69 +995,6 @@ export function ChatBar({
}
}
const stashAt = (
scope: string | null,
text = draftRef.current,
attachments = $composerAttachments.get()
) => stashSessionDraft(scope, text, attachments)
// Per-thread draft swap — the composer's only session coupling. Lifecycle
// never clears composer state; this effect alone stashes on leave, restores
// on enter. Keyed writes are idempotent, so no skip-sentinel.
useEffect(() => {
const { attachments, text } = takeSessionDraft(activeQueueSessionKey)
loadIntoComposer(text, attachments)
return () => {
const editing = queueEditRef.current
if (editing?.sessionKey === activeQueueSessionKey) {
stashAt(activeQueueSessionKey, editing.draft, editing.attachments)
} else if (!isBrowsingHistory(sessionId)) {
stashAt(activeQueueSessionKey)
}
}
}, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps
// Debounced stash into the active scope. Skipped while browsing history or
// editing a queued prompt — recalled text must not clobber the real draft.
useEffect(() => {
if (isBrowsingHistory(sessionId) || queueEdit) {
return
}
pendingDraftPersistRef.current = { scope: activeQueueSessionKey, text: draft }
const handle = window.setTimeout(() => {
pendingDraftPersistRef.current = null
stashAt(activeQueueSessionKey, draft)
}, DRAFT_PERSIST_DEBOUNCE_MS)
return () => window.clearTimeout(handle)
}, [activeQueueSessionKey, draft, queueEdit, sessionId])
// pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R
// inside the debounce window would drop trailing keystrokes without this.
useEffect(() => {
const flushPendingDraftPersist = () => {
const pending = pendingDraftPersistRef.current
if (!pending) {
return
}
pendingDraftPersistRef.current = null
stashAt(pending.scope, pending.text)
}
window.addEventListener('pagehide', flushPendingDraftPersist)
return () => {
window.removeEventListener('pagehide', flushPendingDraftPersist)
flushPendingDraftPersist()
}
}, [])
const beginQueuedEdit = (entry: QueuedPromptEntry) => {
if (!activeQueueSessionKey || queueEdit) {
return
@@ -1378,61 +1197,21 @@ export function ChatBar({
}
}, [busy, drainNextQueued, queuedPrompts.length])
// Queue-edit cleanup: on session swap the scope effect already stashed the
// edit snapshot; only restore into the composer when still on the same scope.
// Clean up queue edit when its target disappears (session swap or external delete).
useEffect(() => {
if (!queueEdit) {
return
}
if (queueEdit.sessionKey === activeQueueSessionKey) {
if (editingQueuedPrompt) {
return
}
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
if (queueEdit.sessionKey === activeQueueSessionKey && editingQueuedPrompt) {
return
}
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
setQueueEdit(null)
}, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps
const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => {
const submittedScope = activeQueueSessionKeyRef.current
const submittedAttachments = attachments ?? []
const restore = () => {
loadIntoComposer(text, submittedAttachments)
stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments)
}
void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text))
.then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope)))
.catch(restore)
}
const submitDraft = () => {
// Source the text from the DOM editor, not React state. The AUI composer
// state (`draft`) and the derived `hasComposerPayload` lag the DOM by a
// render, so on fast typing or IME composition the final keystroke(s) may
// not have synced yet — reading state here drops the message (Enter looks
// like it does nothing; typing a trailing space only "fixes" it because the
// extra input event forces a state sync). draftRef is updated on every
// input event; refresh it from the editor once more to also cover an
// in-flight keystroke that hasn't fired its input event yet.
const editor = editorRef.current
if (editor) {
const domText = composerPlainText(editor)
if (domText !== draftRef.current) {
draftRef.current = domText
aui.composer().setText(domText)
}
}
const text = draftRef.current
const payloadPresent = text.trim().length > 0 || attachments.length > 0
if (queueEdit) {
exitQueuedEdit('save')
} else if (busy) {
@@ -1443,11 +1222,12 @@ export function ChatBar({
// busy guard for commands that genuinely need an idle session (skill
// /send directives). Queuing them would make every slash command wait
// for the current turn to finish, which is how the TUI never behaves.
if (!attachments.length && SLASH_COMMAND_RE.test(text.trim())) {
if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) {
const submitted = draft
triggerHaptic('submit')
clearDraft()
dispatchSubmit(text)
} else if (payloadPresent) {
void onSubmit(submitted)
} else if (hasComposerPayload) {
queueCurrentDraft()
} else {
// Stop button (the only way to reach here while busy with an empty
@@ -1455,15 +1235,15 @@ export function ChatBar({
triggerHaptic('cancel')
void Promise.resolve(onCancel())
}
} else if (!payloadPresent && queuedPrompts.length > 0) {
} else if (!hasComposerPayload && queuedPrompts.length > 0) {
void drainNextQueued()
} else if (payloadPresent) {
const submittedAttachments = cloneAttachments(attachments)
} else if (draft.trim() || attachments.length > 0) {
const submitted = draft
triggerHaptic('submit')
resetBrowseState(sessionId)
clearDraft()
clearComposerAttachments()
dispatchSubmit(text, submittedAttachments)
void onSubmit(submitted, { attachments })
}
focusInput()
@@ -1630,7 +1410,7 @@ export function ChatBar({
onPaste={handlePaste}
ref={editorRef}
role="textbox"
spellCheck={false}
spellCheck="true"
suppressContentEditableWarning
/>
{/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree
@@ -1649,15 +1429,7 @@ export function ChatBar({
`asChild` swaps TextareaAutosize for a Radix Slot wrapping our
plain <textarea>, which carries the binding but skips autosize. */}
<ComposerPrimitive.Input asChild submitMode="ctrlEnter" tabIndex={-1} unstable_focusOnScrollToBottom={false}>
<textarea
aria-hidden
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="sr-only"
spellCheck={false}
tabIndex={-1}
/>
<textarea aria-hidden className="sr-only" tabIndex={-1} />
</ComposerPrimitive.Input>
</div>
)
@@ -1696,6 +1468,7 @@ export function ChatBar({
onPick={replaceTriggerWithChip}
/>
)}
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
{activeQueueSessionKey && queuedPrompts.length > 0 && (
// Out of flow so the queue never inflates the composer's measured
// height (that drives thread bottom padding → chat resizes on
@@ -83,12 +83,6 @@ export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null
return `@${kind}:${formatRefValue(rel)}`
}
/** Resolve a batch of drops to their inline `@file:`/`@line:`/`@folder:` refs,
* dropping any that carry no path. */
export function droppedFileInlineRefs(candidates: DroppedFile[], cwd: string | null | undefined): string[] {
return candidates.map(candidate => droppedFileInlineRef(candidate, cwd)).filter((ref): ref is string => Boolean(ref))
}
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
if (!refs.length) {
return null
@@ -10,10 +10,7 @@ import {
DIRECTIVE_CHIP_CLASS,
directiveIconElement,
directiveIconSvg,
formatRefValue,
slashChipClass,
type SlashChipKind,
slashIconElement
formatRefValue
} from '@/components/assistant-ui/directive-text'
export const RICH_INPUT_SLOT = 'composer-rich-input'
@@ -80,24 +77,6 @@ export function refChipElement(kind: string, rawValue: string, displayLabel?: st
return chip
}
/** A non-editable pill for a picked slash command (`/skin nous`, `/tropes`).
* `data-ref-text` carries the literal command so `composerPlainText` round-trips
* it back to the exact text that gets submitted. */
export function slashChipElement(command: string, kind: SlashChipKind, label?: string) {
const chip = document.createElement('span')
const text = document.createElement('span')
chip.contentEditable = 'false'
chip.dataset.refText = command
chip.dataset.slashKind = kind
chip.className = slashChipClass(kind)
text.className = 'truncate'
text.textContent = label || command
chip.append(slashIconElement(kind), text)
return chip
}
function appendTextWithBreaks(target: DocumentFragment | HTMLElement, text: string) {
const lines = text.split('\n')
@@ -0,0 +1,61 @@
import { useI18n } from '@/i18n'
import { desktopSkinSlashCompletions } from '@/lib/desktop-slash-commands'
import { triggerHaptic } from '@/lib/haptics'
import { useTheme } from '@/themes/context'
import { COMPLETION_DRAWER_CLASS, COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty } from './completion-drawer'
interface SkinSlashPopoverProps {
draft: string
onSelect: (command: string) => void
}
export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
const { t } = useI18n()
const c = t.composer
const { availableThemes, themeName } = useTheme()
const match = draft.match(/^\/skin\s+(\S*)$/i)
if (!match) {
return null
}
const items = desktopSkinSlashCompletions(availableThemes, themeName, match[1] ?? '')
return (
<div
aria-label={c.themeSuggestions}
className={COMPLETION_DRAWER_CLASS}
data-slot="composer-skin-completion-drawer"
data-state="open"
role="listbox"
>
<div className="grid gap-0.5 pt-0.5">
{items.length === 0 ? (
<CompletionDrawerEmpty title={c.noMatchingThemes}>
{c.themeTryPre}
<span className="font-mono text-foreground/80">/skin list</span>
{c.themeTryPost}
</CompletionDrawerEmpty>
) : (
items.map(item => (
<button
className={COMPLETION_DRAWER_ROW_CLASS}
key={item.text}
onClick={() => {
triggerHaptic('selection')
onSelect(item.text)
}}
onMouseDown={event => event.preventDefault()}
role="option"
type="button"
>
<span className="shrink-0 font-mono font-medium leading-5 text-foreground">{item.display}</span>
<span className="min-w-0 truncate leading-5 text-muted-foreground/80">{item.meta}</span>
</button>
))
)}
</div>
</div>
)
}
@@ -22,33 +22,6 @@ describe('detectTrigger', () => {
it('returns null for plain text', () => {
expect(detectTrigger('hello there')).toBeNull()
})
it('keeps the slash trigger live while typing args', () => {
expect(detectTrigger('/personality ')).toEqual({
kind: '/',
query: 'personality ',
tokenLength: 13
})
expect(detectTrigger('/personality alic')).toEqual({
kind: '/',
query: 'personality alic',
tokenLength: 17
})
expect(detectTrigger('/tools enable foo')).toEqual({
kind: '/',
query: 'tools enable foo',
tokenLength: 17
})
})
it('does not treat file-style paths as slash triggers', () => {
expect(detectTrigger('src/foo/bar')).toBeNull()
expect(detectTrigger('/path/to/file')).toBeNull()
})
it('still anchors at-mention triggers strictly at the token edge', () => {
expect(detectTrigger('@file:path with space')).toBeNull()
})
})
describe('extractClipboardImageBlobs', () => {
@@ -6,13 +6,7 @@ export interface TriggerState {
tokenLength: number
}
// `@` triggers stop at the first whitespace — `@file:path` and `@diff` are
// single tokens. `/` triggers keep going so the popover stays live while the
// user types args (`/personality alic` → arg completer suggests `alice`).
// Restricting the slash command name to `[a-zA-Z][\w-]*` avoids matching file
// paths like `src/foo/bar`.
const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@/]*)$/
const SLASH_TRIGGER_RE = /(?:^|[\s])(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/
const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/
/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
export function blobDedupeKey(blob: Blob): string {
@@ -103,17 +97,11 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null {
}
export function detectTrigger(textBefore: string): TriggerState | null {
const slash = SLASH_TRIGGER_RE.exec(textBefore)
const match = TRIGGER_RE.exec(textBefore)
if (slash) {
return { kind: '/', query: slash[2], tokenLength: 1 + slash[2].length }
if (!match) {
return null
}
const at = AT_TRIGGER_RE.exec(textBefore)
if (at) {
return { kind: '@', query: at[2], tokenLength: 1 + at[2].length }
}
return null
return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
}
@@ -34,17 +34,9 @@ describe('ComposerTriggerPopover i18n', () => {
})
it('renders localized loading copy for slash commands', () => {
renderPopover('/', true)
const { container } = renderPopover('/', true)
// While loading the popover shows only the spinner + loading copy — the
// `/help` empty-state hint is reserved for the resolved (not-loading) state.
expect(screen.getByText('查找中…')).toBeTruthy()
})
it('renders the slash empty-state hint when not loading', () => {
const { container } = renderPopover('/')
expect(screen.getByText('没有匹配项。')).toBeTruthy()
expect(container.textContent).toContain('/help')
})
})
@@ -1,7 +1,5 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { Fragment } from 'react'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
@@ -9,6 +7,7 @@ import { cn } from '@/lib/utils'
import {
COMPLETION_DRAWER_BELOW_CLASS,
COMPLETION_DRAWER_CLASS,
COMPLETION_DRAWER_ROW_CLASS,
CompletionDrawerEmpty
} from './completion-drawer'
@@ -24,7 +23,11 @@ const AT_ICON_BY_TYPE: Record<string, string> = {
url: 'globe'
}
function atIcon(item: Unstable_TriggerItem) {
function completionIcon(kind: '@' | '/', item: Unstable_TriggerItem) {
if (kind === '/') {
return 'terminal'
}
const meta = item.metadata as { rawText?: string } | undefined
const raw = meta?.rawText || item.label
@@ -39,18 +42,6 @@ function atIcon(item: Unstable_TriggerItem) {
return AT_ICON_BY_TYPE[item.type] || AT_ICON_BY_TYPE.simple
}
interface RowMeta {
display?: string
group?: string
meta?: string
}
const ROW_BASE_CLASS = [
'relative flex w-full cursor-default select-none rounded-md px-2 py-1 text-left',
'outline-hidden transition-colors hover:bg-(--ui-bg-tertiary)',
'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground'
].join(' ')
interface ComposerTriggerPopoverProps {
activeIndex: number
items: readonly Unstable_TriggerItem[]
@@ -72,9 +63,6 @@ export function ComposerTriggerPopover({
}: ComposerTriggerPopoverProps) {
const { t } = useI18n()
const copy = t.composer
const isSlash = kind === '/'
let lastGroup: string | undefined
return (
<div
@@ -85,94 +73,41 @@ export function ComposerTriggerPopover({
role="listbox"
>
{items.length === 0 ? (
loading ? (
<div className="flex items-center gap-2 px-2 py-1.5 text-(--ui-text-tertiary)">
<BrailleSpinner ariaLabel={copy.lookupLoading} className="text-foreground/70" spinner="braille" />
<span>{copy.lookupLoading}</span>
</div>
) : (
<CompletionDrawerEmpty title={copy.lookupNoMatches}>
{kind === '@' ? (
<>
{copy.lookupTry} <span className="font-mono text-foreground/80">@file:</span> {copy.lookupOr}{' '}
<span className="font-mono text-foreground/80">@folder:</span>.
</>
) : (
<>
{copy.lookupTry} <span className="font-mono text-foreground/80">/help</span>.
</>
)}
</CompletionDrawerEmpty>
)
<CompletionDrawerEmpty title={loading ? copy.lookupLoading : copy.lookupNoMatches}>
{kind === '@' ? (
<>
{copy.lookupTry} <span className="font-mono text-foreground/80">@file:</span> {copy.lookupOr}{' '}
<span className="font-mono text-foreground/80">@folder:</span>.
</>
) : (
<>
{copy.lookupTry} <span className="font-mono text-foreground/80">/help</span>.
</>
)}
</CompletionDrawerEmpty>
) : (
items.map((item, index) => {
const meta = item.metadata as RowMeta | undefined
const display = meta?.display ?? (isSlash ? `/${item.label}` : item.label)
const meta = item.metadata as { display?: string; meta?: string } | undefined
const display = meta?.display ?? (kind === '/' ? `/${item.label}` : item.label)
const description = meta?.meta || item.description
const group = meta?.group?.trim()
const showHeader = isSlash && Boolean(group) && group !== lastGroup
const isFirstHeader = lastGroup === undefined
lastGroup = group || lastGroup
const active = index === activeIndex
return (
<Fragment key={item.id}>
{showHeader && (
<div
className={cn(
'select-none px-2 pb-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)',
isFirstHeader ? 'pt-0.5' : 'pt-2'
)}
>
{group}
</div>
<button
className={cn(COMPLETION_DRAWER_ROW_CLASS, index === activeIndex && 'bg-(--ui-bg-tertiary)')}
data-highlighted={index === activeIndex ? '' : undefined}
key={item.id}
onClick={() => onPick(item)}
onMouseEnter={() => onHover(index)}
type="button"
>
<span className="grid size-3.5 shrink-0 place-items-center text-(--ui-text-tertiary)">
<Codicon name={completionIcon(kind, item)} size="0.875rem" />
</span>
<span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">{display}</span>
{description && (
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
)}
<button
className={cn(ROW_BASE_CLASS, isSlash ? 'flex-col gap-0' : 'items-center gap-2')}
data-highlighted={active ? '' : undefined}
onClick={() => onPick(item)}
onMouseEnter={() => onHover(index)}
type="button"
>
{isSlash ? (
<>
{/* Active row (keyboard nav or hover) un-truncates inline so
long command names / descriptions stay readable without a
floating tooltip. */}
<span
className={cn(
'text-[0.8125rem] font-medium leading-snug text-foreground',
active ? 'whitespace-normal break-words' : 'truncate'
)}
>
{display}
</span>
{description && (
<span
className={cn(
'text-[0.6875rem] leading-snug text-(--ui-text-tertiary)',
active ? 'whitespace-normal break-words' : 'truncate'
)}
>
{description}
</span>
)}
</>
) : (
<>
<span className="grid size-4 shrink-0 place-items-center text-(--ui-text-tertiary)">
<Codicon name={atIcon(item)} size="0.875rem" />
</span>
<span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">
{display}
</span>
{description && (
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
)}
</>
)}
</button>
</Fragment>
</button>
)
})
)}
@@ -1,57 +0,0 @@
import { describe, expect, it } from 'vitest'
import { type DroppedFile, partitionDroppedFiles } from './use-composer-actions'
// A Finder/Explorer drop carries a native File handle; an in-app drag (project
// tree, gutter line ref) is path-only. The split decides whether a drop becomes
// an inline @file: ref (in-app, workspace-relative, gateway-resolvable) or goes
// through the upload pipeline (OS drop — absolute local path a remote gateway
// can't read, plus image bytes for vision).
const osDrop = (path: string): DroppedFile => ({ file: new File(['x'], path.split('/').pop() || 'f'), path })
const inAppRef = (path: string, extra: Partial<DroppedFile> = {}): DroppedFile => ({ path, ...extra })
describe('partitionDroppedFiles', () => {
it('routes File-bearing OS drops to osDrops and path-only in-app drags to inAppRefs', () => {
const finderPdf = osDrop('/Users/mahmoud/Downloads/DEVIS_signed.pdf')
const projectFile = inAppRef('src/index.ts')
const { inAppRefs, osDrops } = partitionDroppedFiles([finderPdf, projectFile])
expect(osDrops).toEqual([finderPdf])
expect(inAppRefs).toEqual([projectFile])
})
it('treats an OS screenshot drop as an upload target (so it gets byte upload + vision)', () => {
const screenshot = osDrop('/var/folders/tmp/Screenshot 2026-06-09.png')
const { inAppRefs, osDrops } = partitionDroppedFiles([screenshot])
expect(osDrops).toEqual([screenshot])
expect(inAppRefs).toEqual([])
})
it('keeps gutter line-range drags inline (no File handle)', () => {
const lineRef = inAppRef('src/app.ts', { line: 10, lineEnd: 20 })
const { inAppRefs, osDrops } = partitionDroppedFiles([lineRef])
expect(osDrops).toEqual([])
expect(inAppRefs).toEqual([lineRef])
})
it('splits a mixed drop and preserves order within each group', () => {
const a = inAppRef('a.ts')
const b = osDrop('/abs/b.pdf')
const c = inAppRef('c.ts')
const d = osDrop('/abs/d.png')
const { inAppRefs, osDrops } = partitionDroppedFiles([a, b, c, d])
expect(inAppRefs).toEqual([a, c])
expect(osDrops).toEqual([b, d])
})
it('returns empty groups for an empty drop', () => {
expect(partitionDroppedFiles([])).toEqual({ inAppRefs: [], osDrops: [] })
})
})
@@ -33,7 +33,7 @@ function blobExtension(blob: Blob): string {
return (mime && BLOB_MIME_EXTENSION[mime]) || '.png'
}
export function isImagePath(filePath: string): boolean {
function isImagePath(filePath: string): boolean {
return IMAGE_EXTENSION_PATTERN.test(filePath)
}
@@ -181,35 +181,6 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
return result
}
/**
* Split dropped entries by origin. OS/Finder drops carry a native `File`
* handle; in-app drags (project tree, gutter line refs) are path-only.
*
* The distinction is load-bearing: an in-app path is workspace-relative and
* resolves on the gateway as-is, so it stays an inline `@file:`/`@line:` ref.
* An OS drop is an absolute path on *this* machine — the gateway can't read it
* in remote mode, and an image needs its bytes uploaded to get vision either
* way. So OS drops must go through the attachment/upload pipeline rather than
* leaking a local path into the prompt text.
*/
export function partitionDroppedFiles(candidates: DroppedFile[]): {
osDrops: DroppedFile[]
inAppRefs: DroppedFile[]
} {
const osDrops: DroppedFile[] = []
const inAppRefs: DroppedFile[] = []
for (const candidate of candidates) {
if (candidate.file) {
osDrops.push(candidate)
} else {
inAppRefs.push(candidate)
}
}
return { osDrops, inAppRefs }
}
interface ComposerActionsOptions {
activeSessionId: string | null
currentCwd: string
+9 -18
View File
@@ -49,9 +49,9 @@ import { ChatDropOverlay } from './chat-drop-overlay'
import { ChatSwapOverlay } from './chat-swap-overlay'
import { ChatBar, ChatBarFallback } from './composer'
import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus'
import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
import { droppedFileInlineRef, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
import type { ChatBarState } from './composer/types'
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
import type { DroppedFile } from './hooks/use-composer-actions'
import { useFileDropZone } from './hooks/use-file-drop-zone'
import { SessionActionsMenu } from './sidebar/session-actions-menu'
import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading'
@@ -126,10 +126,7 @@ function ChatHeader({
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div
className="min-w-0 flex-1"
style={{
maxWidth:
'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)'
}}
style={{ maxWidth: 'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)' }}
>
<SessionActionsMenu
align="start"
@@ -302,25 +299,19 @@ export function ChatView({
})
// Drop files anywhere in the conversation area, not just on the composer
// input. In-app drags (project tree / gutter) carry workspace-relative paths
// the gateway resolves directly, so they stay inline `@file:` refs. OS/Finder
// drops carry absolute local paths that don't exist on a remote gateway (and
// images need byte upload for vision), so route them through the attachment
// pipeline — otherwise the local path leaks into the prompt verbatim.
// input — appending the same inline `@file:` ref chips the composer drop
// produces (vs. attachment cards) so both surfaces behave identically.
const onDropFiles = useCallback(
(candidates: DroppedFile[]) => {
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
const refs = droppedFileInlineRefs(inAppRefs, currentCwd)
const refs = candidates
.map(candidate => droppedFileInlineRef(candidate, currentCwd))
.filter((ref): ref is string => Boolean(ref))
if (refs.length) {
requestComposerInsert(refs.join(' '), { mode: 'inline', target: 'main' })
}
if (osDrops.length) {
void onAttachDroppedItems(osDrops)
}
},
[currentCwd, onAttachDroppedItems]
[currentCwd]
)
// Dropping a sidebar session inserts an @session link the agent can resolve
@@ -13,7 +13,6 @@ import { Streamdown } from 'streamdown'
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { PageLoader } from '@/components/page-loader'
import { translateNow, useI18n } from '@/i18n'
import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
import { cn } from '@/lib/utils'
import type { PreviewTarget } from '@/store/preview'
@@ -181,13 +180,15 @@ function looksBinaryBytes(bytes: Uint8Array) {
}
async function readTextPreview(filePath: string) {
try {
return await readDesktopFileText(filePath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (window.hermesDesktop.readFileText) {
try {
return await window.hermesDesktop.readFileText(filePath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
throw error
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
throw error
}
}
}
@@ -287,7 +288,7 @@ const MARKDOWN_COMPONENTS = {
function MarkdownPreview({ text }: { text: string }) {
return (
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground" data-selectable-text="true">
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground">
<Streamdown components={MARKDOWN_COMPONENTS} controls={false} mode="static" parseIncompleteMarkdown={false}>
{text}
</Streamdown>
@@ -383,10 +384,7 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
)
})}
</div>
<div
className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!"
data-selectable-text="true"
>
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!">
{selection && (
<div
aria-hidden
@@ -448,9 +446,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
try {
if (isImage) {
// Prefer bytes the caller already handed us (a pasted/dropped
// screenshot) over re-reading a path that may be transient/unreadable.
const dataUrl = target.dataUrl || (await readDesktopFileDataUrl(filePath))
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
if (active) {
setState({ dataUrl, loading: false })
@@ -488,7 +484,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
return () => {
active = false
}
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.dataUrl, target.language])
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
if (state.loading) {
return <PageLoader label={t.preview.loading} />
@@ -1,50 +1,11 @@
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PreviewPane } from './preview-pane'
describe('PreviewPane console state', () => {
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0))
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
})
afterEach(() => {
cleanup()
$connection.set(null)
vi.unstubAllGlobals()
})
it('does not watch backend-only remote filesystem previews locally', () => {
const watchPreviewFile = vi.fn(async () => ({ id: 'watch-1', path: '/remote/file.txt' }))
const onPreviewFileChanged = vi.fn(() => vi.fn())
$connection.set({ mode: 'remote' } as never)
vi.stubGlobal('window', {
...window,
hermesDesktop: {
onPreviewFileChanged,
watchPreviewFile
}
})
render(
<PreviewPane
setTitlebarToolGroup={vi.fn()}
target={{
kind: 'file',
label: 'file.txt',
path: '/remote/file.txt',
previewKind: 'text',
source: '/remote/file.txt',
url: 'file:///remote/file.txt'
}}
/>
)
expect(watchPreviewFile).not.toHaveBeenCalled()
expect(onPreviewFileChanged).not.toHaveBeenCalled()
})
it('does not rebuild the pane titlebar group for streamed console logs', () => {
@@ -5,7 +5,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
import { Bug } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -407,7 +406,6 @@ export function PreviewPane({
useEffect(() => {
if (
target.kind !== 'file' ||
isDesktopFsRemoteMode() ||
!window.hermesDesktop?.watchPreviewFile ||
!window.hermesDesktop?.onPreviewFileChanged
) {
@@ -14,8 +14,6 @@ import type { CronJob } from '@/types/hermes'
import { jobState, jobTitle, STATE_DOT } from '../../cron/job-state'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import { SidebarLoadMoreRow } from './load-more-row'
const INACTIVE_STATES = new Set(['completed', 'disabled', 'error', 'paused'])
// Recent runs shown in the inline quick-peek — enough to glance at history
@@ -26,11 +24,6 @@ const PEEK_RUN_LIMIT = 5
// open peek so a freshly-fired run shows up within a few seconds.
const PEEK_POLL_INTERVAL_MS = 8000
// Keep the section compact: show a few jobs up front, reveal more in larger
// steps on demand (mirrors the messaging sections in the sidebar).
const INITIAL_VISIBLE_JOBS = 3
const LOAD_MORE_STEP = 10
const relativeFmt = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' })
// Localized "in 5 min" / "2 hr ago" without hand-rolled strings — picks the
@@ -40,25 +33,17 @@ function relativeTime(targetMs: number, nowMs: number): string {
const abs = Math.abs(diff)
const sign = diff < 0 ? -1 : 1
if (abs < 60_000) {
return relativeFmt.format(sign * Math.round(abs / 1000), 'second')
}
if (abs < 60_000) {return relativeFmt.format(sign * Math.round(abs / 1000), 'second')}
if (abs < 3_600_000) {
return relativeFmt.format(sign * Math.round(abs / 60_000), 'minute')
}
if (abs < 3_600_000) {return relativeFmt.format(sign * Math.round(abs / 60_000), 'minute')}
if (abs < 86_400_000) {
return relativeFmt.format(sign * Math.round(abs / 3_600_000), 'hour')
}
if (abs < 86_400_000) {return relativeFmt.format(sign * Math.round(abs / 3_600_000), 'hour')}
return relativeFmt.format(sign * Math.round(abs / 86_400_000), 'day')
}
function nextRunMs(job: CronJob): null | number {
if (!job.next_run_at) {
return null
}
if (!job.next_run_at) {return null}
const ms = Date.parse(job.next_run_at)
@@ -69,9 +54,7 @@ function nextRunMs(job: CronJob): null | number {
// the timestamp is what tells them apart. Compact (no year, no seconds) for the
// narrow sidebar.
function formatRunTime(seconds?: null | number): string {
if (!seconds) {
return '—'
}
if (!seconds) {return '—'}
const date = new Date(seconds * 1000)
@@ -107,15 +90,11 @@ export function SidebarCronJobsSection({
const [nowMs, setNowMs] = useState(() => Date.now())
// Single-open inline peek so the section stays scannable.
const [peekJobId, setPeekJobId] = useState<null | string>(null)
// Rows revealed so far; starts compact, grows in steps via "load more".
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_JOBS)
// One clock for the whole section (rows are pure) so the countdowns tick
// without re-rendering the rest of the sidebar. Only runs while expanded.
useEffect(() => {
if (!open) {
return
}
if (!open) {return}
const id = window.setInterval(() => setNowMs(Date.now()), 1000)
@@ -129,25 +108,17 @@ export function SidebarCronJobsSection({
const an = nextRunMs(a)
const bn = nextRunMs(b)
if (an !== null && bn !== null && an !== bn) {
return an - bn
}
if (an !== null && bn !== null && an !== bn) {return an - bn}
if (an === null && bn !== null) {
return 1
}
if (an === null && bn !== null) {return 1}
if (an !== null && bn === null) {
return -1
}
if (an !== null && bn === null) {return -1}
return jobTitle(a).localeCompare(jobTitle(b))
})
}, [jobs])
const cap = Math.min(visibleCount, max)
const shown = sorted.slice(0, cap)
const hiddenCount = Math.min(sorted.length, max) - shown.length
const shown = sorted.slice(0, max)
// When capped, signal "50+" rather than implying the list is complete.
const countLabel = jobs.length > max ? `${max}+` : String(jobs.length)
@@ -168,7 +139,7 @@ export function SidebarCronJobsSection({
</button>
</div>
{open && (
<SidebarGroupContent className="flex max-h-72 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75 compact:max-h-none compact:overflow-visible">
<SidebarGroupContent className="flex max-h-72 shrink-0 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75">
{shown.map(job => (
<CronJobSidebarRow
expanded={peekJobId === job.id}
@@ -181,12 +152,6 @@ export function SidebarCronJobsSection({
onTrigger={() => onTriggerJob(job.id)}
/>
))}
{hiddenCount > 0 && (
<SidebarLoadMoreRow
onClick={() => setVisibleCount(count => count + LOAD_MORE_STEP)}
step={Math.min(LOAD_MORE_STEP, hiddenCount)}
/>
)}
</SidebarGroupContent>
)}
</SidebarGroup>
@@ -216,7 +181,11 @@ function CronJobSidebarRow({
const next = nextRunMs(job)
const label = jobTitle(job)
const meta = INACTIVE_STATES.has(state) ? (c.states[state] ?? state) : next !== null ? relativeTime(next, nowMs) : '—'
const meta = INACTIVE_STATES.has(state)
? (c.states[state] ?? state)
: next !== null
? relativeTime(next, nowMs)
: '—'
return (
<div>
@@ -288,7 +257,13 @@ function CronJobSidebarRow({
)
}
function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (sessionId: string) => void }) {
function CronJobSidebarRuns({
jobId,
onOpenRun
}: {
jobId: string
onOpenRun: (sessionId: string) => void
}) {
const { t } = useI18n()
const c = t.cron
const selectedSessionId = useStore($selectedStoredSessionId)
@@ -300,22 +275,16 @@ function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (s
const load = () =>
getCronJobRuns(jobId, PEEK_RUN_LIMIT)
.then(result => {
if (!cancelled) {
setRuns(result)
}
if (!cancelled) {setRuns(result)}
})
.catch(() => {
if (!cancelled) {
setRuns(prev => prev ?? [])
}
if (!cancelled) {setRuns(prev => prev ?? [])}
})
void load()
const intervalId = window.setInterval(() => {
if (document.visibilityState === 'visible') {
void load()
}
if (document.visibilityState === 'visible') {void load()}
}, PEEK_POLL_INTERVAL_MS)
return () => {
+279 -361
View File
@@ -48,7 +48,6 @@ import {
$pinnedSessionIds,
$sidebarAgentsGrouped,
$sidebarCronOpen,
$sidebarMessagingOpenIds,
$sidebarOpen,
$sidebarOverlayMounted,
$sidebarPinsOpen,
@@ -65,7 +64,6 @@ import {
setSidebarSessionOrderIds,
setSidebarWorkspaceOrderIds,
SIDEBAR_SESSIONS_PAGE_SIZE,
toggleSidebarMessagingOpen,
unpinSession
} from '@/store/layout'
import {
@@ -78,9 +76,6 @@ import {
} from '@/store/profile'
import {
$cronSessions,
$messagingPlatformTotals,
$messagingSessions,
$messagingTruncated,
$selectedStoredSessionId,
$sessionProfileTotals,
$sessions,
@@ -95,19 +90,12 @@ import { SidebarPanelLabel } from '../../shell/sidebar-label'
import type { SidebarNavItem } from '../../types'
import { SidebarCronJobsSection } from './cron-jobs-section'
import { SidebarLoadMoreRow } from './load-more-row'
import { ProfileRail } from './profile-switcher'
import { SidebarSessionRow } from './session-row'
import { VirtualSessionList } from './virtual-session-list'
const VIRTUALIZE_THRESHOLD = 25
// Non-session groups (messaging platforms) stay compact: show a few rows up
// front, reveal more in larger steps on demand. Keeps a busy platform from
// dominating the sidebar before the user asks to see it.
const NON_SESSION_INITIAL_ROWS = 3
const NON_SESSION_LOAD_STEP = 10
// Render the modifier key the user actually presses on this platform. The
// global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere
// else) in desktop-controller.tsx, but the hint should match muscle memory.
@@ -136,16 +124,7 @@ const WORKSPACE_PAGE = 5
// unified list scannable, then reveal/fetch more in N-sized steps on demand.
const PROFILE_INITIAL_PAGE = 5
const GROUP_DND_ID_PREFIX = 'group:'
// Two modes via the `compact` height variant (styles.css):
// tall → each section is shrink-0, capped, its own scroller; Sessions is flex-1.
// compact → COMPACT_FLAT drops the caps so the whole stack scrolls as one.
// Sections stay shrink-0 so none can be squeezed below its content and bleed onto
// the next — the flexbox `min-height: auto` overlap trap that caused the bug.
const COMPACT_FLAT = 'compact:max-h-none compact:overflow-visible'
// A non-session group's scroll body: own scroller when tall, flattened when compact.
const GROUP_BODY = cn('overflow-y-auto overscroll-contain', COMPACT_FLAT)
const LOCAL_SESSION_SOURCES = new Set(['cli', 'desktop', 'local', 'tui'])
const groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}`
@@ -162,25 +141,24 @@ function orderByIds<T>(items: T[], getId: (item: T) => string, orderIds: string[
const byId = new Map(items.map(item => [getId(item), item]))
const seen = new Set<string>()
const ordered: T[] = []
const out: T[] = []
for (const id of orderIds) {
const item = byId.get(id)
if (item) {
ordered.push(item)
out.push(item)
seen.add(id)
}
}
// Items missing from the persisted order are new since it was last
// reconciled. Callers pass recency-sorted lists (newest first), so surface
// these at the TOP instead of burying them beneath the saved order —
// otherwise a brand-new session sinks to the bottom of the sidebar and reads
// as "my latest session never showed up".
const fresh = items.filter(item => !seen.has(getId(item)))
for (const item of items) {
if (!seen.has(getId(item))) {
out.push(item)
}
}
return fresh.length ? [...fresh, ...ordered] : ordered
return out
}
function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
@@ -193,15 +171,17 @@ function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
}
const current = new Set(currentIds)
const retained = orderIds.filter(id => current.has(id))
const retainedSet = new Set(retained)
const next = orderIds.filter(id => current.has(id))
const known = new Set(next)
// New ids (absent from the saved order) are the newest sessions/groups; keep
// them ahead of the persisted order so fresh activity surfaces at the top of
// the sidebar rather than being appended to the bottom.
const fresh = currentIds.filter(id => !retainedSet.has(id))
for (const id of currentIds) {
if (!known.has(id)) {
next.push(id)
known.add(id)
}
}
return [...fresh, ...retained]
return next
}
function sameIds(left: string[], right: string[]) {
@@ -271,6 +251,43 @@ function workspaceGroupsFor(
return [...groups.values()]
}
function sourceSessionGroupsFor(sessions: SessionInfo[]): {
localSessions: SessionInfo[]
sourceGroups: SidebarSessionGroup[]
} {
const groups = new Map<string, SidebarSessionGroup>()
const localSessions: SessionInfo[] = []
for (const session of sessions) {
const sourceId = normalizeSessionSource(session.source)
if (!sourceId || LOCAL_SESSION_SOURCES.has(sourceId)) {
localSessions.push(session)
continue
}
const label = sessionSourceLabel(sourceId) ?? sourceId
const group = groups.get(sourceId) ?? {
id: `source:${sourceId}`,
label,
mode: 'source',
path: null,
sessions: [],
sourceId
}
group.sessions.push(session)
groups.set(sourceId, group)
}
return {
localSessions,
sourceGroups: [...groups.values()].sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
}
}
function useSortableBindings(id: string) {
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })
@@ -292,7 +309,6 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
onNavigate: (item: SidebarNavItem) => void
onLoadMoreSessions: () => void
onLoadMoreProfileSessions?: (profile: string) => Promise<void> | void
onLoadMoreMessaging?: (platform: string) => Promise<void> | void
onResumeSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
onArchiveSession: (sessionId: string) => void
@@ -306,7 +322,6 @@ export function ChatSidebar({
onNavigate,
onLoadMoreSessions,
onLoadMoreProfileSessions,
onLoadMoreMessaging,
onResumeSession,
onDeleteSession,
onArchiveSession,
@@ -330,9 +345,6 @@ export function ChatSidebar({
const sessions = useStore($sessions)
const cronSessions = useStore($cronSessions)
const cronJobs = useStore($cronJobs)
const messagingSessions = useStore($messagingSessions)
const messagingPlatformTotals = useStore($messagingPlatformTotals)
const messagingTruncated = useStore($messagingTruncated)
const sessionsLoading = useStore($sessionsLoading)
const sessionsTotal = useStore($sessionsTotal)
const sessionProfileTotals = useStore($sessionProfileTotals)
@@ -352,10 +364,6 @@ export function ChatSidebar({
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
const [profileLoadMorePending, setProfileLoadMorePending] = useState<Record<string, boolean>>({})
const [messagingLoadMorePending, setMessagingLoadMorePending] = useState<Record<string, boolean>>({})
const messagingOpenIds = useStore($sidebarMessagingOpenIds)
// Per-platform count of rows currently revealed (starts at NON_SESSION_INITIAL_ROWS).
const [messagingVisible, setMessagingVisible] = useState<Record<string, number>>({})
const searchInputRef = useRef<HTMLInputElement>(null)
const trimmedQuery = searchQuery.trim()
@@ -521,12 +529,24 @@ export function ChatSidebar({
[unpinnedAgentSessions, agentOrderIds]
)
// Recents are local-only: messaging-platform sessions are fetched as their
// own slice ($messagingSessions) and rendered in self-managed per-platform
// sections below, so there is no source-grouping magic to untangle here.
const { localSessions: localAgentSessions, sourceGroups } = useMemo(
() => sourceSessionGroupsFor(agentSessions),
[agentSessions]
)
const orderedSourceGroups = useMemo(
() => orderByIds(sourceGroups, g => g.id, workspaceOrderIds),
[sourceGroups, workspaceOrderIds]
)
const agentGroups = useMemo(
() => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
[agentSessions, s.noWorkspace, workspaceOrderIds]
() =>
orderByIds(
workspaceGroupsFor(localAgentSessions, s.noWorkspace, { preserveSessionOrder: sourceGroups.length > 0 }),
g => g.id,
workspaceOrderIds
),
[localAgentSessions, s.noWorkspace, sourceGroups.length, workspaceOrderIds]
)
const loadMoreForProfileGroup = useCallback(
@@ -544,76 +564,6 @@ export function ChatSidebar({
[onLoadMoreProfileSessions]
)
const loadMoreForMessaging = useCallback(
(platform: string) => {
if (!onLoadMoreMessaging) {
return
}
setMessagingLoadMorePending(prev => ({ ...prev, [platform]: true }))
void Promise.resolve(onLoadMoreMessaging(platform))
.catch(() => undefined)
.finally(() => setMessagingLoadMorePending(({ [platform]: _done, ...rest }) => rest))
},
[onLoadMoreMessaging]
)
// Reveal another batch of a platform's rows; fetch from the backend too if we
// run past what's loaded and more remain on disk.
const revealMoreMessaging = (platform: string, loaded: number, hasMore: boolean) => {
const next = (messagingVisible[platform] ?? NON_SESSION_INITIAL_ROWS) + NON_SESSION_LOAD_STEP
setMessagingVisible(prev => ({ ...prev, [platform]: next }))
if (next > loaded && hasMore) {
loadMoreForMessaging(platform)
}
}
// Each messaging platform is its own self-managed section: split the
// separately-fetched messaging slice by source, newest platform first, rows
// within a platform by recency. Per-platform totals (when a "load more" has
// resolved them) drive the count + whether more remain on disk.
const messagingGroups = useMemo<MessagingSection[]>(() => {
if (!messagingSessions.length) {
return []
}
const bySource = new Map<string, SessionInfo[]>()
for (const session of messagingSessions) {
const sourceId = normalizeSessionSource(session.source)
if (!sourceId) {
continue
}
const list = bySource.get(sourceId) ?? []
list.push(session)
bySource.set(sourceId, list)
}
return [...bySource.entries()]
.map(([sourceId, list]) => {
const ordered = [...list].sort((a, b) => sessionTime(b) - sessionTime(a))
const known = messagingPlatformTotals[sourceId]
const total = Math.max(ordered.length, known ?? 0)
return {
// Known exact total → more exist iff total exceeds loaded; otherwise
// the seed fetch was capped, so assume more until a per-platform load
// resolves the count.
hasMore: known != null ? known > ordered.length : messagingTruncated,
label: sessionSourceLabel(sourceId) ?? sourceId,
sessions: ordered,
sourceId,
total
}
})
.sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
}, [messagingSessions, messagingPlatformTotals, messagingTruncated])
// ALL-profiles view: one collapsible group per profile, color on the header
// (not on every row). Default profile floats to the top, the rest alpha.
const profileGroups = useMemo<SidebarSessionGroup[] | undefined>(() => {
@@ -660,34 +610,37 @@ export function ChatSidebar({
sessionProfileTotals
])
const displayAgentSessions = agentSessions
const displayAgentSessions = sourceGroups.length ? localAgentSessions : agentSessions
// Pagination is scope-aware. In "All profiles" mode it tracks the global
// unified set. When scoped to one profile it must compare that profile's own
// loaded rows against that profile's total — otherwise a huge default profile
// keeps "Load more" stuck on while you browse a small one (the aggregator's
// total sums every profile). Per-profile totals come from the aggregator
// (children excluded); fall back to the global total / loaded count.
const loadedSessionCount = showAllProfiles ? sessions.length : visibleSessions.length
const scopedProfileTotal = showAllProfiles ? undefined : sessionProfileTotals[profileScope]
const displayAgentGroups = useMemo(() => {
if (orderedSourceGroups.length) {
const localGroups = agentsGrouped
? agentGroups
: localAgentSessions.length
? [
{
id: 'local-sessions',
label: 'Local',
mode: 'workspace' as const,
path: null,
sessions: localAgentSessions
}
]
: []
const knownSessionTotal = Math.max(
showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount),
loadedSessionCount
)
return orderByIds([...orderedSourceGroups, ...localGroups], g => g.id, workspaceOrderIds)
}
const hasMoreSessions = knownSessionTotal > loadedSessionCount
const remainingSessionCount = Math.max(0, knownSessionTotal - loadedSessionCount)
const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
const displayAgentGroups = showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
// The recents list owns its own (virtualized) scroll container only when it's a
// long flat list. In that case it must keep its scroller even in short mode, so
// we don't flatten it (flattening would defeat virtualization). Short flat lists
// and grouped views flatten into the single outer scroll instead.
const recentsVirtualizes = !displayAgentGroups?.length && displayAgentSessions.length >= VIRTUALIZE_THRESHOLD
return showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
}, [
agentGroups,
agentsGrouped,
localAgentSessions,
orderedSourceGroups,
profileGroups,
showAllProfiles,
workspaceOrderIds
])
useEffect(() => {
if (!displayAgentGroups?.length || showAllProfiles) {
@@ -708,6 +661,25 @@ export function ChatSidebar({
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
// Pagination is scope-aware. In "All profiles" mode it tracks the global
// unified set. When scoped to one profile it must compare that profile's own
// loaded rows against that profile's total — otherwise a huge default profile
// keeps "Load more" stuck on while you browse a small one (the aggregator's
// total sums every profile). Per-profile totals come from the aggregator
// (children excluded); fall back to the global total / loaded count.
const loadedSessionCount = showAllProfiles ? sessions.length : visibleSessions.length
const scopedProfileTotal = showAllProfiles ? undefined : sessionProfileTotals[profileScope]
const knownSessionTotal = Math.max(
showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount),
loadedSessionCount
)
const hasMoreSessions = knownSessionTotal > loadedSessionCount
const remainingSessionCount = Math.max(0, knownSessionTotal - loadedSessionCount)
const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) {
return
@@ -797,14 +769,7 @@ export function ChatSidebar({
<SidebarMenuButton
aria-disabled={!isInteractive}
className={cn(
// no-drag: these rows sit directly under the titlebar's
// [-webkit-app-region:drag] strips (app-shell.tsx), with only
// 6px of clearance. Drag regions win hit-testing over DOM
// (pointer-events can't override), and on Linux/WSLg the
// resolved region has been observed to swallow clicks on the
// top rows. Same carve-out as USER_BUBBLE_BASE_CLASS in
// thread.tsx.
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out [-webkit-app-region:no-drag] hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
active &&
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
!isInteractive &&
@@ -827,7 +792,9 @@ export function ChatSidebar({
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
{contentVisible && (
<>
<span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span>
<span className="min-w-0 flex-1 truncate">
{s.nav[item.id] ?? item.label}
</span>
{isNewSession && (
<KbdGroup
className={cn('ml-auto', newSessionKbdFlash && 'opacity-100!')}
@@ -856,191 +823,135 @@ export function ChatSidebar({
</div>
)}
{contentVisible && showSessionSections && (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75">
{trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
emptyState={
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
{s.noMatch(trimmedQuery)}
</div>
}
label={s.results}
labelMeta={String(searchResults.length)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
onToggle={() => undefined}
onTogglePin={pinSession}
open
pinned={false}
rootClassName="min-h-32 flex-1 overflow-hidden p-0"
sessions={searchResults}
workingSessionIdSet={workingSessionIdSet}
/>
{contentVisible && showSessionSections && trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
emptyState={
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
{s.noMatch(trimmedQuery)}
</div>
}
label={s.results}
labelMeta={String(searchResults.length)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
onToggle={() => undefined}
onTogglePin={pinSession}
open
pinned={false}
rootClassName="min-h-0 flex-1 p-0"
sessions={searchResults}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{contentVisible && showSessionSections && !trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
dndSensors={dndSensors}
emptyState={<SidebarPinnedEmptyState />}
label={s.pinned}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onReorder={handlePinnedDragEnd}
onResumeSession={onResumeSession}
onToggle={() => setSidebarPinsOpen(!pinsOpen)}
onTogglePin={unpinSession}
open={pinsOpen}
pinned
rootClassName="shrink-0 p-0 pb-1"
sessions={pinnedSessions}
sortable={pinnedSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{contentVisible && showSessionSections && !trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName={cn(
'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75',
// Separate profile sections clearly in the ALL view; rows inside
// each group keep their own tight gap-px rhythm.
showAllProfiles ? 'gap-3' : 'gap-px'
)}
dndSensors={dndSensors}
emptyState={showSessionSkeletons ? <SidebarSessionSkeletons /> : <SidebarAllPinnedState />}
footer={
// Hide "load more" only when workspace-grouped (those groups page
// themselves). ALL-profiles now pages per-profile from each profile
// header; the global footer only applies to non-ALL views.
!showAllProfiles && !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
<SidebarLoadMoreRow
loading={sessionsLoading}
onClick={onLoadMoreSessions}
step={Math.min(SIDEBAR_SESSIONS_PAGE_SIZE, remainingSessionCount)}
/>
) : null
}
forceEmptyState={showSessionSkeletons}
groups={displayAgentGroups}
headerAction={
// Always reserve the icon-xs (size-6) slot so the header keeps the
// same height whether or not the toggle renders — otherwise the
// "Sessions" label jumps when switching to the ALL-profiles view.
// Grouping operates on unpinned recents; if everything is pinned
// the toggle does nothing, and it's irrelevant in the ALL-profiles
// view (always grouped by profile), so hide the button (not the slot).
<div className="grid size-6 shrink-0 place-items-center">
{!showAllProfiles && localAgentSessions.length > 0 ? (
<Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
<Button
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
className={cn(
'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
setSidebarAgentsGrouped(!agentsGrouped)
}}
size="icon-xs"
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
</Button>
</Tip>
) : null}
</div>
}
label={s.sessions}
labelMeta={recentsMeta}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace}
onReorder={showAllProfiles ? undefined : handleAgentDragEnd}
onResumeSession={onResumeSession}
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
onTogglePin={pinSession}
open={agentsOpen}
pinned={false}
rootClassName="min-h-0 flex-1 p-0"
sessions={displayAgentSessions}
sortable={!showAllProfiles && agentSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{!trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName={cn('flex max-h-44 flex-col gap-px rounded-lg pb-2 pt-1', GROUP_BODY)}
dndSensors={dndSensors}
emptyState={<SidebarPinnedEmptyState />}
label={s.pinned}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onReorder={handlePinnedDragEnd}
onResumeSession={onResumeSession}
onToggle={() => setSidebarPinsOpen(!pinsOpen)}
onTogglePin={unpinSession}
open={pinsOpen}
pinned
rootClassName="shrink-0 p-0 pb-1"
sessions={pinnedSessions}
sortable={pinnedSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{!trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName={cn(
'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75',
// Separate profile sections clearly in the ALL view; rows inside
// each group keep their own tight gap-px rhythm.
showAllProfiles ? 'gap-3' : 'gap-px',
// Flatten into the single scroll when compact — unless this is the
// virtualized long list, which must keep its own scroller.
!recentsVirtualizes && COMPACT_FLAT
)}
dndSensors={dndSensors}
emptyState={showSessionSkeletons ? <SidebarSessionSkeletons /> : <SidebarAllPinnedState />}
footer={
// Hide "load more" only when workspace-grouped (those groups page
// themselves). ALL-profiles now pages per-profile from each profile
// header; the global footer only applies to non-ALL views.
!showAllProfiles && !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
<SidebarLoadMoreRow
loading={sessionsLoading}
onClick={onLoadMoreSessions}
step={Math.min(SIDEBAR_SESSIONS_PAGE_SIZE, remainingSessionCount)}
/>
) : null
}
forceEmptyState={showSessionSkeletons}
groups={displayAgentGroups}
headerAction={
// Always reserve the icon-xs (size-6) slot so the header keeps the
// same height whether or not the toggle renders — otherwise the
// "Sessions" label jumps when switching to the ALL-profiles view.
// Grouping operates on unpinned recents; if everything is pinned
// the toggle does nothing, and it's irrelevant in the ALL-profiles
// view (always grouped by profile), so hide the button (not the slot).
<div className="grid size-6 shrink-0 place-items-center">
{!showAllProfiles && agentSessions.length > 0 ? (
<Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
<Button
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
className={cn(
'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
setSidebarAgentsGrouped(!agentsGrouped)
}}
size="icon-xs"
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
</Button>
</Tip>
) : null}
</div>
}
label={s.sessions}
labelMeta={recentsMeta}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace}
onReorder={showAllProfiles ? undefined : handleAgentDragEnd}
onResumeSession={onResumeSession}
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
onTogglePin={pinSession}
open={agentsOpen}
pinned={false}
rootClassName={cn(
'min-h-32 flex-1 overflow-hidden p-0',
!recentsVirtualizes && 'compact:min-h-0 compact:flex-none compact:overflow-visible'
)}
sessions={displayAgentSessions}
sortable={!showAllProfiles && agentSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{!trimmedQuery &&
messagingGroups.map(group => {
const visible = messagingVisible[group.sourceId] ?? NON_SESSION_INITIAL_ROWS
const shownSessions = group.sessions.slice(0, visible)
// More to show if rows are hidden behind the cap, or the backend
// still has older threads on disk.
const canRevealMore = visible < group.sessions.length || group.hasMore
return (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName={cn('flex max-h-56 flex-col gap-px pb-1.75', GROUP_BODY)}
emptyState={null}
footer={
canRevealMore ? (
<SidebarLoadMoreRow
loading={Boolean(messagingLoadMorePending[group.sourceId])}
onClick={() => revealMoreMessaging(group.sourceId, group.sessions.length, group.hasMore)}
step={Math.min(NON_SESSION_LOAD_STEP, Math.max(0, group.total - shownSessions.length))}
/>
) : null
}
key={group.sourceId}
label={group.label}
labelIcon={
<PlatformAvatar
className="size-4 rounded-[4px] text-[0.5625rem] [&_svg]:size-3"
platformId={group.sourceId}
platformName={group.label}
/>
}
labelMeta={countLabel(group.sessions.length, group.total)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
onToggle={() => toggleSidebarMessagingOpen(group.sourceId)}
onTogglePin={pinSession}
open={messagingOpenIds.includes(group.sourceId)}
pinned={false}
rootClassName="shrink-0 p-0"
sessions={shownSessions}
workingSessionIdSet={workingSessionIdSet}
/>
)
})}
{!trimmedQuery && cronJobs.length > 0 && (
<SidebarCronJobsSection
jobs={cronJobs}
label={s.cronJobs}
onManageJob={onManageCronJob}
onOpenRun={onResumeSession}
onToggle={() => setSidebarCronOpen(!cronOpen)}
onTriggerJob={onTriggerCronJob}
open={cronOpen}
/>
)}
</div>
{contentVisible && !trimmedQuery && cronJobs.length > 0 && (
<SidebarCronJobsSection
jobs={cronJobs}
label={s.cronJobs}
onManageJob={onManageCronJob}
onOpenRun={onResumeSession}
onToggle={() => setSidebarCronOpen(!cronOpen)}
onTriggerJob={onTriggerCronJob}
open={cronOpen}
/>
)}
{contentVisible && !showSessionSections && <div className="min-h-0 flex-1" />}
@@ -1061,10 +972,9 @@ interface SidebarSectionHeaderProps {
onToggle: () => void
action?: React.ReactNode
meta?: React.ReactNode
icon?: React.ReactNode
}
function SidebarSectionHeader({ label, open, onToggle, action, meta, icon }: SidebarSectionHeaderProps) {
function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSectionHeaderProps) {
return (
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
<button
@@ -1072,7 +982,6 @@ function SidebarSectionHeader({ label, open, onToggle, action, meta, icon }: Sid
onClick={onToggle}
type="button"
>
{icon}
<SidebarPanelLabel>{label}</SidebarPanelLabel>
{meta && <SidebarCount>{meta}</SidebarCount>}
<DisclosureCaret
@@ -1135,14 +1044,6 @@ interface SidebarSessionGroup {
totalCount?: number
}
interface MessagingSection {
sourceId: string
label: string
sessions: SessionInfo[]
total: number
hasMore: boolean
}
interface SidebarSessionsSectionProps {
label: string
open: boolean
@@ -1164,7 +1065,6 @@ interface SidebarSessionsSectionProps {
footer?: React.ReactNode
groups?: SidebarSessionGroup[]
labelMeta?: React.ReactNode
labelIcon?: React.ReactNode
sortable?: boolean
onReorder?: (event: DragEndEvent) => void
dndSensors?: ReturnType<typeof useSensors>
@@ -1191,7 +1091,6 @@ function SidebarSessionsSection({
footer,
groups,
labelMeta,
labelIcon,
sortable = false,
onReorder,
dndSensors
@@ -1282,7 +1181,6 @@ function SidebarSessionsSection({
inner = (
<VirtualSessionList
activeSessionId={activeSessionId}
className={contentClassName}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
@@ -1311,14 +1209,7 @@ function SidebarSessionsSection({
return (
<SidebarGroup className={rootClassName}>
<SidebarSectionHeader
action={headerAction}
icon={labelIcon}
label={label}
meta={labelMeta}
onToggle={onToggle}
open={open}
/>
<SidebarSectionHeader action={headerAction} label={label} meta={labelMeta} onToggle={onToggle} open={open} />
{open && (
<SidebarGroupContent className={resolvedContentClassName}>
{body}
@@ -1507,3 +1398,30 @@ interface SortableSessionRowProps {
function SortableSidebarSessionRow(props: SortableSessionRowProps) {
return <SidebarSessionRow {...props} {...useSortableBindings(props.session.id)} />
}
interface SidebarLoadMoreRowProps {
loading: boolean
onClick: () => void
step: number
}
function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps) {
const { t } = useI18n()
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
return (
<button
className="flex min-h-5 items-center gap-1.5 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
disabled={loading}
onClick={onClick}
type="button"
>
{/* Seat the icon in the same w-3.5 column session rows use for their dot
so the chevron + label line up with the rows above. */}
<span className="grid w-3.5 shrink-0 place-items-center">
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
</span>
<span>{label}</span>
</button>
)
}
@@ -1,30 +0,0 @@
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
interface SidebarLoadMoreRowProps {
step: number
onClick: () => void
loading?: boolean
}
// "Load N more" affordance shared by the recents, messaging, and cron sections.
// The chevron sits in the same w-3.5 column the rows use for their dot, so it
// lines up with the list above.
export function SidebarLoadMoreRow({ step, onClick, loading = false }: SidebarLoadMoreRowProps) {
const { t } = useI18n()
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
return (
<button
className="flex min-h-5 items-center gap-1.5 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
disabled={loading}
onClick={onClick}
type="button"
>
<span className="grid w-3.5 shrink-0 place-items-center">
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
</span>
<span>{label}</span>
</button>
)
}
@@ -83,9 +83,8 @@ const stepThroughCells: Modifier = ({ containerNodeRect, draggingNodeRect, trans
// Arc-Spaces-style profile rail at the sidebar foot: a default↔all toggle pinned
// left, the colored named profiles scrolling between, and Manage pinned right.
// The active profile pops in its own color — the "where am I" cue. Single-
// profile users see the "+" (create their first profile) and the Manage
// overflow (edit the default profile's SOUL.md); the colored named squares
// and the default↔all toggle only appear once a second profile exists.
// profile users see only the "+" (create their first profile); everything else
// appears once a second profile exists.
export function ProfileRail() {
const { t } = useI18n()
const p = t.profiles
@@ -269,11 +268,9 @@ export function ProfileRail() {
</Tip>
</div>
{/* Always reachable, even with only the default profile: the manage
overlay is the only place to edit a profile's SOUL.md, and a
single-profile user must be able to edit the default's persona
without first creating a throwaway second profile. */}
<ProfilePill active={false} glyph="ellipsis" label={p.manageProfiles} onSelect={() => navigate(PROFILES_ROUTE)} />
{multiProfile && (
<ProfilePill active={false} glyph="ellipsis" label={p.manageProfiles} onSelect={() => navigate(PROFILES_ROUTE)} />
)}
{/* Land in the new profile on a fresh chat (selectProfile triggers the
new-session reset), not stuck on the session you were just in. */}
@@ -21,7 +21,6 @@ import { triggerHaptic } from '@/lib/haptics'
import { exportSession } from '@/lib/session-export'
import { notify, notifyError } from '@/store/notifications'
import { setSessions } from '@/store/session'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
interface SessionActions {
sessionId: string
@@ -69,26 +68,13 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
void writeClipboardText(sessionId).catch(err => notifyError(err, r.copyIdFailed))
}
},
...(canOpenSessionWindow()
? [
{
disabled: !sessionId,
icon: 'link-external',
label: r.newWindow,
onSelect: () => {
triggerHaptic('selection')
void openSessionInNewWindow(sessionId)
}
}
]
: []),
{
disabled: !sessionId,
icon: 'cloud-download',
label: r.export,
onSelect: () => {
triggerHaptic('selection')
void exportSession(sessionId, { profile, title })
void exportSession(sessionId, { title })
}
},
{
@@ -2,18 +2,14 @@ import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import type { SessionInfo } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { cn } from '@/lib/utils'
import { $attentionSessionIds } from '@/store/session'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
@@ -71,11 +67,6 @@ export function SidebarSessionRow({
const title = sessionTitle(session)
const age = formatAge(session.last_active || session.started_at, r)
const handleLabel = `Reorder ${title}`
// A handed-off session's live source is local, but it originated on a
// messaging platform — surface that origin as a small badge so e.g. a
// Telegram thread continued here still reads as Telegram.
const handoffSource = handoffOriginSource(session.handoff_state, session.handoff_platform)
const handoffLabel = handoffSource ? sessionSourceLabel(handoffSource) ?? handoffSource : null
// Subscribe per-row (the leaf) instead of drilling a set through the list —
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
// session is waiting on the user.
@@ -133,15 +124,11 @@ export function SidebarSessionRow({
return
}
// ⌘-click (mac) / ⌃-click (win/linux) pops the chat into its own
// window — the universal "open in a new window" gesture. Archive
// lives in the row's ⋯ and right-click menus. Falls through to a
// normal resume when standalone windows aren't available (web embed).
if ((event.metaKey || event.ctrlKey) && canOpenSessionWindow()) {
if (event.metaKey || event.ctrlKey) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
void openSessionInNewWindow(session.id)
onArchive()
return
}
@@ -192,15 +179,6 @@ export function SidebarSessionRow({
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
)}
{handoffSource && handoffLabel ? (
<Tip label={r.handoffOrigin(handoffLabel)}>
<PlatformAvatar
className="size-4 rounded-[4px] text-[0.5rem] [&_svg]:size-2.5"
platformId={handoffSource}
platformName={handoffLabel}
/>
</Tip>
) : null}
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}
</span>
+50 -189
View File
@@ -4,22 +4,19 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud'
import { setTerminalTakeover } from '@/app/right-sidebar/store'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { KbdGroup } from '@/components/ui/kbd'
import { getHermesConfigRecord, listAllProfileSessions } from '@/hermes'
import { getHermesConfigRecord, listSessions } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import {
Activity,
Archive,
BarChart3,
Check,
ChevronLeft,
ChevronRight,
Clock,
Cpu,
Download,
Globe,
type IconComponent,
Info,
@@ -33,18 +30,13 @@ import {
Settings,
Settings2,
Sun,
Terminal,
Users,
Wrench,
Zap
} from '@/lib/icons'
import { comboTokens } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
import { $bindings } from '@/store/keybinds'
import { luminance } from '@/themes/color'
import { type ThemeMode, useTheme } from '@/themes/context'
import { isUserTheme, resolveTheme } from '@/themes/user-themes'
import {
AGENTS_ROUTE,
@@ -62,11 +54,8 @@ import { FIELD_LABELS, SECTIONS } from '../settings/constants'
import { fieldCopyForSchemaKey } from '../settings/field-copy'
import { prettyName } from '../settings/helpers'
import { MarketplaceThemePage } from './marketplace-theme-page'
interface PaletteItem {
/** Keybind action id — its live combo renders as a hotkey hint. */
action?: string
active?: boolean
icon: IconComponent
id: string
/** Keep the palette open after running (live-preview pickers like theme/mode). */
@@ -80,16 +69,10 @@ interface PaletteItem {
}
interface PaletteGroup {
/** Optional: a headingless group renders as a bare action row (e.g. the
* "Install theme…" entry pinned atop the theme picker). */
heading?: string
heading: string
items: PaletteItem[]
}
// Nested page → its parent, so Back / Esc step up one level instead of closing
// the palette. Pages absent here go straight back to the root list.
const PAGE_PARENTS: Record<string, string> = { 'install-theme': 'theme' }
/** A nested page reachable from a root item via `to`. */
interface PalettePage {
groups: PaletteGroup[]
@@ -103,23 +86,7 @@ interface SessionEntry {
title: string
}
// cmdk defaults to fuzzy subsequence scoring, so "color" matches anything with
// c…o…l…o…r scattered across it. Use case-insensitive multi-term substring
// matching instead: every typed word must literally appear in the item's
// value/keywords, which keeps results tight and predictable.
const paletteFilter = (value: string, search: string, keywords?: string[]): number => {
const needle = search.trim().toLowerCase()
if (!needle) {
return 1
}
const haystack = `${value} ${keywords?.join(' ') ?? ''}`.toLowerCase()
return needle.split(/\s+/).every(term => haystack.includes(term)) ? 1 : 0
}
type SessionRow = Awaited<ReturnType<typeof listAllProfileSessions>>['sessions'][number]
type SessionRow = Awaited<ReturnType<typeof listSessions>>['sessions'][number]
const toSessionEntry = (session: SessionRow): SessionEntry => ({
id: session.id,
@@ -179,32 +146,11 @@ const THEME_MODES: ReadonlyArray<{ icon: IconComponent; mode: ThemeMode }> = [
{ icon: Monitor, mode: 'system' }
]
// Which Light/Dark groups a theme belongs in. Built-ins render in both modes
// (the engine synthesises the missing side). Imported VS Code themes only carry
// the variant(s) the extension shipped — a single dark theme like Dracula lives
// under Dark only, while a GitHub/Solarized family (light + dark) lives in both.
function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean {
if (!isUserTheme(name)) {
return true
}
const resolved = resolveTheme(name)
if (!resolved) {
return true
}
const background = target === 'dark' ? (resolved.darkColors ?? resolved.colors).background : resolved.colors.background
return target === 'dark' ? luminance(background) <= 0.5 : luminance(background) > 0.5
}
export function CommandPalette() {
const { t } = useI18n()
const open = useStore($commandPaletteOpen)
const bindings = useStore($bindings)
const navigate = useNavigate()
const { availableThemes, resolvedMode, setMode, setTheme, themeName } = useTheme()
const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme()
const [search, setSearch] = useState('')
const [page, setPage] = useState<string | null>(null)
@@ -218,13 +164,13 @@ export function CommandPalette() {
const sessionsQuery = useQuery({
queryKey: ['command-palette', 'sessions'],
queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
queryFn: () => listSessions(200, 1, 'exclude'),
enabled: open
})
const archivedQuery = useQuery({
queryKey: ['command-palette', 'archived'],
queryFn: () => listAllProfileSessions(200, 0, 'only'),
queryFn: () => listSessions(200, 0, 'only'),
enabled: open
})
@@ -248,19 +194,10 @@ export function CommandPalette() {
}, [open])
const go = useCallback((path: string) => () => navigate(path), [navigate])
// Step up one nested page (or back to the root list), clearing the filter so
// the parent page doesn't reopen mid-search.
const goBack = useCallback(() => {
setSearch('')
setPage(prev => (prev ? (PAGE_PARENTS[prev] ?? null) : null))
}, [])
const settingsSectionLabel = useCallback(
(section: (typeof SECTIONS)[number]) => t.settings.sections[section.id] ?? section.label,
[t.settings.sections]
)
const configFieldLabel = useCallback(
(key: string) =>
fieldCopyForSchemaKey(t.settings.fieldLabels, key) ??
@@ -277,61 +214,20 @@ export function CommandPalette() {
{
heading: cc.goTo,
items: [
{ icon: Plus, id: 'nav-new', keywords: ['chat', 'create'], label: cc.nav.newChat.title, run: go(NEW_CHAT_ROUTE) },
{ icon: Settings, id: 'nav-settings', label: cc.nav.settings.title, run: go(SETTINGS_ROUTE) },
{
action: 'session.new',
icon: Plus,
id: 'nav-new',
keywords: ['chat', 'create'],
label: cc.nav.newChat.title,
run: go(NEW_CHAT_ROUTE)
},
{
action: 'view.showTerminal',
icon: Terminal,
id: 'nav-terminal',
keywords: ['terminal', 'shell', 'console'],
label: t.keybinds.actions['view.showTerminal'],
run: () => setTerminalTakeover(true)
},
{
action: 'nav.settings',
icon: Settings,
id: 'nav-settings',
label: cc.nav.settings.title,
run: go(SETTINGS_ROUTE)
},
{
action: 'nav.skills',
icon: Wrench,
id: 'nav-skills',
keywords: ['tools', 'toolsets'],
label: cc.nav.skills.title,
run: go(SKILLS_ROUTE)
},
{
action: 'nav.messaging',
icon: MessageCircle,
id: 'nav-messaging',
label: cc.nav.messaging.title,
run: go(MESSAGING_ROUTE)
},
{
action: 'nav.artifacts',
icon: Package,
id: 'nav-artifacts',
label: cc.nav.artifacts.title,
run: go(ARTIFACTS_ROUTE)
},
{
action: 'nav.cron',
icon: Clock,
id: 'nav-cron',
keywords: ['schedule', 'jobs'],
label: t.shell.statusbar.cron,
run: go(CRON_ROUTE)
},
{ action: 'nav.profiles', icon: Users, id: 'nav-profiles', label: t.profiles.title, run: go(PROFILES_ROUTE) },
{ action: 'nav.agents', icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }
{ icon: MessageCircle, id: 'nav-messaging', label: cc.nav.messaging.title, run: go(MESSAGING_ROUTE) },
{ icon: Package, id: 'nav-artifacts', label: cc.nav.artifacts.title, run: go(ARTIFACTS_ROUTE) },
{ icon: Clock, id: 'nav-cron', keywords: ['schedule', 'jobs'], label: t.shell.statusbar.cron, run: go(CRON_ROUTE) },
{ icon: Users, id: 'nav-profiles', label: t.profiles.title, run: go(PROFILES_ROUTE) },
{ icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }
]
},
{
@@ -477,40 +373,24 @@ export function CommandPalette() {
theme: {
title: t.settings.appearance.themeTitle,
placeholder: t.settings.appearance.themeDesc,
groups: [
// Pinned at the top: drills into the Marketplace browser.
{
items: [
{
icon: Download,
id: 'theme-install',
keywords: ['install', 'marketplace', 'vscode', 'vs code', 'download', 'new', 'color'],
label: t.commandCenter.installTheme.title,
to: 'install-theme'
}
]
},
// Built-ins and imported families list under the mode(s) they support;
// picking sets skin + mode at once. A multi-variant import (GitHub,
// Solarized) appears in both groups and switches variants with the mode.
...(['light', 'dark'] as const).map(groupMode => ({
heading: groupMode === 'light' ? t.settings.modeOptions.light.label : t.settings.modeOptions.dark.label,
items: availableThemes
.filter(theme => themeSupportsMode(theme.name, groupMode))
.map(theme => ({
active: themeName === theme.name && resolvedMode === groupMode,
icon: groupMode === 'light' ? Sun : Moon,
id: `theme-${theme.name}-${groupMode}`,
keepOpen: true,
keywords: ['theme', 'appearance', 'palette', groupMode, theme.label, theme.description ?? ''],
label: theme.label,
run: () => {
setTheme(theme.name)
setMode(groupMode)
}
}))
// Skins aren't inherently light/dark — the same skin renders in either
// mode. Group by appearance so picking an entry sets skin + mode at
// once, and keep the palette open so each pick previews live.
groups: (['light', 'dark'] as const).map(groupMode => ({
heading: groupMode === 'light' ? t.settings.modeOptions.light.label : t.settings.modeOptions.dark.label,
items: availableThemes.map(theme => ({
active: themeName === theme.name && resolvedMode === groupMode,
icon: groupMode === 'light' ? Sun : Moon,
id: `theme-${theme.name}-${groupMode}`,
keepOpen: true,
keywords: ['theme', 'appearance', 'palette', groupMode, theme.label, theme.description ?? ''],
label: theme.label,
run: () => {
setTheme(theme.name)
setMode(groupMode)
}
}))
]
}))
},
'color-mode': {
title: t.settings.appearance.colorMode,
@@ -519,6 +399,7 @@ export function CommandPalette() {
{
heading: t.settings.appearance.colorMode,
items: THEME_MODES.map(entry => ({
active: mode === entry.mode,
icon: entry.icon,
id: `mode-${entry.mode}`,
keepOpen: true,
@@ -528,16 +409,9 @@ export function CommandPalette() {
}))
}
]
},
// Server-driven page: items come from the Marketplace, rendered by
// <MarketplaceThemePage> (loader + live search + per-row install).
'install-theme': {
title: t.commandCenter.installTheme.title,
placeholder: t.commandCenter.installTheme.placeholder,
groups: []
}
}),
[availableThemes, resolvedMode, setMode, setTheme, t, themeName]
[availableThemes, mode, resolvedMode, setMode, setTheme, t, themeName]
)
const activePage = page ? subPages[page] : null
@@ -562,22 +436,17 @@ export function CommandPalette() {
return (
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
<DialogPrimitive.Portal>
{/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */}
<DialogPrimitive.Overlay className="fixed inset-0 z-[200]" />
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/15 backdrop-blur-[1px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
aria-describedby={undefined}
className={cn(
HUD_POSITION,
HUD_SURFACE,
'z-[210] w-[min(34rem,calc(100vw-2rem))] overflow-hidden duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95'
)}
className="fixed left-1/2 top-[14vh] z-[210] w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95"
>
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>
<Command className="bg-transparent" filter={paletteFilter} loop>
<Command className="bg-transparent" loop>
{activePage && (
<button
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={goBack}
onClick={() => setPage(null)}
type="button"
>
<ChevronLeft className="size-3.5" />
@@ -587,7 +456,6 @@ export function CommandPalette() {
</button>
)}
<CommandInput
className={HUD_TEXT}
onKeyDown={event => {
if (!activePage) {
return
@@ -598,45 +466,38 @@ export function CommandPalette() {
if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
event.preventDefault()
event.stopPropagation()
goBack()
setPage(null)
}
}}
onValueChange={setSearch}
placeholder={placeholder}
value={search}
/>
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
{page === 'install-theme' ? (
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
) : (
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
)}
{visibleGroups.map((group, index) => (
<CommandList className="max-h-[min(24rem,60vh)]">
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
{visibleGroups.map(group => (
<CommandGroup
className={HUD_HEADING}
className="**:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-wider **:[[cmdk-group-heading]]:text-[0.6875rem] **:[[cmdk-group-heading]]:text-muted-foreground/70"
heading={group.heading}
key={group.heading ?? `palette-group-${index}`}
key={group.heading}
>
{group.items.map(item => {
const Icon = item.icon
const combo = item.action ? bindings[item.action]?.[0] : undefined
const keys = combo ? comboTokens(combo) : null
return (
<CommandItem
className={cn(HUD_ITEM, HUD_TEXT)}
className="gap-2.5"
key={item.id}
keywords={item.keywords}
onSelect={() => handleSelect(item)}
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{item.label}</span>
{keys && <KbdGroup className="ml-auto" keys={keys} />}
{item.to && (
<ChevronRight
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !keys && 'ml-auto')}
/>
{item.to ? (
<ChevronRight className="ml-auto size-4 shrink-0 text-muted-foreground/70" />
) : (
<Check className={cn('ml-auto size-4 text-foreground', !item.active && 'invisible')} />
)}
</CommandItem>
)
@@ -1,157 +0,0 @@
/**
* Cmd-K "Install theme…" page.
*
* Browses the VS Code Marketplace for color themes: an empty query shows the
* most-installed themes, typing runs a live (debounced) search against the
* Marketplace. Selecting a row downloads + converts + installs it via the same
* pipeline as the settings importer, then activates it — and stays open so the
* user can grab several.
*/
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud'
import type { DesktopMarketplaceSearchItem } from '@/global'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Download, Loader2, Palette } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { installVscodeThemeFromMarketplace } from '@/themes/install'
const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 })
function useDebounced<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const handle = setTimeout(() => setDebounced(value), delayMs)
return () => clearTimeout(handle)
}, [value, delayMs])
return debounced
}
interface MarketplaceThemePageProps {
search: string
/** Activate a freshly installed theme by slug. */
onPickTheme: (name: string) => void
}
export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePageProps) {
const { t } = useI18n()
const copy = t.commandCenter.installTheme
const debouncedSearch = useDebounced(search.trim(), 300)
const [installingId, setInstallingId] = useState<string | null>(null)
const [installed, setInstalled] = useState<Record<string, true>>({})
const [installError, setInstallError] = useState<string | null>(null)
const query = useQuery({
queryKey: ['marketplace-themes', debouncedSearch],
queryFn: () => window.hermesDesktop?.themes?.searchMarketplace(debouncedSearch) ?? Promise.resolve([]),
staleTime: 5 * 60 * 1000
})
const install = async (item: DesktopMarketplaceSearchItem) => {
if (installingId) {
return
}
setInstallingId(item.extensionId)
setInstallError(null)
try {
const theme = await installVscodeThemeFromMarketplace(item.extensionId)
triggerHaptic('crisp')
setInstalled(prev => ({ ...prev, [item.extensionId]: true }))
onPickTheme(theme.name)
} catch (error) {
setInstallError(error instanceof Error ? error.message : copy.error)
} finally {
setInstallingId(null)
}
}
if (query.isLoading) {
return <Status icon={<Loader2 className="size-3.5 animate-spin" />} text={copy.loading} />
}
if (query.isError) {
return <Status text={copy.error} tone="error" />
}
const results = query.data ?? []
if (results.length === 0) {
return <Status text={copy.empty} />
}
return (
<div role="listbox">
{installError && <p className="px-2 pb-1 pt-1.5 text-[0.6875rem] text-(--ui-red)">{installError}</p>}
{results.map(item => {
const busy = installingId === item.extensionId
const done = installed[item.extensionId]
return (
<button
className={cn(
'flex w-full items-start rounded-md text-left transition-colors hover:bg-(--chrome-action-hover) disabled:opacity-60 aria-disabled:opacity-60',
HUD_ITEM,
HUD_TEXT
)}
disabled={Boolean(installingId) && !busy}
key={item.extensionId}
onClick={() => void install(item)}
onMouseDown={event => event.preventDefault()}
role="option"
type="button"
>
<Palette className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium">{item.displayName}</span>
<span className="truncate text-[0.6875rem] text-muted-foreground/80">
{item.publisher}
{item.installs > 0 ? ` · ${copy.installs(compactNumber.format(item.installs))}` : ''}
</span>
</span>
<span className="ml-auto mt-0.5 flex shrink-0 items-center gap-1 text-[0.6875rem] text-muted-foreground">
{busy ? (
<>
<Loader2 className="size-3 animate-spin" />
{copy.installing}
</>
) : done ? (
<>
<Check className="size-3 text-(--ui-green)" />
{copy.installed}
</>
) : (
<>
<Download className="size-3" />
{copy.install}
</>
)}
</span>
</button>
)
})}
</div>
)
}
function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) {
return (
<div
className={cn(
'flex items-center justify-center gap-2 px-2 py-6 text-xs',
tone === 'error' ? 'text-(--ui-red)' : 'text-muted-foreground'
)}
>
{icon}
{text}
</div>
)
}
+66 -216
View File
@@ -11,16 +11,9 @@ import { Pane, PaneMain } from '@/components/pane-shell'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useSkinCommand } from '@/themes/use-skin-command'
import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
import {
isMessagingSource,
LOCAL_SESSION_SOURCE_IDS,
MESSAGING_SESSION_SOURCE_IDS,
normalizeSessionSource
} from '../lib/session-source'
import { setCronFocusJobId, setCronJobs } from '../store/cron'
import {
$panesFlipped,
@@ -51,14 +44,11 @@ import {
$currentCwd,
$freshDraftReady,
$gatewayState,
$messagingSessions,
$selectedStoredSessionId,
$sessions,
$workingSessionIds,
CRON_SECTION_LIMIT,
getRecentlySettledSessionIds,
mergeSessionPage,
MESSAGING_SECTION_LIMIT,
sessionPinId,
setAwaitingResponse,
setBusy,
@@ -68,16 +58,12 @@ import {
setCurrentModel,
setCurrentProvider,
setMessages,
setMessagingPlatformTotals,
setMessagingSessions,
setMessagingTruncated,
setSessionProfileTotals,
setSessions,
setSessionsLoading,
setSessionsTotal
} from '../store/session'
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
import { isSecondaryWindow } from '../store/windows'
import { ChatView } from './chat'
import { useComposerActions } from './chat/hooks/use-composer-actions'
@@ -99,8 +85,6 @@ import { RightSidebarPane } from './right-sidebar'
import { $terminalTakeover } from './right-sidebar/store'
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
import { SessionPickerOverlay } from './session-picker-overlay'
import { SessionSwitcher } from './session-switcher'
import { useContextSuggestions } from './session/hooks/use-context-suggestions'
import { useCwdActions } from './session/hooks/use-cwd-actions'
import { useHermesConfig } from './session/hooks/use-hermes-config'
@@ -136,39 +120,22 @@ const SkillsView = lazy(async () => ({ default: (await import('./skills')).Skill
// this cadence while the app is open + visible so new runs surface promptly
// instead of waiting for the next user-triggered refreshSessions().
const CRON_POLL_INTERVAL_MS = 30_000
// The recents list is local-only: cron rows have their own section, and each
// messaging platform (telegram, discord, …) is fetched separately into its own
// self-managed sidebar section (refreshMessagingSessions). Excluding both here
// keeps "Load more" paging through interactive local chats instead of
// interleaving gateway threads that bury them.
const SIDEBAR_EXCLUDED_SOURCES = ['cron', ...MESSAGING_SESSION_SOURCE_IDS]
// The messaging slice is the inverse: drop cron + every local source so only
// external-platform conversations remain, then split per platform in the UI.
const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS]
// Cheap signature compare so the poll only swaps the atom (and re-renders the
// sidebar) when the visible cron rows actually changed.
function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
if (a.length !== b.length) {
return false
}
if (a.length !== b.length) {return false}
return a.every((session, i) => session.id === b[i]?.id && session.title === b[i]?.title)
}
// Rows a session refresh must preserve even if the aggregator omits them:
// in-flight first turns (message_count 0), pinned rows aged off the page, the
// actively-viewed chat (its "working" flag clears a beat before the aggregator
// sees the persisted row), and sessions whose turn just settled (same race, but
// for a chat the user has already navigated away from). Pass `scope` to only
// keep the active row when it belongs to the profile being paged.
// in-flight first turns (message_count 0), pinned rows aged off the page, and
// the actively-viewed chat (its "working" flag clears a beat before the
// aggregator sees the persisted row). Pass `scope` to only keep the active row
// when it belongs to the profile being paged.
function sessionsToKeep(scope?: string): Set<string> {
const keep = new Set<string>([
...$workingSessionIds.get(),
...$pinnedSessionIds.get(),
...getRecentlySettledSessionIds()
])
const keep = new Set<string>([...$workingSessionIds.get(), ...$pinnedSessionIds.get()])
const active = $selectedStoredSessionId.get()
if (active) {
@@ -227,7 +194,7 @@ export function DesktopController() {
toggleCommandCenter
} = useOverlayRouting()
const terminalSidebarOpen = chatOpen && terminalTakeover
const terminalTakeoverActive = chatOpen && terminalTakeover
const titlebarToolGroups = useGroupRegistry<TitlebarTool>()
const statusbarItemGroups = useGroupRegistry<StatusbarItem>()
@@ -267,31 +234,6 @@ export function DesktopController() {
}
}, [])
// hermes:// deep links (e.g. a docs "Send to App" button for an automation blueprint).
// Build the equivalent /blueprint slash command from the payload and drop
// it into the composer — the user reviews/edits, then sends; the agent (or
// the shared command handler) creates the job. Signal readiness so a link
// that arrived during boot is flushed exactly once.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onDeepLink?.((payload) => {
if (!payload || payload.kind !== 'blueprint' || !payload.name) {
return
}
const slots = Object.entries(payload.params || {})
.map(([k, v]) => {
const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v
return `${k}=${sval}`
})
.join(' ')
const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}`
requestComposerInsert(command, { mode: 'block', target: 'main' })
requestComposerFocus('main')
})
// Tell the main process the renderer is ready to receive deep links.
void window.hermesDesktop?.signalDeepLinkReady?.()
return () => unsubscribe?.()
}, [])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!$filePreviewTarget.get() && !$previewTarget.get()) {
@@ -331,51 +273,6 @@ export function DesktopController() {
}
}, [])
// Messaging-platform sessions as their own slice, fetched separately from
// local recents so each platform renders a self-managed section and never
// competes with local chats for the recents page budget. One combined fetch
// seeds every platform; the sidebar splits the rows per source.
const refreshMessagingSessions = useCallback(async () => {
try {
const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', {
excludeSources: MESSAGING_EXCLUDED_SOURCES
})
// Drop any non-messaging source the broad exclude didn't catch (custom
// sources) — those stay in local recents, not a platform section.
const rows = result.sessions.filter(s => isMessagingSource(s.source))
setMessagingSessions(prev => (sameCronSignature(prev, rows) ? prev : rows))
// Hit the cap → at least one platform may have more on disk than loaded,
// so platform sections offer their own per-platform "load more".
setMessagingTruncated(result.sessions.length >= MESSAGING_SECTION_LIMIT)
} catch {
// Non-fatal: the messaging sections just stay empty/stale.
}
}, [])
// Page a single platform's section independently (mirrors the per-profile
// pager): fetch that source's next window and merge it back in place, leaving
// every other platform's rows untouched. Resolves the platform's exact total.
const loadMoreMessagingForPlatform = useCallback(async (platform: string) => {
const inPlatform = (s: SessionInfo) => normalizeSessionSource(s.source) === platform
const loaded = $messagingSessions.get().filter(inPlatform).length
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', 'all', {
source: platform
})
const incoming = result.sessions.filter(s => normalizeSessionSource(s.source) === platform)
setMessagingSessions(prev => [
...prev.filter(s => !inPlatform(s)),
...mergeSessionPage(prev.filter(inPlatform), incoming, sessionsToKeep())
])
const total = result.total ?? incoming.length
setMessagingPlatformTotals(prev => ({ ...prev, [platform]: Math.max(total, incoming.length) }))
}, [])
// Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created
// synchronously (agent tool call or the cron UI), so refreshing here right
// after an agent turn surfaces a new job immediately; the interval poll keeps
@@ -412,7 +309,7 @@ export function DesktopController() {
const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope
const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, {
excludeSources: SIDEBAR_EXCLUDED_SOURCES
excludeSources: ['cron']
})
if (refreshSessionsRequestRef.current === requestId) {
@@ -428,8 +325,7 @@ export function DesktopController() {
void refreshCronSessions()
void refreshCronJobs()
void refreshMessagingSessions()
}, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions])
}, [profileScope, refreshCronSessions, refreshCronJobs])
const loadMoreSessions = useCallback(() => {
bumpSessionsLimit()
@@ -444,15 +340,12 @@ export function DesktopController() {
const loaded = $sessions.get().filter(inKey).length
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, {
excludeSources: SIDEBAR_EXCLUDED_SOURCES
excludeSources: ['cron']
})
const keep = sessionsToKeep(key)
setSessions(prev => [
...prev.filter(s => !inKey(s)),
...mergeSessionPage(prev.filter(inKey), result.sessions, keep)
])
setSessions(prev => [...prev.filter(s => !inKey(s)), ...mergeSessionPage(prev.filter(inKey), result.sessions, keep)])
const total = result.profile_totals?.[key] ?? result.total ?? result.sessions.length
setSessionProfileTotals(prev => ({ ...prev, [key]: Math.max(total, result.sessions.length) }))
@@ -547,9 +440,7 @@ export function DesktopController() {
return
}
const storedProfile = $sessions
.get()
.find(session => session.id === storedSessionId || session._lineage_root_id === storedSessionId)?.profile
const storedProfile = $sessions.get().find(session => session.id === storedSessionId)?.profile
for (let index = 0; index < Math.max(1, attempts); index += 1) {
try {
@@ -715,20 +606,19 @@ export function DesktopController() {
submitText,
transcribeVoiceAudio
} = usePromptActions({
activeSessionId,
activeSessionIdRef,
branchCurrentSession: branchInNewChat,
busyRef,
createBackendSessionForSend,
handleSkinCommand,
refreshSessions,
requestGateway,
resumeStoredSession: resumeSession,
selectedStoredSessionIdRef,
startFreshSessionDraft,
sttEnabled,
updateSessionState
})
activeSessionId,
activeSessionIdRef,
branchCurrentSession: branchInNewChat,
busyRef,
createBackendSessionForSend,
handleSkinCommand,
refreshSessions,
requestGateway,
selectedStoredSessionIdRef,
startFreshSessionDraft,
sttEnabled,
updateSessionState
})
useGatewayBoot({
handleGatewayEvent: handleDesktopGatewayEvent,
@@ -754,14 +644,10 @@ export function DesktopController() {
// in the background (advancing next-run/state and creating runs), so poll the
// job list on an interval (and on tab re-focus) while connected.
useEffect(() => {
if (gatewayState !== 'open') {
return
}
if (gatewayState !== 'open') {return}
const tick = () => {
if (document.visibilityState === 'visible') {
void refreshCronJobs()
}
if (document.visibilityState === 'visible') {void refreshCronJobs()}
}
const intervalId = window.setInterval(tick, CRON_POLL_INTERVAL_MS)
@@ -773,13 +659,6 @@ export function DesktopController() {
}
}, [gatewayState, refreshCronJobs])
useEffect(() => {
if (gatewayState === 'open' && !activeSessionId && freshDraftReady) {
void refreshCurrentModel()
void refreshHermesConfig()
}
}, [activeSessionId, freshDraftReady, gatewayState, refreshCurrentModel, refreshHermesConfig])
useRouteResume({
activeSessionId,
activeSessionIdRef,
@@ -798,7 +677,6 @@ export function DesktopController() {
const { leftStatusbarItems, statusbarItems } = useStatusbarItems({
agentsOpen,
chatOpen,
commandCenterOpen,
extraLeftItems: statusbarItemGroups.flat.left,
extraRightItems: statusbarItemGroups.flat.right,
@@ -819,7 +697,6 @@ export function DesktopController() {
currentView={currentView}
onArchiveSession={sessionId => void archiveSession(sessionId)}
onDeleteSession={sessionId => void removeSession(sessionId)}
onLoadMoreMessaging={loadMoreMessagingForPlatform}
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
onLoadMoreSessions={loadMoreSessions}
onManageCronJob={jobId => {
@@ -837,35 +714,27 @@ export function DesktopController() {
/>
)
// One PTY-backed terminal mounted forever; <TerminalSlot /> placeholders decide
// where it shows. Lives in main's stacking context (not the root overlay layer)
// so pane resize handles still paint above it. Toggling never rebuilds the shell.
const mainOverlays = (
<PersistentTerminal cwd={currentCwd} onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
)
const overlays = (
<>
{!isSecondaryWindow() && <DesktopInstallOverlay />}
{!isSecondaryWindow() && (
<DesktopOnboardingOverlay
enabled={gatewayState === 'open'}
onCompleted={() => {
void refreshHermesConfig()
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
requestGateway={requestGateway}
/>
)}
<DesktopInstallOverlay />
{/* One PTY-backed terminal mounted forever; <TerminalSlot /> placeholders
decide where it shows. Toggling fullscreen never rebuilds the shell. */}
<PersistentTerminal cwd={currentCwd} onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
<DesktopOnboardingOverlay
enabled={gatewayState === 'open'}
onCompleted={() => {
void refreshHermesConfig()
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
requestGateway={requestGateway}
/>
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
<SessionPickerOverlay onResume={resumeSession} />
<ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} />
<UpdatesOverlay />
<GatewayConnectingOverlay />
<BootFailureOverlay />
<CommandPalette />
<SessionSwitcher />
{settingsOpen && (
<Suspense fallback={null}>
@@ -953,6 +822,12 @@ export function DesktopController() {
/>
)
const takeoverTerminalView = (
<div className="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background) pt-(--titlebar-height)">
<TerminalSlot />
</div>
)
// Flipped layout mirrors the default: sessions sidebar → right, file
// browser + preview rail → left. Same panes, swapped sides.
const sidebarSide = panesFlipped ? 'right' : 'left'
@@ -997,56 +872,33 @@ export function DesktopController() {
</Pane>
)
const terminalPane = (
<Pane
defaultOpen
disabled={!terminalSidebarOpen}
divider
id="terminal-sidebar"
key="terminal-sidebar"
maxWidth="80vw"
minWidth="22vw"
resizable
side={railSide}
width="42vw"
>
<div className="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--ui-editor-surface-background) pt-(--titlebar-height)">
<TerminalSlot />
</div>
</Pane>
)
return (
<AppShell
leftStatusbarItems={leftStatusbarItems}
leftTitlebarTools={titlebarToolGroups.flat.left}
mainOverlays={mainOverlays}
onOpenSettings={openSettings}
overlays={overlays}
previewPaneOpen={chatOpen && Boolean(previewTarget || filePreviewTarget)}
statusbarItems={statusbarItems}
terminalPaneOpen={terminalSidebarOpen}
titlebarTools={titlebarToolGroups.flat.right}
>
{!isSecondaryWindow() && (
<Pane
forceCollapsed={narrowViewport}
hoverReveal
id="chat-sidebar"
maxWidth={SIDEBAR_MAX_WIDTH}
minWidth={SIDEBAR_DEFAULT_WIDTH}
onOverlayActiveChange={setSidebarOverlayMounted}
resizable
side={sidebarSide}
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
>
{sidebar}
</Pane>
)}
<Pane
disabled={terminalTakeoverActive}
forceCollapsed={narrowViewport}
hoverReveal
id="chat-sidebar"
maxWidth={SIDEBAR_MAX_WIDTH}
minWidth={SIDEBAR_DEFAULT_WIDTH}
onOverlayActiveChange={setSidebarOverlayMounted}
resizable
side={sidebarSide}
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
>
{sidebar}
</Pane>
<PaneMain>
<Routes>
<Route element={chatView} index />
<Route element={chatView} path=":sessionId" />
<Route element={terminalTakeoverActive ? takeoverTerminalView : chatView} index />
<Route element={terminalTakeoverActive ? takeoverTerminalView : chatView} path=":sessionId" />
<Route
element={
<Suspense fallback={null}>
@@ -1083,13 +935,11 @@ export function DesktopController() {
</PaneMain>
{/*
Order within a side maps to column order. Default (rail on the right):
main | terminal | preview | file-browser. Flipped (rail on the left):
mirror to file-browser | preview | terminal | main so terminal stays
adjacent to the chat.
main | preview | file-browser. Flipped (rail on the left): mirror it to
file-browser | preview | main so preview stays adjacent to the chat.
*/}
{panesFlipped ? fileBrowserPane : terminalPane}
{previewPane}
{panesFlipped ? terminalPane : fileBrowserPane}
{panesFlipped ? fileBrowserPane : previewPane}
{panesFlipped ? previewPane : fileBrowserPane}
</AppShell>
)
}
-22
View File
@@ -1,22 +0,0 @@
// Shared chrome for the top-center floating HUDs (command palette + session
// switcher). They pin just under the title bar, centered, and lean on a crisp
// border + shadow to separate from the app — no dimming/blurring backdrop.
// Each caller layers on its own z-index, width, and overflow.
export const HUD_POSITION = 'fixed left-1/2 top-3 -translate-x-1/2'
// Matches the app's borderless-overlay surface (dialog, keybind panel, …):
// hairline `--stroke-nous` paired with the soft `--shadow-nous` float.
export const HUD_SURFACE = 'rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous'
// One row/text size for both HUDs (compact — two notches under `text-sm`).
export const HUD_TEXT = 'text-xs'
// Shared item layout + padding for both HUDs. Tight vertical rhythm so rows
// don't feel chunky; overrides the shadcn `CommandItem` default (`px-2 py-1.5`).
export const HUD_ITEM = 'gap-2 px-2 py-1'
// Section headings styled like the sidebar panel labels: brand-tinted, uppercase,
// tightly tracked — plain text, no sticky chrome bar. Targets the cmdk group
// heading via the universal-descendant variant.
export const HUD_HEADING =
'**:[[cmdk-group-heading]]:static **:[[cmdk-group-heading]]:bg-transparent **:[[cmdk-group-heading]]:px-2.5 **:[[cmdk-group-heading]]:pb-1 **:[[cmdk-group-heading]]:pt-2.5 **:[[cmdk-group-heading]]:text-[0.64rem] **:[[cmdk-group-heading]]:font-semibold **:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-[0.16em] **:[[cmdk-group-heading]]:text-(--theme-primary)'
@@ -3,7 +3,6 @@ import { useEffect, useRef } from 'react'
import type { HermesConnection } from '@/global'
import { HermesGateway } from '@/hermes'
import { translateNow } from '@/i18n'
import { desktopDefaultCwd } from '@/lib/desktop-fs'
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import {
$desktopBoot,
@@ -26,16 +25,11 @@ import {
import { notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile'
import {
$activeSessionId,
$attentionSessionIds,
$connection,
$currentCwd,
$sessions,
$workingSessionIds,
ensureDefaultWorkspaceCwd,
setConnection,
setCurrentBranch,
setCurrentCwd,
setSessionsLoading
} from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
@@ -357,12 +351,6 @@ export function useGatewayBoot({
message: translateNow('boot.steps.loadingSettings'),
progress: 97
})
await ensureDefaultWorkspaceCwd()
const remoteDefault = await desktopDefaultCwd().catch(() => null)
if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) {
setCurrentCwd(remoteDefault.cwd)
setCurrentBranch(remoteDefault.branch || '')
}
await callbacksRef.current.refreshHermesConfig()
if (cancelled) {
+24 -89
View File
@@ -1,10 +1,10 @@
import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
import { setRightSidebarTab } from '@/app/right-sidebar/store'
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
import { matchesQuery } from '@/hooks/use-media-query'
import { PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
import { PROFILE_SLOT_COUNT } from '@/lib/keybinds/actions'
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
import { toggleCommandPalette } from '@/store/command-palette'
import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds'
@@ -18,25 +18,13 @@ import {
toggleSidebarOpen
} from '@/store/layout'
import {
$newChatProfile,
cycleProfile,
requestProfileCreate,
switchProfileToSlot,
switchToDefaultProfile,
toggleShowAllProfiles
} from '@/store/profile'
import { setModelPickerOpen } from '@/store/session'
import {
$switcherOpen,
closeSwitcher,
commitOnCtrlUp,
onSwitcherTabDown,
onSwitcherTabUp,
openOrAdvanceSwitcher,
slotSessionId,
switcherActive,
switcherJustClosed
} from '@/store/session-switcher'
import { $activeSessionId, $sessions, setModelPickerOpen } from '@/store/session'
import { useTheme } from '@/themes/context'
import { requestComposerFocus } from '../chat/composer/focus'
@@ -72,7 +60,6 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
// Keep the latest closures without re-subscribing the listener.
const handlersRef = useRef<HandlerMap>({})
const commitSwitcherRef = useRef<() => void>(() => {})
const profileSwitchHandlers: HandlerMap = {}
@@ -80,32 +67,26 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
profileSwitchHandlers[`profile.switch.${slot}`] = () => switchProfileToSlot(slot)
}
const goToSession = (sessionId: null | string) => {
if (sessionId) {
navigate(sessionRoute(sessionId))
// Move to the adjacent session in recency order, wrapping at the ends.
const cycleSession = (direction: 1 | -1) => {
const sessions = $sessions.get()
if (sessions.length < 2) {
return
}
const current = sessions.findIndex(session => session.id === $activeSessionId.get())
const start = current === -1 ? (direction === 1 ? -1 : 0) : current
const next = sessions[(start + direction + sessions.length) % sessions.length]
if (next) {
navigate(sessionRoute(next.id))
}
}
// ^N jumps straight to the Nth recent session and dismisses the switcher.
const sessionSlotHandlers: HandlerMap = {}
for (let slot = 1; slot <= SESSION_SLOT_COUNT; slot += 1) {
sessionSlotHandlers[`session.slot.${slot}`] = () => {
closeSwitcher()
goToSession(slotSessionId(slot))
}
}
commitSwitcherRef.current = () => goToSession(commitOnCtrlUp())
const stepSession = (direction: 1 | -1) => {
onSwitcherTabDown()
goToSession(openOrAdvanceSwitcher(direction))
}
const showFiles = () => {
const showRightSidebarTab = (tab: 'files' | 'terminal') => {
setFileBrowserOpen(true)
setTerminalTakeover(false)
setRightSidebarTab(tab)
}
handlersRef.current = {
@@ -125,16 +106,11 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
'nav.agents': () => navigate(AGENTS_ROUTE),
'session.new': () => {
// Match the sidebar New Session button. A plain keyboard new chat should
// target the current live profile, not a stale per-profile quick-create
// selection from a prior action.
$newChatProfile.set(null)
deps.startFreshSession()
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
},
'session.next': () => stepSession(1),
'session.prev': () => stepSession(-1),
...sessionSlotHandlers,
'session.next': () => cycleSession(1),
'session.prev': () => cycleSession(-1),
'session.focusSearch': requestSessionSearchFocus,
'session.togglePin': deps.toggleSelectedPin,
@@ -152,8 +128,8 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
toggleFileBrowserOpen()
}
},
'view.showFiles': showFiles,
'view.showTerminal': () => setTerminalTakeover(!$terminalTakeover.get()),
'view.showFiles': () => showRightSidebarTab('files'),
'view.showTerminal': () => showRightSidebarTab('terminal'),
'view.flipPanes': togglePanesFlipped,
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
@@ -194,16 +170,6 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
return
}
// While the session switcher is up, Esc abandons it (stay put) before any
// combo dispatch — ⌃Tab keeps stepping through the existing handler.
if (switcherActive() && event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
closeSwitcher()
return
}
const combo = comboFromEvent(event)
if (!combo) {
@@ -230,39 +196,8 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
handler()
}
// Mac-app-switcher commit: lifting Ctrl with the overlay open lands on the
// highlighted session. A window blur (Cmd+Tab away mid-switch) cancels so
// the overlay never gets stranded waiting for a keyup that never comes.
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Tab') {
onSwitcherTabUp()
}
if (event.key === 'Control') {
commitSwitcherRef.current()
}
}
const onBlur = () => switcherActive() && closeSwitcher()
// Swallow trailing contextmenu after Ctrl+click commit (Electron main menu).
const onContextMenu = (event: MouseEvent) => {
if ($switcherOpen.get() || switcherJustClosed()) {
event.preventDefault()
event.stopPropagation()
}
}
window.addEventListener('keydown', onKeyDown, { capture: true })
window.addEventListener('keyup', onKeyUp, { capture: true })
window.addEventListener('blur', onBlur)
window.addEventListener('contextmenu', onContextMenu, { capture: true })
return () => {
window.removeEventListener('keydown', onKeyDown, { capture: true })
window.removeEventListener('keyup', onKeyUp, { capture: true })
window.removeEventListener('blur', onBlur)
window.removeEventListener('contextmenu', onContextMenu, { capture: true })
}
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [])
}
@@ -1,27 +0,0 @@
import { createDragDropManager, type DragDropManager } from 'dnd-core'
import { HTML5Backend } from 'react-dnd-html5-backend'
let manager: DragDropManager | null = null
/**
* A single, app-lifetime react-dnd manager for the file tree.
*
* react-arborist mounts its own react-dnd `DndProvider` with `HTML5Backend`
* inside every `<Tree>`. react-dnd v14 stores that provider's manager on a
* global, ref-counted singleton context and nulls it when the count hits 0.
* On a keyed remount (cwd / collapse changes force a fresh `<Tree>`), the
* singleton can be torn down and recreated while the previous `HTML5Backend`
* still owns the `window.__isReactDndHtml5Backend` setup flag — so the new
* backend's `setup()` throws "Cannot have two HTML5 backends at the same
* time." and trips the file-tree error boundary (it never recovers, because
* "Try again" just remounts into the same race).
*
* Passing arborist a stable `dndManager` makes it skip the global-singleton
* path entirely and reuse one backend for the lifetime of the app, so the
* window flag is never double-claimed.
*/
export function getFileTreeDndManager(): DragDropManager {
manager ??= createDragDropManager(HTML5Backend)
return manager
}
@@ -1,100 +0,0 @@
/// <reference types="node" />
import { Buffer } from 'node:buffer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
import { clearProjectDirCache, readProjectDir } from './ipc'
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
const readFileDataUrl = vi.fn<(path: string) => Promise<string>>()
const gitRoot = vi.fn<(path: string) => Promise<string | null>>()
function ok(entries: HermesReadDirEntry[]): HermesReadDirResult {
return { entries }
}
function dataUrl(text: string) {
return `data:text/plain;base64,${Buffer.from(text, 'utf8').toString('base64')}`
}
function installBridge() {
;(
window as unknown as {
hermesDesktop: {
gitRoot: typeof gitRoot
readDir: typeof readDir
readFileDataUrl: typeof readFileDataUrl
}
}
).hermesDesktop = { gitRoot, readDir, readFileDataUrl }
}
describe('readProjectDir', () => {
beforeEach(() => {
clearProjectDirCache()
readDir.mockReset()
readFileDataUrl.mockReset()
gitRoot.mockReset()
installBridge()
})
afterEach(() => {
clearProjectDirCache()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
it('returns no-bridge when the desktop bridge is unavailable', async () => {
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
await expect(readProjectDir('/repo')).resolves.toEqual({ entries: [], error: 'no-bridge' })
})
it('filters gitignored entries when readDir returns Windows-style paths', async () => {
gitRoot.mockResolvedValue('C:\\repo')
readDir.mockImplementation(async path => {
if (path === 'C:\\repo\\src') {
return ok([
{ name: 'debug.log', path: 'C:\\repo\\src\\debug.log', isDirectory: false },
{ name: '临时.txt', path: 'C:\\repo\\src\\临时.txt', isDirectory: false },
{ name: 'keep.ts', path: 'C:\\repo\\src\\keep.ts', isDirectory: false }
])
}
if (path === 'C:/repo') {
return ok([{ name: '.gitignore', path: 'C:/repo/.gitignore', isDirectory: false }])
}
if (path === 'C:/repo/src') {
return ok([])
}
return ok([])
})
readFileDataUrl.mockResolvedValue(dataUrl('# Unicode 路径规则\nsrc/*.log\nsrc/临时.txt\n'))
const result = await readProjectDir('C:\\repo\\src', 'C:\\repo')
expect(result.entries.map(entry => entry.name)).toEqual(['keep.ts'])
expect(gitRoot).toHaveBeenCalledWith('C:/repo')
expect(readFileDataUrl).toHaveBeenCalledWith('C:/repo/.gitignore')
})
it('does not fetch .gitignore contents when listings do not contain .gitignore', async () => {
gitRoot.mockResolvedValue('/repo')
readDir.mockImplementation(async path => {
if (path === '/repo/src') {
return ok([{ name: 'debug.log', path: '/repo/src/debug.log', isDirectory: false }])
}
return ok([])
})
const result = await readProjectDir('/repo/src', '/repo')
expect(result.entries.map(entry => entry.name)).toEqual(['debug.log'])
expect(readFileDataUrl).not.toHaveBeenCalled()
})
})
+18 -12
View File
@@ -1,6 +1,5 @@
import ignore from 'ignore'
import { desktopFsCacheKey, desktopGitRoot, readDesktopDir, readDesktopFileDataUrl } from '@/lib/desktop-fs'
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
export type ProjectTreeEntry = HermesReadDirEntry
@@ -28,7 +27,7 @@ function decodeDataUrl(dataUrl: string) {
}
function clean(path: string) {
return path.replace(/\\/g, '/').replace(/\/+$/, '') || '/'
return path.replace(/\/+$/, '') || '/'
}
/** Strict POSIX-style relative path; null if `child` is not inside `root`. */
@@ -64,11 +63,15 @@ function ancestorDirs(root: string, dir: string) {
}
async function gitRootFor(start: string) {
const key = `${desktopFsCacheKey()}:${clean(start)}`
if (!window.hermesDesktop?.gitRoot) {
return null
}
const key = clean(start)
let cached = gitRootCache.get(key)
if (!cached) {
cached = desktopGitRoot(start)
cached = window.hermesDesktop.gitRoot(key)
gitRootCache.set(key, cached)
}
@@ -77,14 +80,18 @@ async function gitRootFor(start: string) {
/** Read .gitignore at `dir` if it actually exists — never probe missing files. */
async function readGitignore(dir: string): Promise<GitignoreRule | null> {
if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) {
return null
}
try {
const listing = await readDesktopDir(dir)
const listing = await window.hermesDesktop.readDir(dir)
if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {
return null
}
const text = decodeDataUrl(await readDesktopFileDataUrl(`${dir}/.gitignore`))
const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`))
return { base: dir, ig: ignore().add(text) }
} catch {
@@ -93,11 +100,11 @@ async function readGitignore(dir: string): Promise<GitignoreRule | null> {
}
async function gitignoreFor(dir: string) {
const key = `${desktopFsCacheKey()}:${clean(dir)}`
const key = clean(dir)
let cached = gitignoreCache.get(key)
if (!cached) {
cached = readGitignore(clean(dir))
cached = readGitignore(key)
gitignoreCache.set(key, cached)
}
@@ -135,10 +142,9 @@ export async function readProjectDir(dirPath: string, rootPath = dirPath): Promi
return { entries: [], error: 'no-bridge' }
}
const result = await readDesktopDir(dirPath)
const entries = result?.entries ?? []
const result = await window.hermesDesktop.readDir(dirPath)
return { ...result, entries: await filterIgnored(entries, rootPath, dirPath) }
return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) }
}
export function clearProjectDirCache(rootPath?: string) {
@@ -149,7 +155,7 @@ export function clearProjectDirCache(rootPath?: string) {
return
}
const key = `${desktopFsCacheKey()}:${clean(rootPath)}`
const key = clean(rootPath)
gitRootCache.delete(key)
gitignoreCache.delete(key)
}
@@ -1,177 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
import { useI18n } from '@/i18n'
import { readDesktopDir, setDesktopFsRemotePicker } from '@/lib/desktop-fs'
import { cn } from '@/lib/utils'
function clean(path: string) {
return path.replace(/\/+$/, '') || '/'
}
function parentDir(path: string) {
const value = clean(path)
if (value === '/') {
return '/'
}
const parent = value.slice(0, value.lastIndexOf('/'))
return parent || '/'
}
function pathName(path: string) {
return path.split('/').filter(Boolean).pop() || path
}
interface PendingSelection {
defaultPath: string
resolve: (paths: string[]) => void
title: string
}
export function RemoteFolderPicker() {
const { t } = useI18n()
const r = t.rightSidebar
const [pending, setPending] = useState<PendingSelection | null>(null)
const [currentPath, setCurrentPath] = useState('/')
const [entries, setEntries] = useState<Array<{ name: string; path: string }>>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
setDesktopFsRemotePicker({
selectPaths: options =>
new Promise(resolve => {
const defaultPath = clean(options?.defaultPath || '/')
setCurrentPath(defaultPath)
setPending({ defaultPath, resolve, title: options?.title || r.remotePickerTitle })
})
})
return () => setDesktopFsRemotePicker(null)
}, [r.remotePickerTitle])
useEffect(() => {
if (!pending) {
return
}
let active = true
setLoading(true)
setError(null)
void readDesktopDir(currentPath)
.then(result => {
if (!active) {
return
}
if (result.error) {
setError(result.error)
setEntries([])
return
}
setEntries(result.entries.filter(entry => entry.isDirectory).map(entry => ({ name: entry.name, path: entry.path })))
})
.catch(err => {
if (active) {
setError(err instanceof Error ? err.message : String(err))
setEntries([])
}
})
.finally(() => {
if (active) {
setLoading(false)
}
})
return () => {
active = false
}
}, [currentPath, pending])
const crumbs = useMemo(() => {
const parts = clean(currentPath).split('/').filter(Boolean)
const out = [{ label: '/', path: '/' }]
let acc = ''
for (const part of parts) {
acc += `/${part}`
out.push({ label: part, path: acc })
}
return out
}, [currentPath])
const close = (paths: string[] = []) => {
pending?.resolve(paths)
setPending(null)
setEntries([])
setError(null)
}
return (
<Dialog onOpenChange={open => !open && close()} open={Boolean(pending)}>
<DialogContent className="max-w-lg gap-0 overflow-hidden p-0">
<div className="border-b border-border/70 px-4 py-3">
<DialogTitle className="text-sm">{pending?.title || r.remotePickerTitle}</DialogTitle>
<DialogDescription className="mt-1 text-xs">{r.remotePickerDescription}</DialogDescription>
</div>
<div className="flex min-h-[22rem] flex-col">
<div className="flex flex-wrap items-center gap-1 border-b border-border/50 px-3 py-2 text-xs text-muted-foreground">
{crumbs.map((crumb, index) => (
<button
className={cn('rounded px-1.5 py-0.5 hover:bg-muted hover:text-foreground', index === crumbs.length - 1 && 'text-foreground')}
key={crumb.path}
onClick={() => setCurrentPath(crumb.path)}
type="button"
>
{crumb.label}
</button>
))}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
<FolderRow disabled={currentPath === '/'} name=".." onClick={() => setCurrentPath(parentDir(currentPath))} />
{loading ? (
<div className="flex items-center gap-2 px-2 py-3 text-xs text-muted-foreground">
<Codicon name="loading" size="0.8rem" spinning />
{r.loadingFiles}
</div>
) : error ? (
<div className="px-2 py-3 text-xs text-destructive">{r.unreadableBody(error)}</div>
) : entries.length === 0 ? (
<div className="px-2 py-3 text-xs text-muted-foreground">{r.emptyBody}</div>
) : (
entries.map(entry => <FolderRow key={entry.path} name={pathName(entry.path)} onClick={() => setCurrentPath(entry.path)} />)
)}
</div>
</div>
<div className="flex items-center justify-between gap-2 border-t border-border/70 px-4 py-3">
<div className="min-w-0 truncate text-xs text-muted-foreground">{currentPath}</div>
<div className="flex shrink-0 items-center gap-2">
<Button onClick={() => close()} size="sm" variant="ghost">
{t.common.cancel}
</Button>
<Button onClick={() => close([currentPath])} size="sm">
{r.remotePickerSelect}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
function FolderRow({ disabled = false, name, onClick }: { disabled?: boolean; name: string; onClick: () => void }) {
return (
<button
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
disabled={disabled}
onClick={onClick}
type="button"
>
<Codicon name="folder" size="0.875rem" />
<span className="min-w-0 truncate">{name}</span>
</button>
)
}
@@ -7,7 +7,6 @@ import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { getFileTreeDndManager } from './dnd-manager'
import type { TreeNode } from './use-project-tree'
const ROW_HEIGHT = 22
@@ -95,7 +94,6 @@ export function ProjectTree({
disableDrag
disableDrop
disableEdit
dndManager={getFileTreeDndManager()}
height={size.height}
indent={INDENT}
initialOpenState={openState}
@@ -147,8 +145,7 @@ function ProjectTreeRow({
}
const isFolder = node.data.isDirectory
const isPlaceholder = Boolean(node.data.placeholder)
const isErrorPlaceholder = node.data.placeholder === 'error'
const isPlaceholder = node.data.id.endsWith('::__loading__')
return (
<div
@@ -213,10 +210,8 @@ function ProjectTreeRow({
)}
{!isFolder && <span aria-hidden className="w-3 shrink-0" />}
<span aria-hidden className="flex w-3.5 items-center justify-center text-(--ui-text-tertiary)">
{isPlaceholder && !isErrorPlaceholder ? (
{isPlaceholder ? (
<Codicon name="loading" size="0.75rem" spinning />
) : isErrorPlaceholder ? (
<Codicon name="warning" size="0.75rem" />
) : isFolder ? (
<Codicon name={node.isOpen ? 'folder-opened' : 'folder'} size="0.875rem" />
) : (
@@ -1,24 +1,19 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import type { HermesReadDirResult } from '@/global'
import { clearProjectDirCache, readProjectDir } from './ipc'
import { resetProjectTreeState, useProjectTree } from './use-project-tree'
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
beforeEach(() => {
$connection.set(null)
resetProjectTreeState()
readDir.mockReset()
;(window as unknown as { hermesDesktop: { readDir: typeof readDir } }).hermesDesktop = { readDir }
})
afterEach(() => {
cleanup()
$connection.set(null)
resetProjectTreeState()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
@@ -111,37 +106,7 @@ describe('useProjectTree', () => {
expect(readDir).toHaveBeenCalledTimes(1)
})
it('reads gitignore from the real path while caching per connection', async () => {
const readFileDataUrl = vi.fn(async () => `data:text/plain;base64,${btoa('ignored.log\n')}`)
const gitRoot = vi.fn(async () => '/repo')
readDir.mockImplementation(async path => {
if (path === '/repo') return ok([{ name: '.gitignore', path: '/repo/.gitignore', isDirectory: false }])
if (path === '/repo/src') {
return ok([
{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false },
{ name: 'ignored.log', path: '/repo/src/ignored.log', isDirectory: false }
])
}
throw new Error(`unexpected path ${path}`)
})
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { gitRoot, readDir, readFileDataUrl }
$connection.set({ baseUrl: 'local-a', mode: 'local' } as never)
await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({
entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }]
})
expect(readDir).toHaveBeenCalledWith('/repo')
expect(readDir).not.toHaveBeenCalledWith(expect.stringContaining('local-a'))
$connection.set({ baseUrl: 'local-b', mode: 'local' } as never)
clearProjectDirCache()
await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({
entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }]
})
expect(readDir.mock.calls.filter(([path]) => path === '/repo')).toHaveLength(2)
})
it('captures per-folder error code and shows an error placeholder child', async () => {
it('captures per-folder error code and leaves the folder expandable but empty', async () => {
readDir.mockResolvedValueOnce(ok([{ name: 'priv', path: '/p/priv', isDirectory: true }]))
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
@@ -154,14 +119,7 @@ describe('useProjectTree', () => {
})
expect(result.current.data[0].error).toBe('EACCES')
expect(result.current.data[0].children).toEqual([
{
id: '/p/priv::__error__',
isDirectory: false,
name: 'Unable to read (EACCES)',
placeholder: 'error'
}
])
expect(result.current.data[0].children).toEqual([])
})
it('dedupes concurrent loadChildren calls for the same id', async () => {
@@ -2,8 +2,6 @@ import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { useCallback, useEffect, useMemo } from 'react'
import { $connection } from '@/store/session'
import { clearProjectDirCache, readProjectDir } from './ipc'
export interface TreeNode {
@@ -16,14 +14,11 @@ export interface TreeNode {
children?: TreeNode[]
/** True while a readDir for this folder is in flight. */
loading?: boolean
/** Synthetic loading/error rows are not real filesystem entries. */
placeholder?: 'error' | 'loading'
/** Last error code from readDir (e.g. EACCES). Cleared on next successful load. */
error?: string
}
const PLACEHOLDER_ID = '__loading__'
const ERROR_PLACEHOLDER_ID = '__error__'
function makeNode(path: string, name: string, isDirectory: boolean): TreeNode {
return { id: path, isDirectory, name }
@@ -48,16 +43,7 @@ function patchNode(nodes: TreeNode[] | undefined | null, id: string, patch: (n:
}
function placeholderChild(parentId: string): TreeNode {
return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…', placeholder: 'loading' }
}
function errorChild(parentId: string, error: string | undefined): TreeNode {
return {
id: `${parentId}::${ERROR_PLACEHOLDER_ID}`,
isDirectory: false,
name: `Unable to read (${error || 'read-error'})`,
placeholder: 'error'
}
return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…' }
}
export interface UseProjectTreeResult {
@@ -98,7 +84,6 @@ const initialState: ProjectTreeState = {
const inflight = new Set<string>()
const $projectTree = atom<ProjectTreeState>(initialState)
let nextRootRequestId = 0
let lastConnectionKey = ''
function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) {
$projectTree.set(updater($projectTree.get()))
@@ -160,7 +145,6 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
}
export function resetProjectTreeState() {
lastConnectionKey = ''
clearProjectTree()
clearProjectDirCache()
}
@@ -174,8 +158,6 @@ export function resetProjectTreeState() {
*/
export function useProjectTree(cwd: string): UseProjectTreeResult {
const state = useStore($projectTree)
const connection = useStore($connection)
const connectionKey = `${connection?.mode || 'local'}:${connection?.profile || ''}:${connection?.baseUrl || ''}`
const refreshRoot = useCallback(() => loadRoot(cwd, { force: true }), [cwd])
@@ -245,7 +227,7 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
...n,
loading: false,
error: error || undefined,
children: error ? [errorChild(n.id, error)] : entries.map(e => makeNode(e.path, e.name, e.isDirectory))
children: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory))
}))
}
})
@@ -254,15 +236,8 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
)
useEffect(() => {
const connectionChanged = lastConnectionKey !== '' && lastConnectionKey !== connectionKey
lastConnectionKey = connectionKey
if (connectionChanged) {
clearProjectDirCache()
void loadRoot(cwd, { force: true })
return
}
void loadRoot(cwd)
}, [connectionKey, cwd])
}, [cwd])
return useMemo(
() => ({
+96 -25
View File
@@ -4,22 +4,22 @@ import type { ReactNode } from 'react'
import { ErrorBoundary } from '@/components/error-boundary'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import { Loader } from '@/components/ui/loader'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { selectDesktopPaths } from '@/lib/desktop-fs'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $panesFlipped } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
import { $currentCwd } from '@/store/session'
import { $currentBranch, $currentCwd } from '@/store/session'
import { SidebarPanelLabel } from '../shell/sidebar-label'
import { RemoteFolderPicker } from './files/remote-picker'
import { ProjectTree } from './files/tree'
import { useProjectTree } from './files/use-project-tree'
import { $rightSidebarTab, $terminalTakeover, type RightSidebarTabId, setRightSidebarTab } from './store'
import { TerminalSlot } from './terminal/persistent'
interface RightSidebarPaneProps {
onActivateFile: (path: string) => void
@@ -27,10 +27,24 @@ interface RightSidebarPaneProps {
onChangeCwd: (path: string) => Promise<void> | void
}
interface RightSidebarTab {
icon: string
id: RightSidebarTabId
labelKey: 'files' | 'terminal'
}
const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [
{ id: 'files', labelKey: 'files', icon: 'list-tree' },
{ id: 'terminal', labelKey: 'terminal', icon: 'terminal' }
]
export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd }: RightSidebarPaneProps) {
const { t } = useI18n()
const r = t.rightSidebar
const activeTab = useStore($rightSidebarTab)
const terminalTakeover = useStore($terminalTakeover)
const panesFlipped = useStore($panesFlipped)
const currentBranch = useStore($currentBranch).trim()
const currentCwd = useStore($currentCwd).trim()
const hasCwd = currentCwd.length > 0
@@ -54,9 +68,10 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
} = useProjectTree(currentCwd)
const canCollapse = Object.values(openState).some(Boolean)
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
const chooseFolder = async () => {
const selected = await selectDesktopPaths({
const selected = await window.hermesDesktop?.selectPaths({
defaultPath: hasCwd ? currentCwd : undefined,
directories: true,
multiple: false,
@@ -82,6 +97,8 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
}
}
const tabs = terminalTakeover ? RIGHT_SIDEBAR_TABS.filter(tab => tab.id !== 'terminal') : RIGHT_SIDEBAR_TABS
return (
<aside
aria-label={r.aria}
@@ -92,31 +109,85 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
)}
>
<RemoteFolderPicker />
<RightSidebarChrome activeTab={effectiveTab} branch={currentBranch} tabs={tabs} />
<FilesystemTab
canCollapse={canCollapse}
collapseNonce={collapseNonce}
cwd={currentCwd}
cwdName={cwdName}
data={data}
error={rootError}
hasCwd={hasCwd}
loading={rootLoading}
onActivateFile={onActivateFile}
onActivateFolder={onActivateFolder}
onChangeFolder={chooseFolder}
onCollapseAll={collapseAll}
onLoadChildren={loadChildren}
onNodeOpenChange={setNodeOpen}
onPreviewFile={previewFile}
onRefresh={() => void refreshRoot()}
openState={openState}
/>
{effectiveTab === 'terminal' ? (
<TerminalSlot />
) : (
<FilesystemTab
canCollapse={canCollapse}
collapseNonce={collapseNonce}
cwd={currentCwd}
cwdName={cwdName}
data={data}
error={rootError}
hasCwd={hasCwd}
loading={rootLoading}
onActivateFile={onActivateFile}
onActivateFolder={onActivateFolder}
onChangeFolder={chooseFolder}
onCollapseAll={collapseAll}
onLoadChildren={loadChildren}
onNodeOpenChange={setNodeOpen}
onPreviewFile={previewFile}
onRefresh={() => void refreshRoot()}
openState={openState}
/>
)}
</aside>
)
}
function RightSidebarChrome({
activeTab,
branch,
tabs
}: {
activeTab: RightSidebarTabId
branch: string
tabs: readonly RightSidebarTab[]
}) {
const { t } = useI18n()
const r = t.rightSidebar
return (
<header className="shrink-0 bg-transparent text-[0.75rem]">
<div className="flex items-center gap-2 px-2.5 py-1">
<nav aria-label={r.panelsAria} className="flex min-w-0 items-center gap-1">
{tabs.map(tab => {
const label = r[tab.labelKey]
return (
<Tip key={tab.id} label={label}>
<Button
aria-label={label}
aria-pressed={tab.id === activeTab}
className={cn(
'text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
tab.id === activeTab && 'bg-(--ui-control-active-background) text-foreground'
)}
onClick={() => setRightSidebarTab(tab.id)}
size="icon-xs"
variant="ghost"
>
<Codicon name={tab.icon} size="0.875rem" />
</Button>
</Tip>
)
})}
</nav>
{branch && (
<span className="ml-auto flex min-w-0 items-center gap-1 text-[0.6875rem] text-(--ui-text-tertiary)">
<Codicon className="shrink-0" name="git-branch" size="0.75rem" />
<span className="truncate">{branch}</span>
</span>
)}
</div>
</header>
)
}
interface FilesystemTabProps extends FileTreeBodyProps {
canCollapse: boolean
cwdName: string
@@ -2,10 +2,14 @@ import { atom } from 'nanostores'
import { persistBoolean, storedBoolean } from '@/lib/storage'
export type RightSidebarTabId = 'files' | 'git' | 'terminal' | 'web'
const TAKEOVER_KEY = 'hermes.desktop.terminalTakeover'
export const $rightSidebarTab = atom<RightSidebarTabId>('files')
export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false))
$terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active))
export const setRightSidebarTab = (tab: RightSidebarTabId) => $rightSidebarTab.set(tab)
export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active)
@@ -1,65 +0,0 @@
import type { Terminal } from '@xterm/xterm'
// Serialized view of the in-app terminal, handed to the agent's `read_terminal`
// tool. Line indices are absolute into xterm's buffer (0 = oldest scrollback
// line), so the agent can page with start_line/count against `total_lines`.
export interface TerminalReadResult {
total_lines: number
start: number
end: number
viewport_rows: number
cursor_row: number
text: string
}
export interface TerminalReadOptions {
start?: number
count?: number
}
type Reader = (opts: TerminalReadOptions) => TerminalReadResult
// The persistent terminal is a singleton (one xterm mounted forever), so a
// module-level slot is enough — set while the session is live, cleared on
// dispose. The gateway `terminal.read.request` handler reads through this.
let activeReader: Reader | null = null
export function setActiveTerminalReader(reader: Reader | null): void {
activeReader = reader
}
export function readActiveTerminal(opts: TerminalReadOptions = {}): TerminalReadResult | null {
return activeReader ? activeReader(opts) : null
}
export function makeTerminalReader(term: Terminal): Reader {
return ({ start, count }) => {
const buf = term.buffer.active
const total = buf.length
const rows = term.rows
// Default window = the visible screen; baseY is the viewport's top row.
const from = Math.max(0, Math.min(start ?? buf.baseY, total))
const to = Math.max(from, Math.min(from + Math.max(1, count ?? rows), total))
const lines: string[] = []
// translateToString(true) right-trims and resolves wide chars, dropping SGR
// colors — exactly what the agent wants.
for (let i = from; i < to; i += 1) {
lines.push(buf.getLine(i)?.translateToString(true) ?? '')
}
while (lines.length && !lines[lines.length - 1].trim()) {
lines.pop()
}
return {
total_lines: total,
start: from,
end: to,
viewport_rows: rows,
cursor_row: buf.baseY + buf.cursorY,
text: lines.join('\n')
}
}
}
@@ -1,5 +1,7 @@
import '@xterm/xterm/css/xterm.css'
import { useStore } from '@nanostores/react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
@@ -7,7 +9,7 @@ import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import { setTerminalTakeover } from '../store'
import { $terminalTakeover, setRightSidebarTab, setTerminalTakeover } from '../store'
import { addSelectionShortcutLabel } from './selection'
import { useTerminalSession } from './use-terminal-session'
@@ -19,32 +21,41 @@ interface TerminalTabProps {
export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
const { t } = useI18n()
const { addSelectionToChat, hostRef, selection, selectionStyle, shellName, status } = useTerminalSession({
cwd,
onAddSelectionToChat
})
const label = t.rightSidebar.terminalHide
const takeover = useStore($terminalTakeover)
const label = takeover ? t.rightSidebar.terminalSplit : t.rightSidebar.terminalFocus
const toggleTakeover = () => {
// Pre-select the Terminal tab so the slot is ready to host us on return.
if (takeover) {
setRightSidebarTab('terminal')
}
setTerminalTakeover(!takeover)
}
return (
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
<div className="flex h-8 shrink-0 items-center gap-2 px-2.5">
<SidebarPanelLabel className="text-(--ui-text-secondary)!">{shellName}</SidebarPanelLabel>
<SidebarPanelLabel className="text-white!">{shellName}</SidebarPanelLabel>
<Tip label={label}>
<Button
aria-label={label}
className="ml-auto size-6 rounded-md text-(--ui-text-secondary)!"
onClick={() => setTerminalTakeover(false)}
className="ml-auto size-6 rounded-md text-white!"
onClick={toggleTakeover}
size="icon"
type="button"
variant="ghost"
>
<Codicon name="close" size="0.875rem" />
<Codicon name={takeover ? 'screen-normal' : 'screen-full'} size="0.875rem" />
</Button>
</Tip>
</div>
<div className="relative min-h-0 flex-1 bg-(--ui-editor-surface-background) p-2">
<div className="relative min-h-0 flex-1 bg-[#002b36] p-2">
{status === 'starting' && (
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center">
<Loader
@@ -73,13 +84,12 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
</Button>
</div>
)}
{/* Outer div paints terminal inset; inner div is the xterm host so the
canvas sizes to the content area and p-2 stays as terminal padding.
Screen/viewport inherit the live skin surface so the terminal blends
with the app and follows light/dark; the xterm canvas itself is
painted the resolved surface color in use-terminal-session. */}
{/* Outer div paints the dark inset; inner div is the xterm host so the
canvas sizes to the *content* area and p-2 shows as terminal padding.
Forcing screen/viewport bg avoids xterm's default black peeking
through the unused pixels below the last full row. */}
<div
className="h-full min-h-0 overflow-hidden text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-(--ui-editor-surface-background)! [&_.xterm-viewport]:bg-(--ui-editor-surface-background)!"
className="h-full min-h-0 overflow-hidden text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-[#002b36]! [&_.xterm-viewport]:bg-[#002b36]!"
ref={hostRef}
/>
</div>
@@ -2,6 +2,8 @@ import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { TERMINAL_BG } from './selection'
import { TerminalTab } from './index'
/**
@@ -105,9 +107,7 @@ export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerm
visibility: visible ? 'visible' : 'hidden',
pointerEvents: visible ? 'auto' : 'none',
zIndex: 4,
// Match the live skin surface so the header strip (transparent) and body
// read as one cohesive pane instead of revealing a near-black slab behind.
backgroundColor: 'var(--ui-editor-surface-background)',
backgroundColor: TERMINAL_BG,
contain: 'layout size paint'
}
@@ -1,101 +1,38 @@
import type { ITheme, Terminal } from '@xterm/xterm'
import type { CSSProperties } from 'react'
import type { DesktopTerminalPalette } from '@/themes/types'
// Solarized-derived palette, but with bright ANSI 815 promoted to real
// accent variants instead of Schoonover's UI grays. Hermes' TUI skins (gold,
// crimson, ...) emit bright SGR codes that would otherwise wash out to gray.
// We always render the dark canvas — the app's light surfaces can't host the
// default skin without dropping below readable contrast.
export const TERMINAL_BG = '#002b36'
// VS Code's default integrated-terminal palette (terminalColorRegistry.ts) — a
// fixed table per theme type, not luminance-derived. Light/dark diverge on
// purpose so each stays legible (e.g. mustard yellow on white).
const DARK_THEME: ITheme = {
background: '#1e1e1e',
foreground: '#cccccc',
cursor: '#cccccc',
cursorAccent: '#1e1e1e',
selectionBackground: '#264f7866',
black: '#000000',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',
blue: '#2472c8',
magenta: '#bc3fbc',
cyan: '#11a8cd',
white: '#e5e5e5',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#e5e5e5'
const THEME: ITheme = {
background: TERMINAL_BG,
foreground: '#839496',
cursor: '#93a1a1',
cursorAccent: TERMINAL_BG,
selectionBackground: '#586e7555',
black: '#073642',
red: '#dc322f',
green: '#859900',
yellow: '#b58900',
blue: '#268bd2',
magenta: '#d33682',
cyan: '#2aa198',
white: '#eee8d5',
brightBlack: '#586e75',
brightRed: '#f25c54',
brightGreen: '#b3d437',
brightYellow: '#f7c948',
brightBlue: '#5fb3ff',
brightMagenta: '#ff6ab4',
brightCyan: '#5cd9c8',
brightWhite: '#fdf6e3'
}
const LIGHT_THEME: ITheme = {
background: '#ffffff',
foreground: '#333333',
cursor: '#333333',
cursorAccent: '#ffffff',
selectionBackground: '#add6ff80',
black: '#000000',
red: '#cd3131',
green: '#00bc00',
yellow: '#949800',
blue: '#0451a5',
magenta: '#bc05bc',
cyan: '#0598bc',
white: '#555555',
brightBlack: '#666666',
brightRed: '#cd3131',
brightGreen: '#14ce14',
brightYellow: '#b5ba00',
brightBlue: '#0451a5',
brightMagenta: '#bc05bc',
brightCyan: '#0598bc',
brightWhite: '#a5a5a5'
}
// Palette by painted mode, optionally overlaid with an imported theme's ANSI
// palette (Solarized terminal for the Solarized skin, etc.). `palette` only
// fills the slots it defines, so a partial import keeps the mode defaults for
// the rest. `background` is a fallback only — withSurface swaps in the live skin
// surface at runtime (keeping transparency); minimumContrastRatio keeps colors
// crisp against it.
export function terminalTheme(mode: 'light' | 'dark', palette?: DesktopTerminalPalette): ITheme {
const base = mode === 'dark' ? DARK_THEME : LIGHT_THEME
if (!palette) {
return base
}
const overlay = { ...base } as Record<string, string>
for (const [slot, value] of Object.entries(palette)) {
if (value) {
overlay[slot] = value
}
}
return overlay as ITheme
}
// Resolve --ui-editor-surface-background (a color-mix on the skin seed) to a
// concrete rgb for the WebGL renderer + contrast clamp. Custom props don't
// resolve via getComputedStyle, so probe a real background-color. Read AFTER
// applyTheme repaints (mount / rAF post-change) or it lags a frame behind.
export function resolveSurfaceColor(fallback: string): string {
if (typeof document === 'undefined' || !document.body) {
return fallback
}
const probe = document.createElement('span')
probe.style.cssText =
'position:absolute;visibility:hidden;pointer-events:none;background-color:var(--ui-editor-surface-background)'
document.body.appendChild(probe)
const resolved = getComputedStyle(probe).backgroundColor
probe.remove()
return resolved && resolved !== 'rgba(0, 0, 0, 0)' ? resolved : fallback
}
export const terminalTheme = (): ITheme => THEME
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
@@ -3,20 +3,12 @@ import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { WebglAddon } from '@xterm/addon-webgl'
import { Terminal } from '@xterm/xterm'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { triggerHaptic } from '@/lib/haptics'
import { useTheme } from '@/themes/context'
import { makeTerminalReader, setActiveTerminalReader } from './buffer'
import {
isAddSelectionShortcut,
resolveSurfaceColor,
terminalSelectionAnchor,
terminalSelectionLabel,
terminalTheme
} from './selection'
import { isAddSelectionShortcut, terminalSelectionAnchor, terminalSelectionLabel, terminalTheme } from './selection'
type TerminalStatus = 'closed' | 'open' | 'starting'
@@ -72,29 +64,10 @@ function stripEscapeSequences(data: string) {
return text
}
// Keep only the ANSI escape sequences from a chunk, dropping printable text. Lets
// us apply control codes (e.g. a clear-screen) while discarding boot spacers and
// zsh's reverse-video "%" partial-line marker.
function keepEscapeSequences(data: string) {
let index = 0
let out = ''
function isStartupSpacer(data: string) {
const text = stripEscapeSequences(data).replace(/[\s\r\n]/g, '')
while (index < data.length) {
if (data.charCodeAt(index) === 0x1b) {
const sequence = readEscapeSequence(data, index)
if (sequence) {
out += sequence
index += sequence.length
continue
}
}
index += 1
}
return out
return text === '' || text === '%'
}
function stripInitialPromptGap(data: string) {
@@ -122,14 +95,6 @@ interface UseTerminalSessionOptions {
onAddSelectionToChat: (text: string, label?: string) => void
}
// Bind the palette to the live skin surface so the terminal blends with the app
// (and the contrast clamp has a real background to work against).
function withSurface(theme: ReturnType<typeof terminalTheme>) {
const surface = resolveSurfaceColor(theme.background ?? '#ffffff')
return { ...theme, background: surface, cursorAccent: surface }
}
function transferHasDropCandidates(t: DataTransfer): boolean {
if (t.types?.includes(HERMES_PATHS_MIME)) {
return true
@@ -219,21 +184,8 @@ function quotePathForShell(path: string, shellName: string): string {
}
export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSessionOptions) {
// Key off renderedMode (the painted surface type), not resolvedMode (the
// clicked switch) — a skin can keep a light surface in "dark" mode, and we
// must match the surface or the ANSI palette inverts against it. themeName
// re-resolves the canvas surface on skin switches (same mode, new tint).
const { renderedMode, theme, themeName } = useTheme()
// Adopt the skin's ANSI palette when it ships one (imported VS Code themes do),
// matched to the painted variant; built-in skins carry none, so the terminal
// keeps its VS Code defaults. withSurface still owns the background, so this
// never touches transparency.
const ansiPalette = renderedMode === 'dark' ? (theme.darkTerminal ?? theme.terminal) : theme.terminal
const activeTheme = useMemo(() => terminalTheme(renderedMode, ansiPalette), [renderedMode, ansiPalette])
const initialThemeRef = useRef(activeTheme)
const hostRef = useRef<HTMLDivElement | null>(null)
const termRef = useRef<Terminal | null>(null)
const webglRef = useRef<WebglAddon | null>(null)
const sessionIdRef = useRef<string | null>(null)
const shellNameRef = useRef('shell')
const selectionLabelRef = useRef('')
@@ -248,26 +200,19 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
onAddSelectionToChatRef.current = onAddSelectionToChat
}, [onAddSelectionToChat])
// Live selection at call time. A redraw-heavy TUI (spinners, clocks) outruns
// onSelectionChange, so trust xterm directly — fall back to the native
// selection — rather than the cached ref / React state.
const readSelection = useCallback(
() => termRef.current?.getSelection() || window.getSelection()?.toString() || '',
[]
)
const addSelectionToChat = useCallback(() => {
const selectedText = readSelection() || selectionRef.current
const selectedText = selectionRef.current || termRef.current?.getSelection() || ''
const label =
selectionLabelRef.current ||
(termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
const trimmed = selectedText.trim()
if (!trimmed) {
return
}
const label =
selectionLabelRef.current ||
(termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
onAddSelectionToChatRef.current(trimmed, label)
termRef.current?.clearSelection()
selectionRef.current = ''
@@ -275,14 +220,15 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
setSelection('')
setSelectionStyle(null)
triggerHaptic('selection')
}, [readSelection])
}, [])
// Always listen — gating on the React selection state misses selections the
// TUI redraw races. Only swallow ⌘/Ctrl+L when there's text to send, else it
// must reach the shell as clear-screen.
useEffect(() => {
if (!selection.trim()) {
return
}
const onKeyDown = (event: KeyboardEvent) => {
if (!isAddSelectionShortcut(event) || !readSelection().trim()) {
if (!isAddSelectionShortcut(event)) {
return
}
@@ -294,7 +240,7 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [addSelectionToChat, readSelection])
}, [addSelectionToChat, selection])
useEffect(() => {
const host = hostRef.current
@@ -315,25 +261,12 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
allowTransparency: true,
convertEol: true,
cursorBlink: true,
fontFamily: "'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace",
fontFamily: "'SF Mono', 'Menlo', 'Cascadia Code', 'JetBrains Mono', monospace",
fontSize: 11,
fontWeight: '400',
fontWeightBold: '700',
letterSpacing: 0,
lineHeight: 1.12,
// Full-screen TUIs (hermes --tui, vim) grab the mouse, so a plain drag
// can't select — ⌥-drag (macOS) / Shift-drag (else) forces a native
// selection over mouse-mode apps, which ⌘/Ctrl+L then sends to chat.
macOptionClickForcesSelection: true,
macOptionIsMeta: true,
// VS Code/Cursor's secret sauce: terminal.integrated.minimumContrastRatio
// defaults to 4.5 there. xterm defaults to 1 (off), which paints the raw
// saturated ANSI palette — vivid green/cyan on white reads as candy.
// Clamping to 4.5:1 darkens/lightens foregrounds against the background
// at render time, matching the muted ink-like look of their terminal.
minimumContrastRatio: 4.5,
scrollback: 1000,
theme: withSurface(initialThemeRef.current)
theme: terminalTheme()
})
const fit = new FitAddon()
@@ -343,10 +276,18 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
term.loadAddon(new Unicode11Addon())
term.loadAddon(new WebLinksAddon())
term.unicode.activeVersion = '11'
term.open(host)
term.focus()
// Let the GUI chat agent read this pane via the `read_terminal` tool: the
// gateway's terminal.read.request handler serializes the buffer through this.
setActiveTerminalReader(makeTerminalReader(term))
// WebGL renderer matches the dashboard ChatPage path; xterm's default DOM
// renderer paints SGR via CSS classes that visibly mute against our skins.
try {
const webgl = new WebglAddon()
webgl.onContextLoss(() => webgl.dispose())
term.loadAddon(webgl)
} catch (err) {
console.warn('[hermes-terminal] WebGL unavailable; falling back to DOM', err)
}
const onDragOver = (e: DragEvent) => {
if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) {
@@ -387,75 +328,6 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
host.removeEventListener('drop', onDrop)
})
// A fresh prompt should sit at the top. Every resize SIGWINCHes the shell,
// which reprints its prompt and can leave stale blank rows above it. While
// the session is pristine (nothing run yet) we ask the shell to clear +
// redraw via Ctrl-L (\f) after the resize settles. Ctrl-L preserves
// multi-line prompts (term.clear() would drop all but the cursor row) and we
// stop the moment real output exists, so command scrollback is never wiped.
let promptPristine = true
let gapCleanupTimer = 0
// While armed, strip leading blank rows so the prompt lands at the very top
// (no starship `add_newline` gap). Re-armed before each Ctrl-L redraw so the
// resize cleanup doesn't reintroduce the blank line.
let stripLeading = true
const armedWrite = (data: string) => {
if (!stripLeading) {
term.write(data)
return
}
const next = stripInitialPromptGap(data)
const visible = stripEscapeSequences(next).replace(/[\s%]/g, '')
if (!visible) {
// Spacer / lone clear-screen / zsh `%` marker: apply control codes but
// drop the blank text and stay armed so the prompt still lands at top.
const controls = keepEscapeSequences(next)
if (controls) {
term.write(controls)
}
return
}
stripLeading = false
term.write(next)
}
const scheduleGapCleanup = () => {
if (!promptPristine) {
return
}
if (gapCleanupTimer) {
window.clearTimeout(gapCleanupTimer)
}
gapCleanupTimer = window.setTimeout(() => {
gapCleanupTimer = 0
const id = sessionIdRef.current
if (disposed || !id || !promptPristine) {
return
}
stripLeading = true
void terminalApi.write(id, '\f')
term.clearSelection()
}, 120)
}
cleanup.push(() => {
if (gapCleanupTimer) {
window.clearTimeout(gapCleanupTimer)
}
})
const fitAndResize = () => {
if (disposed || !host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) {
return
@@ -472,7 +344,6 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
if (id && (lastSentSize?.cols !== term.cols || lastSentSize?.rows !== term.rows)) {
lastSentSize = { cols: term.cols, rows: term.rows }
void terminalApi.resize(id, { cols: term.cols, rows: term.rows })
scheduleGapCleanup()
}
}
@@ -509,12 +380,6 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
const id = sessionIdRef.current
if (id) {
// Once the user submits a line, real output may follow — stop the
// pristine-prompt gap cleanup so we never clear command scrollback.
if (promptPristine && data.includes('\r')) {
promptPristine = false
}
void terminalApi.write(id, data)
}
})
@@ -531,88 +396,87 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
cleanup.push(() => selectionDisposable.dispose())
const startSession = () =>
void terminalApi
.start({ cols: term.cols, cwd, rows: term.rows })
.then(session => {
if (disposed) {
void terminalApi.dispose(session.id)
term.attachCustomKeyEventHandler(event => {
if (event.type !== 'keydown') {
return true
}
return
}
if (isAddSelectionShortcut(event) && term.hasSelection()) {
event.preventDefault()
addSelectionToChat()
sessionIdRef.current = session.id
lastSentSize = { cols: term.cols, rows: term.rows }
shellNameRef.current = session.shell || 'shell'
setShellName(session.shell || 'shell')
return false
}
const initial = term.hasSelection() ? term.getSelection() : ''
selectionRef.current = initial
selectionLabelRef.current = initial ? terminalSelectionLabel(term, shellNameRef.current, initial) : ''
return true
})
setStatus('open')
fitAndResize()
cleanup.push(
terminalApi.onData(session.id, armedWrite),
terminalApi.onExit(session.id, ({ code, signal }) => {
setStatus('closed')
term.write(`\r\n[terminal exited${signal ? `: ${signal}` : code !== null ? `: ${code}` : ''}]\r\n`)
})
)
void terminalApi
.start({ cols: term.cols, cwd, rows: term.rows })
.then(session => {
if (disposed) {
void terminalApi.dispose(session.id)
window.requestAnimationFrame(() => {
fitAndResize()
term.clearSelection() // drop any selection painted over transient boot rows
term.focus()
return
}
sessionIdRef.current = session.id
lastSentSize = { cols: term.cols, rows: term.rows }
shellNameRef.current = session.shell || 'shell'
setShellName(session.shell || 'shell')
if (term.hasSelection()) {
const currentSelection = term.getSelection()
selectionRef.current = currentSelection
selectionLabelRef.current = terminalSelectionLabel(term, shellNameRef.current, currentSelection)
} else {
selectionRef.current = ''
selectionLabelRef.current = ''
}
setStatus('open')
let wrotePromptContent = false
cleanup.push(
terminalApi.onData(session.id, data => {
if (wrotePromptContent) {
term.write(data)
return
}
if (isStartupSpacer(data)) {
return
}
const next = stripInitialPromptGap(data)
if (next) {
wrotePromptContent = true
term.write(next)
}
}),
terminalApi.onExit(session.id, sessionExit => {
const { code, signal } = sessionExit
setStatus('closed')
term.write(`\r\n[terminal exited${signal ? `: ${signal}` : code !== null ? `: ${code}` : ''}]\r\n`)
})
)
window.requestAnimationFrame(() => {
fitAndResize()
term.focus()
})
.catch(error => {
setStatus('closed')
term.write(`Terminal failed to start: ${error instanceof Error ? error.message : String(error)}\r\n`)
})
// Open + fit + start only once webfonts settle. Fitting with fallback metrics
// picks the wrong row count, the shell boots at that size, then the real font
// loads -> refit -> SIGWINCH -> the shell reprints its prompt lower, leaving
// stale blank rows (and a stray selection) above it.
const mount = () => {
if (disposed || !host.isConnected) {
return
}
term.open(host)
term.focus()
// WebGL renderer matches the dashboard ChatPage path; xterm's default DOM
// renderer paints SGR via CSS classes that visibly mute against our skins.
try {
const webgl = new WebglAddon()
webgl.onContextLoss(() => {
webgl.dispose()
webglRef.current = null
})
term.loadAddon(webgl)
webglRef.current = webgl
} catch (err) {
console.warn('[hermes-terminal] WebGL unavailable; falling back to DOM', err)
}
fitAndResize()
startSession()
}
// fonts.ready settles only already-requested faces; bold/italic aren't asked
// for until styled output paints (past atlas init), so warm them up front.
const warm = document.fonts?.load
? Promise.allSettled(['400', '700', 'italic 400'].map(v => document.fonts.load(`${v} 11px 'JetBrains Mono'`)))
: Promise.resolve()
void warm.then(mount, mount)
})
.catch(error => {
setStatus('closed')
term.write(`Terminal failed to start: ${error instanceof Error ? error.message : String(error)}\r\n`)
})
return () => {
disposed = true
cleanup.forEach(run => run())
setActiveTerminalReader(null)
const id = sessionIdRef.current
sessionIdRef.current = null
@@ -623,34 +487,12 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
term.dispose()
termRef.current = null
webglRef.current = null
shellNameRef.current = 'shell'
selectionRef.current = ''
selectionLabelRef.current = ''
}
}, [addSelectionToChat, cwd])
useEffect(() => {
const term = termRef.current
if (!term) {
return
}
// Re-resolve the surface in a rAF: ThemeProvider's applyTheme repaints the
// CSS vars in a sibling effect that runs after this one, so reading now
// would lag a mode behind. By the next frame the vars are current.
const raf = requestAnimationFrame(() => {
term.options.theme = withSurface(activeTheme)
// The WebGL renderer caches glyph colors in a texture atlas, so a
// light/dark switch leaves already-drawn cells stale until the atlas is
// cleared. No-op for the DOM fallback.
webglRef.current?.clearTextureAtlas()
})
return () => cancelAnimationFrame(raf)
}, [activeTheme, themeName])
return {
addSelectionToChat,
hostRef,
@@ -1,32 +0,0 @@
import { useStore } from '@nanostores/react'
import { SessionPickerDialog } from '@/components/session-picker'
import { $gatewayState, $selectedStoredSessionId, $sessionPickerOpen, setSessionPickerOpen } from '@/store/session'
interface SessionPickerOverlayProps {
onResume: (storedSessionId: string) => void
}
/**
* Mounts the session picker that `/resume` (and `/sessions`, `/switch`) opens —
* the desktop equivalent of the TUI's sessions overlay. Resuming runs through
* the same `resumeSession` path the sidebar uses.
*/
export function SessionPickerOverlay({ onResume }: SessionPickerOverlayProps) {
const open = useStore($sessionPickerOpen)
const gatewayOpen = useStore($gatewayState) === 'open'
const activeStoredSessionId = useStore($selectedStoredSessionId)
if (!gatewayOpen) {
return null
}
return (
<SessionPickerDialog
activeStoredSessionId={activeStoredSessionId}
onOpenChange={setSessionPickerOpen}
onResume={onResume}
open={open}
/>
)
}
-107
View File
@@ -1,107 +0,0 @@
import { useStore } from '@nanostores/react'
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useNavigate } from 'react-router-dom'
import { sessionTitle } from '@/lib/chat-runtime'
import { cn } from '@/lib/utils'
import { $attentionSessionIds, $workingSessionIds } from '@/store/session'
import { $switcherIndex, $switcherOpen, $switcherSessions, closeSwitcher } from '@/store/session-switcher'
import { HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from './floating-hud'
import { sessionRoute } from './routes'
// Compact session-switcher HUD — keyboard-driven from `use-keybinds`, rows
// clickable via mousedown (Ctrl+click on macOS). No Dialog: Tab stays global.
export function SessionSwitcher() {
const open = useStore($switcherOpen)
const sessions = useStore($switcherSessions)
const index = useStore($switcherIndex)
const working = useStore($workingSessionIds)
const attention = useStore($attentionSessionIds)
const navigate = useNavigate()
const activeRef = useRef<HTMLDivElement>(null)
useEffect(() => {
activeRef.current?.scrollIntoView({ block: 'nearest' })
}, [index, open])
if (!open || sessions.length === 0) {
return null
}
const workingIds = new Set(working)
const attentionIds = new Set(attention)
const pick = (sessionId: string) => {
closeSwitcher()
navigate(sessionRoute(sessionId))
}
return createPortal(
<>
{/* Transparent click-catcher: click-away closes, but no dim/blur. */}
<div
className="fixed inset-0 z-[219]"
onMouseDown={e => {
e.preventDefault()
closeSwitcher()
}}
/>
<div
className={cn(
HUD_POSITION,
HUD_SURFACE,
'dt-portal-scrollbar z-[220] max-h-[min(22rem,64vh)] w-[min(19rem,calc(100vw-2rem))] select-none overflow-y-auto p-1'
)}
>
{sessions.map((session, i) => {
const selected = i === index
return (
<div
className={cn(
'flex cursor-pointer items-center rounded leading-tight',
HUD_ITEM,
HUD_TEXT,
selected ? 'bg-accent text-accent-foreground' : 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background)'
)}
key={session.id}
onMouseDown={e => {
e.preventDefault()
pick(session.id)
}}
ref={selected ? activeRef : undefined}
>
<SwitcherDot attention={attentionIds.has(session.id)} working={workingIds.has(session.id)} />
<span className="min-w-0 flex-1 truncate">{sessionTitle(session)}</span>
{i < 9 && (
<span
className={cn(
'shrink-0 font-mono text-[0.625rem] tabular-nums',
selected ? 'text-accent-foreground/70' : 'text-(--ui-text-quaternary)'
)}
>
{i + 1}
</span>
)}
</div>
)
})}
</div>
</>,
document.body
)
}
function SwitcherDot({ attention, working }: { attention: boolean; working: boolean }) {
return (
<span
className={cn(
'size-1 shrink-0 rounded-full',
attention ? 'bg-amber-400' : working ? 'animate-pulse bg-(--ui-accent)' : 'bg-(--ui-text-quaternary)/50'
)}
/>
)
}
@@ -1,7 +1,6 @@
import type { QueryClient } from '@tanstack/react-query'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
import {
appendAssistantTextPart,
appendReasoningPart,
@@ -15,11 +14,9 @@ import {
upsertToolPart
} from '@/lib/chat-messages'
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
import { triggerHaptic } from '@/lib/haptics'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { setClarifyRequest } from '@/store/clarify'
import { $gateway } from '@/store/gateway'
import { notify } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
@@ -64,67 +61,6 @@ interface QueuedStreamDeltas {
reasoning: string
}
type SessionRuntimeStatePatch = Partial<
Pick<
ClientSessionState,
| 'branch'
| 'cwd'
| 'fast'
| 'model'
| 'personality'
| 'provider'
| 'reasoningEffort'
| 'serviceTier'
| 'yolo'
>
>
function sessionInfoStatePatch(payload: GatewayEventPayload | undefined): SessionRuntimeStatePatch {
const patch: SessionRuntimeStatePatch = {}
if (typeof payload?.model === 'string') {
patch.model = payload.model || ''
}
if (typeof payload?.provider === 'string') {
patch.provider = payload.provider || ''
}
if (typeof payload?.cwd === 'string') {
patch.cwd = payload.cwd
}
if (typeof payload?.branch === 'string') {
patch.branch = payload.branch
}
if (typeof payload?.personality === 'string') {
patch.personality = normalizePersonalityValue(payload.personality)
}
if (typeof payload?.reasoning_effort === 'string') {
patch.reasoningEffort = payload.reasoning_effort
}
if (typeof payload?.service_tier === 'string') {
patch.serviceTier = payload.service_tier
}
if (typeof payload?.fast === 'boolean') {
patch.fast = payload.fast
}
if (typeof payload?.yolo === 'boolean') {
patch.yolo = payload.yolo
}
return patch
}
function hasSessionInfoStatePatch(patch: SessionRuntimeStatePatch): boolean {
return Object.keys(patch).length > 0
}
// Minimum gap between two assistant-text flushes during a stream. Was 16ms
// (rAF only), which at typical LLM token rates of ~30-80 tok/sec meant every
// token got its own React commit + Streamdown markdown re-parse, scaling
@@ -677,9 +613,6 @@ export function useMessageStream({
(event: RpcEvent) => {
const payload = event.payload as GatewayEventPayload | undefined
const explicitSid = event.session_id || ''
if (!explicitSid && gatewayEventRequiresSessionId(event.type)) {
return
}
const sessionId = explicitSid || activeSessionIdRef.current
const isActiveEvent = !!sessionId && sessionId === activeSessionIdRef.current
@@ -689,13 +622,13 @@ export function useMessageStream({
// Apply session-scoped fields when the event targets the active
// session, OR when it's a global broadcast and we have no session.
const apply = explicitSid ? isActiveEvent : !activeSessionIdRef.current
const statePatch = sessionInfoStatePatch(payload)
const hasStatePatch = hasSessionInfoStatePatch(statePatch)
const modelChanged = typeof payload?.model === 'string'
const providerChanged = typeof payload?.provider === 'string'
const runningChanged = typeof payload?.running === 'boolean'
if (apply) {
const runtimeInfo: { branch?: string; cwd?: string } = {}
if (modelChanged) {
setCurrentModel(payload!.model || '')
}
@@ -706,10 +639,20 @@ export function useMessageStream({
if (typeof payload?.cwd === 'string') {
setCurrentCwd(payload.cwd)
runtimeInfo.cwd = payload.cwd
}
if (typeof payload?.branch === 'string') {
setCurrentBranch(payload.branch)
runtimeInfo.branch = payload.branch
}
if (sessionId && (runtimeInfo.cwd !== undefined || runtimeInfo.branch !== undefined)) {
updateSessionState(sessionId, state => ({
...state,
branch: runtimeInfo.branch ?? state.branch,
cwd: runtimeInfo.cwd ?? state.cwd
}))
}
if (typeof payload?.personality === 'string') {
@@ -731,18 +674,7 @@ export function useMessageStream({
if (typeof payload?.yolo === 'boolean') {
setYoloActive(payload.yolo)
}
}
if (sessionId && hasStatePatch) {
updateSessionState(sessionId, state => ({
...state,
...statePatch,
branch: statePatch.branch ?? state.branch,
cwd: statePatch.cwd ?? state.cwd
}))
}
if (apply) {
if (runningChanged && sessionId) {
updateSessionState(sessionId, state => {
const busy = Boolean(payload!.running)
@@ -933,8 +865,6 @@ export function useMessageStream({
// raise it and wait — the sidebar flags "needs input" and the inline bar
// surfaces once the user focuses that chat.
setApprovalRequest({
// false only when a tirith warning forbids it; backend omits the field otherwise.
allowPermanent: payload?.allow_permanent !== false,
command: typeof payload?.command === 'string' ? payload.command : '',
description: typeof payload?.description === 'string' ? payload.description : 'dangerous command',
sessionId: sessionId ?? null
@@ -972,21 +902,6 @@ export function useMessageStream({
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
}
} else if (event.type === 'terminal.read.request') {
// read_terminal tool: serialize the renderer's xterm buffer and answer
// immediately (Python blocks on the respond). Empty text = no live pane.
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
if (requestId) {
const start = typeof payload?.start === 'number' ? payload.start : undefined
const count = typeof payload?.count === 'number' ? payload.count : undefined
const result = readActiveTerminal({ start, count })
void $gateway.get()?.request('terminal.read.respond', {
request_id: requestId,
text: result ? JSON.stringify(result) : ''
})
}
} else if (event.type === 'error') {
const errorMessage = payload?.message || 'Hermes reported an error'
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
@@ -1,77 +0,0 @@
import { renderHook } from '@testing-library/react'
import { QueryClient } from '@tanstack/react-query'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getGlobalModelInfo } from '@/hermes'
import {
$activeSessionId,
$currentModel,
$currentProvider,
setCurrentModel,
setCurrentProvider
} from '@/store/session'
import { useModelControls } from './use-model-controls'
vi.mock('@/hermes', () => ({
getGlobalModelInfo: vi.fn(),
setGlobalModel: vi.fn()
}))
describe('useModelControls.refreshCurrentModel', () => {
beforeEach(() => {
$activeSessionId.set(null)
setCurrentModel('')
setCurrentProvider('')
})
afterEach(() => {
vi.restoreAllMocks()
$activeSessionId.set(null)
setCurrentModel('')
setCurrentProvider('')
})
it('applies the global model when there is no active runtime session', async () => {
vi.mocked(getGlobalModelInfo).mockResolvedValue({
model: 'openai/gpt-5.5',
provider: 'openai-codex'
})
const { result } = renderHook(() =>
useModelControls({
activeSessionId: null,
queryClient: new QueryClient(),
requestGateway: vi.fn()
})
)
await result.current.refreshCurrentModel()
expect($currentModel.get()).toBe('openai/gpt-5.5')
expect($currentProvider.get()).toBe('openai-codex')
})
it('does not clobber the active session footer state with global model info', async () => {
setCurrentModel('deepseek/deepseek-v4-pro')
setCurrentProvider('deepseek')
$activeSessionId.set('runtime-1')
vi.mocked(getGlobalModelInfo).mockResolvedValue({
model: 'openai/gpt-5.5',
provider: 'openai-codex'
})
const { result } = renderHook(() =>
useModelControls({
activeSessionId: 'runtime-1',
queryClient: new QueryClient(),
requestGateway: vi.fn()
})
)
await result.current.refreshCurrentModel()
expect($currentModel.get()).toBe('deepseek/deepseek-v4-pro')
expect($currentProvider.get()).toBe('deepseek')
})
})
@@ -4,13 +4,7 @@ import { useCallback } from 'react'
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
import { useI18n } from '@/i18n'
import { notifyError } from '@/store/notifications'
import {
$activeSessionId,
$currentModel,
$currentProvider,
setCurrentModel,
setCurrentProvider
} from '@/store/session'
import { $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session'
import type { ModelOptionsResponse } from '@/types/hermes'
interface ModelSelection {
@@ -45,13 +39,6 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
try {
const result = await getGlobalModelInfo()
// A resumed/live session owns the footer model state. Global config
// refreshes (gateway boot, profile swap, settings save) must not clobber
// the active chat's runtime model/provider in the status bar.
if ($activeSessionId.get()) {
return
}
if (typeof result.model === 'string') {
setCurrentModel(result.model)
}
@@ -1,13 +1,12 @@
import { cleanup, render, waitFor } from '@testing-library/react'
import { cleanup, render } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { useEffect, useRef } from 'react'
import { useEffect } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $composerAttachments, type ComposerAttachment } from '@/store/composer'
import { $connection, $sessions, setSessions } from '@/store/session'
import { $sessions, setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import { uploadComposerAttachment, usePromptActions } from './use-prompt-actions'
import { usePromptActions } from './use-prompt-actions'
vi.mock('@/hermes', () => ({
getProfiles: vi.fn(async () => ({ profiles: [] })),
@@ -42,12 +41,8 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
}
interface HarnessHandle {
cancelRun: () => Promise<void>
steerPrompt: (text: string) => Promise<boolean>
submitText: (
text: string,
options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
) => Promise<boolean>
submitText: (text: string, options?: { attachments?: never[]; fromQueue?: boolean }) => Promise<boolean>
}
function Harness({
@@ -55,29 +50,17 @@ function Harness({
onReady,
onSeedState,
refreshSessions,
requestGateway,
resumeStoredSession,
storedSessionId
requestGateway
}: {
busyRef?: MutableRefObject<boolean>
onReady: (handle: HarnessHandle) => void
onSeedState?: (state: Record<string, unknown>) => void
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
storedSessionId?: null | string
}) {
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = {
current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId
}
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
const localBusyRef = busyRef ?? { current: false }
const stateRef = useRef({
messages: [],
busy: false,
awaitingResponse: false,
interrupted: true
} as never)
const actions = usePromptActions({
activeSessionId: RUNTIME_SESSION_ID,
@@ -88,14 +71,17 @@ function Harness({
handleSkinCommand: () => '',
refreshSessions,
requestGateway,
resumeStoredSession: resumeStoredSession ?? (() => undefined),
selectedStoredSessionIdRef,
startFreshSessionDraft: () => undefined,
sttEnabled: false,
updateSessionState: (_sessionId, updater) => {
// Seed with interrupted:true so we can prove a fresh submit clears it.
const next = updater(stateRef.current) as unknown as Record<string, unknown>
stateRef.current = next as never
const next = updater({
messages: [],
busy: false,
awaitingResponse: false,
interrupted: true
} as never) as unknown as Record<string, unknown>
onSeedState?.(next)
return next as never
@@ -103,12 +89,8 @@ function Harness({
})
useEffect(() => {
onReady({
cancelRun: actions.cancelRun,
steerPrompt: actions.steerPrompt,
submitText: actions.submitText
})
}, [actions.cancelRun, actions.steerPrompt, actions.submitText, onReady])
onReady({ steerPrompt: actions.steerPrompt, submitText: actions.submitText })
}, [actions.steerPrompt, actions.submitText, onReady])
return null
}
@@ -200,68 +182,6 @@ describe('usePromptActions /title', () => {
})
})
describe('usePromptActions desktop slash pickers', () => {
beforeEach(() => {
setSessions(() => [sessionInfo({ id: '20260610_120000_abcdef', title: 'Loaded session' })])
})
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('resumes an exact session id even when it is not in the loaded sidebar cache', async () => {
const resumeStoredSession = vi.fn(async () => undefined)
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
resumeStoredSession={resumeStoredSession}
/>
)
await handle!.submitText('/resume 20260610_130000_123abc')
expect(resumeStoredSession).toHaveBeenCalledWith('20260610_130000_123abc')
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
})
it('marks a timed-out handoff as failed so the next attempt can retry', async () => {
vi.useFakeTimers()
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'handoff.state') {
return { state: 'pending' } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
const result = handle!.submitText('/handoff telegram')
await vi.advanceTimersByTimeAsync(61_000)
await result
expect(calls.some(call => call.method === 'handoff.request')).toBe(true)
expect(calls).toContainEqual({
method: 'handoff.fail',
params: {
error: expect.stringContaining('Timed out'),
session_id: RUNTIME_SESSION_ID
}
})
})
})
describe('usePromptActions submit / queue drain semantics', () => {
afterEach(() => {
cleanup()
@@ -394,469 +314,3 @@ describe('usePromptActions steerPrompt', () => {
expect(requestGateway).not.toHaveBeenCalled()
})
})
describe('usePromptActions file attachment sync', () => {
afterEach(() => {
cleanup()
$connection.set(null)
vi.restoreAllMocks()
})
function fileAttachment(): ComposerAttachment {
return {
id: 'file:report.txt',
kind: 'file',
label: 'report.txt',
path: '/Users/alice/Downloads/report.txt',
refText: '@file:`/Users/alice/Downloads/report.txt`'
}
}
it('uploads file bytes via file.attach on a remote gateway and submits the rewritten ref', async () => {
// Remote gateway can't read the client-disk path, so the desktop must upload
// the bytes and submit the workspace-relative ref the gateway hands back —
// not the original /Users/... path (which would dead-end as "outside the
// allowed workspace").
$connection.set({ mode: 'remote' } as never)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl: vi.fn(async () => 'data:text/plain;base64,aGVsbG8=') }
})
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'file.attach') {
return {
attached: true,
path: '/remote/work/.hermes/desktop-attachments/report.txt',
ref_text: '@file:.hermes/desktop-attachments/report.txt',
uploaded: true
} as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
const ok = await handle!.submitText('convert this to epub', { attachments: [fileAttachment()] })
expect(ok).toBe(true)
expect(calls.map(c => c.method)).toEqual(['file.attach', 'prompt.submit'])
expect(calls[0]?.params).toMatchObject({
session_id: RUNTIME_SESSION_ID,
path: '/Users/alice/Downloads/report.txt',
name: 'report.txt',
data_url: 'data:text/plain;base64,aGVsbG8='
})
expect(calls[1]?.params).toEqual({
session_id: RUNTIME_SESSION_ID,
text: '@file:.hermes/desktop-attachments/report.txt\n\nconvert this to epub'
})
})
it('passes a path-less @file: ref straight through (no path = nothing to upload)', async () => {
// Submit-layer contract: only attachments that carry a `path` are upload
// candidates. A path-less ref (an @-mention/context ref or pasted text)
// has no bytes to send, so syncAttachments leaves it untouched and the ref
// reaches the gateway as-is — correct for workspace-relative refs.
//
// The MahmoudR drag-drop bug (a Finder PDF that became a local-path text
// ref in remote mode) is fixed upstream at the DROP layer: OS drops now
// carry a path and route through the upload pipeline instead of becoming a
// path-less inline ref. See partitionDroppedFiles in use-composer-actions.
$connection.set({ mode: 'remote' } as never)
const readFileDataUrl = vi.fn(async () => 'data:application/pdf;base64,JVBERi0=')
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl }
})
const pathlessRef: ComposerAttachment = {
id: 'file:devis',
kind: 'file',
label: 'DEVIS_signed.pdf',
// NOTE: no `path` field — only the pre-baked local @file: ref.
refText: '@file:`/Users/mahmoud/Downloads/DEVIS_signed.pdf`'
}
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
const ok = await handle!.submitText('read this file', { attachments: [pathlessRef] })
expect(ok).toBe(true)
// No path → no file.attach, no byte read: the ref passes through unchanged.
expect(calls.map(c => c.method)).toEqual(['prompt.submit'])
expect(readFileDataUrl).not.toHaveBeenCalled()
expect(calls[0]?.params?.text).toContain('@file:`/Users/mahmoud/Downloads/DEVIS_signed.pdf`')
})
it('passes the path directly via file.attach in local mode (no byte upload)', async () => {
$connection.set({ mode: 'local' } as never)
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'file.attach') {
return { attached: true, ref_text: '@file:data/report.txt', uploaded: false } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
const ok = await handle!.submitText('summarize', { attachments: [fileAttachment()] })
expect(ok).toBe(true)
expect(calls[0]?.method).toBe('file.attach')
// Local mode sends no data_url — the gateway shares this disk.
expect(calls[0]?.params).not.toHaveProperty('data_url')
expect(calls[1]).toEqual({
method: 'prompt.submit',
params: { session_id: RUNTIME_SESSION_ID, text: '@file:data/report.txt\n\nsummarize' }
})
})
})
describe('usePromptActions eager-upload races', () => {
beforeEach(() => {
setSessions(() => [sessionInfo()])
$composerAttachments.set([])
})
afterEach(() => {
cleanup()
$composerAttachments.set([])
$connection.set(null)
vi.restoreAllMocks()
})
it('joins an in-flight eager upload at submit instead of staging the file twice', async () => {
// Drop-then-immediately-Enter: the drop kicks off an eager file.attach; if
// submit doesn't join it, both calls stage the file and leave a duplicate
// under .hermes/desktop-attachments/. Submit must await the in-flight upload
// and reuse its gateway-side ref.
$connection.set({ mode: 'remote' } as never)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl: vi.fn(async () => 'data:application/pdf;base64,JVBERi0=') }
})
let releaseAttach: () => void = () => {}
const methods: string[] = []
const requestGateway = vi.fn(async (method: string) => {
methods.push(method)
if (method === 'file.attach') {
// Block until released so submit runs while the upload is in flight.
await new Promise<void>(resolve => {
releaseAttach = resolve
})
return { attached: true, ref_text: '@file:.hermes/desktop-attachments/doc.pdf', uploaded: true } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
await waitFor(() => expect(handle).not.toBeNull())
// Drop a file → the eager effect fires file.attach and blocks on it.
$composerAttachments.set([{ id: 'file:doc.pdf', kind: 'file', label: 'doc.pdf', path: '/Users/me/doc.pdf' }])
await waitFor(() => expect(methods.filter(m => m === 'file.attach').length).toBe(1))
// Submit reads the store, sees the upload in flight, and joins it.
const submitting = handle!.submitText('here you go')
releaseAttach()
expect(await submitting).toBe(true)
// Exactly one file.attach (submit reused the eager result), then the send.
expect(methods.filter(m => m === 'file.attach').length).toBe(1)
expect(methods).toContain('prompt.submit')
})
})
describe('usePromptActions sleep/wake session recovery', () => {
const STORED_SESSION_ID = 'stored-db-xyz789'
const RECOVERED_SESSION_ID = 'rt-recovered-456'
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('resumes the stored session and retries once when prompt.submit reports "session not found"', async () => {
// After sleep/wake the gateway's in-memory session table is cleared, so the
// first prompt.submit with the stale runtime id fails. The hook resumes the
// durable stored id (which survives gateway restarts), gets a fresh live id,
// and retries the send transparently.
const calls: { method: string; params?: Record<string, unknown> }[] = []
let submitAttempts = 0
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'prompt.submit') {
submitAttempts += 1
if (submitAttempts === 1) {
throw new Error('session not found')
}
return {} as never
}
if (method === 'session.resume') {
return { session_id: RECOVERED_SESSION_ID } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={STORED_SESSION_ID}
/>
)
const ok = await handle!.submitText('message after wake')
expect(ok).toBe(true)
// First submit (stale id) → session.resume (stored id) → retry submit (fresh id).
expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' })
})
it('resumes the stored session and retries once when session.interrupt reports "session not found"', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
let interruptAttempts = 0
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'session.interrupt') {
interruptAttempts += 1
if (interruptAttempts === 1) {
throw new Error('session not found')
}
return {} as never
}
if (method === 'session.resume') {
return { session_id: RECOVERED_SESSION_ID } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={STORED_SESSION_ID}
/>
)
await waitFor(() => expect(handle).not.toBeNull())
await handle!.cancelRun()
expect(calls.map(c => c.method)).toEqual(['session.interrupt', 'session.resume', 'session.interrupt'])
expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID })
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID })
})
it('surfaces the original error (no resume) when the failure is not "session not found"', async () => {
const calls: string[] = []
const states: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string) => {
calls.push(method)
if (method === 'prompt.submit') {
throw new Error('session busy')
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
onSeedState={s => states.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={STORED_SESSION_ID}
/>
)
// submitText swallows the error into an inline bubble and returns false.
expect(await handle!.submitText('message')).toBe(false)
// No resume attempt for a non-recoverable error.
expect(calls).not.toContain('session.resume')
})
it('surfaces "session not found" (no resume) when there is no stored session id', async () => {
const calls: string[] = []
const requestGateway = vi.fn(async (method: string) => {
calls.push(method)
if (method === 'prompt.submit') {
throw new Error('session not found')
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={null}
/>
)
// With a null stored ref, the `&& selectedStoredSessionIdRef.current` guard
// short-circuits — no resume is attempted and the error surfaces normally.
expect(await handle!.submitText('message')).toBe(false)
expect(calls).not.toContain('session.resume')
})
})
describe('usePromptActions eager attachment upload (drop-time)', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
$connection.set(null)
$composerAttachments.set([])
})
it('uploads a dropped file the moment it lands (active session) and rewrites the chip with the gateway ref', async () => {
// A Finder drop adds a chip with a local path but no attachedSessionId. With
// a session already open, the hook should stage it right away — so the send
// is instant and the card can show a spinner while bytes upload — instead of
// waiting for submit.
$connection.set({ mode: 'remote' } as never)
const readFileDataUrl = vi.fn(async () => 'data:application/pdf;base64,JVBERi0=')
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { readFileDataUrl } })
const calls: string[] = []
const requestGateway = vi.fn(async (method: string) => {
calls.push(method)
if (method === 'file.attach') {
return { attached: true, ref_text: '@file:.hermes/desktop-attachments/DEVIS_signed.pdf', uploaded: true } as never
}
return {} as never
})
$composerAttachments.set([
{ id: 'file:devis', kind: 'file', label: 'DEVIS_signed.pdf', path: '/Users/mahmoud/Downloads/DEVIS_signed.pdf' }
])
render(<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
await waitFor(() => expect(calls).toContain('file.attach'))
await waitFor(() => expect($composerAttachments.get()[0]?.attachedSessionId).toBe(RUNTIME_SESSION_ID))
const chip = $composerAttachments.get()[0]!
expect(chip.refText).toBe('@file:.hermes/desktop-attachments/DEVIS_signed.pdf')
expect(chip.uploadState).toBeUndefined()
expect(readFileDataUrl).toHaveBeenCalledWith('/Users/mahmoud/Downloads/DEVIS_signed.pdf')
})
it('flags the chip uploadState=error when the eager upload fails, keeping the path so submit can retry', async () => {
$connection.set({ mode: 'remote' } as never)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl: vi.fn(async () => 'data:application/pdf;base64,JVBERi0=') }
})
const requestGateway = vi.fn(async (method: string) => {
if (method === 'file.attach') {
throw new Error('[Errno 13] Permission denied')
}
return {} as never
})
$composerAttachments.set([{ id: 'file:x', kind: 'file', label: 'x.pdf', path: '/abs/x.pdf' }])
render(<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
await waitFor(() => expect($composerAttachments.get()[0]?.uploadState).toBe('error'))
expect($composerAttachments.get()[0]?.attachedSessionId).toBeUndefined()
expect($composerAttachments.get()[0]?.path).toBe('/abs/x.pdf')
})
it('does not eagerly re-upload a chip already attached to this session', async () => {
$connection.set({ mode: 'remote' } as never)
const requestGateway = vi.fn(async () => ({}) as never)
$composerAttachments.set([
{
id: 'file:done',
kind: 'file',
label: 'done.pdf',
path: '/abs/done.pdf',
refText: '@file:data/done.pdf',
attachedSessionId: RUNTIME_SESSION_ID
}
])
render(<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
await Promise.resolve()
expect(requestGateway).not.toHaveBeenCalledWith('file.attach', expect.anything())
})
})
describe('uploadComposerAttachment remote read failures', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('turns the raw 16MB IPC cap error into a friendly remote-gateway message', async () => {
// electron/hardening.cjs rejects the readFileDataUrl IPC with this exact
// shape when a file exceeds DATA_URL_READ_MAX_BYTES.
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
readFileDataUrl: vi.fn(async () => {
throw new Error('File preview failed: file is too large (20971520 bytes; limit 16777216 bytes).')
})
}
})
const requestGateway = vi.fn(async () => ({}) as never)
await expect(
uploadComposerAttachment(
{ id: 'file:big', kind: 'file', label: 'huge.csv', path: '/abs/huge.csv' },
{ remote: true, requestGateway, sessionId: RUNTIME_SESSION_ID }
)
).rejects.toThrow('huge.csv is too large to upload to the remote gateway (max 16 MB).')
// The cap is hit before any gateway round-trip.
expect(requestGateway).not.toHaveBeenCalled()
})
it('passes non-cap read errors through unchanged', async () => {
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
readFileDataUrl: vi.fn(async () => {
throw new Error('ENOENT: no such file')
})
}
})
await expect(
uploadComposerAttachment(
{ id: 'file:gone', kind: 'file', label: 'gone.csv', path: '/abs/gone.csv' },
{ remote: true, requestGateway: vi.fn(async () => ({}) as never), sessionId: RUNTIME_SESSION_ID }
)
).rejects.toThrow('ENOENT: no such file')
})
})
File diff suppressed because it is too large Load Diff
@@ -84,60 +84,6 @@ describe('useRouteResume', () => {
expect(resumeSession).not.toHaveBeenCalled()
})
it('self-heals a stranded routed session (null selected/active, same pathname, not a fresh draft)', () => {
const resumeSession = vi.fn(async () => undefined)
const startFreshSessionDraft = vi.fn()
const activeSessionIdRef: MutableRefObject<null | string> = { current: 'runtime-1' }
const creatingSessionRef = { current: false }
const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) }
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: 'session-1' }
const { rerender } = render(
<RouteResumeHarness
activeSessionId="runtime-1"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId="session-1"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).not.toHaveBeenCalled()
// A create/stream race nulls selected/active but the route stays on the
// session and freshDraftReady is false (NOT a new-chat transition).
activeSessionIdRef.current = null
selectedStoredSessionIdRef.current = null
rerender(
<RouteResumeHarness
activeSessionId={null}
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId={null}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).toHaveBeenCalledTimes(1)
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
})
it('resumes when pathname changes to a routed session', () => {
const resumeSession = vi.fn(async () => undefined)
const startFreshSessionDraft = vi.fn()
@@ -187,72 +133,4 @@ describe('useRouteResume', () => {
expect(resumeSession).toHaveBeenCalledTimes(1)
expect(resumeSession).toHaveBeenCalledWith('session-2', true)
})
it('resumes the selected route again when the gateway reconnects', () => {
const resumeSession = vi.fn(async () => undefined)
const startFreshSessionDraft = vi.fn()
const activeSessionIdRef: MutableRefObject<null | string> = { current: 'runtime-1' }
const creatingSessionRef = { current: false }
const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) }
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: 'session-1' }
const { rerender } = render(
<RouteResumeHarness
activeSessionId="runtime-1"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId="session-1"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).not.toHaveBeenCalled()
rerender(
<RouteResumeHarness
activeSessionId="runtime-1"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="closed"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId="session-1"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
rerender(
<RouteResumeHarness
activeSessionId="runtime-1"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId="session-1"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).toHaveBeenCalledTimes(1)
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
})
})
@@ -56,19 +56,13 @@ export function useRouteResume({
startFreshSessionDraft
}: RouteResumeOptions) {
const lastPathnameRef = useRef<string | null>(null)
const seenGatewayStateRef = useRef(false)
const wasGatewayOpenRef = useRef(false)
useEffect(() => {
const gatewayOpen = gatewayState === 'open'
const pathnameChanged = lastPathnameRef.current !== locationPathname
// Fire only on a genuine closed->open transition (a reconnect). seenGatewayStateRef
// stays false until the first effect run, so a session that mounts with the gateway
// already open is not mistaken for "became open" and does not double-resume with the
// pathname-driven initial resume below.
const gatewayBecameOpen = seenGatewayStateRef.current && !wasGatewayOpenRef.current && gatewayOpen
const gatewayBecameOpen = !wasGatewayOpenRef.current && gatewayOpen
lastPathnameRef.current = locationPathname
seenGatewayStateRef.current = true
wasGatewayOpenRef.current = gatewayOpen
if (currentView !== 'chat' || !gatewayOpen) {
@@ -83,33 +77,12 @@ export function useRouteResume({
Boolean(cachedRuntime) &&
cachedRuntime === activeSessionIdRef.current
// Self-heal a desynced view: the route points at a session that isn't the
// loaded one. A create/stream race can leave selected/active null while
// the route stays on /:sid (symptom: brand-new chat shows "Thinking" then
// an empty transcript even though the turn completed and persisted). The
// pathname didn't change, so the normal gate would skip and the view stays
// stuck empty forever. selectedStoredSessionIdRef is set synchronously at
// resume entry, so this can't loop; the resume's cached fast-path restores
// the already-streamed messages without a refetch.
//
// Crucially this must NOT fire during a /:sid -> /new transition, where
// startFreshSessionDraft nulls selected/active one render before the
// pathname flips to / (same null+/:sid signature). freshDraftReady is the
// discriminator: it's true while heading into a blank new chat, false when
// genuinely stranded on a routed session.
const stuckOnRoutedSession = routedSessionId !== selectedStoredSessionIdRef.current && !freshDraftReady
// Resume when the route meaningfully changed, the gateway just opened, or
// we're stranded on a routed session that never loaded. The first two
// guard against a transient /:sid re-resume during "new chat" state clears
// Resume only when the route meaningfully changed (or gateway just opened).
// This avoids a transient /:sid re-resume during "new chat" state clears
// before the pathname updates from /:sid -> /.
const shouldResume = pathnameChanged || gatewayBecameOpen || stuckOnRoutedSession
const shouldResume = pathnameChanged || gatewayBecameOpen
// On a reconnect (gatewayBecameOpen) re-resume even when the route looks
// `alreadyActive`: the cached runtime id can be stale once the gateway
// rebinds/reaps the session on its side, and trusting it strands Desktop on
// a dead id ("session not found"). Otherwise keep skipping when already active.
if ((gatewayBecameOpen || !alreadyActive) && shouldResume && !creatingSessionRef.current) {
if (!alreadyActive && shouldResume && !creatingSessionRef.current) {
void resumeSession(routedSessionId, true)
}
@@ -2,12 +2,13 @@ import type { MutableRefObject } from 'react'
import { useCallback, useRef } from 'react'
import type { NavigateFunction } from 'react-router-dom'
import { deleteSession, getSessionMessages, listAllProfileSessions, setSessionArchived } from '@/hermes'
import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
import { normalizePersonalityValue } from '@/lib/chat-runtime'
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
import { setSessionYolo } from '@/lib/yolo-session'
import { clearComposerAttachments, clearComposerDraft } from '@/store/composer'
import { clearQueuedPrompts } from '@/store/composer-queue'
import { $pinnedSessionIds } from '@/store/layout'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
@@ -18,6 +19,7 @@ import {
$messages,
$sessions,
$yoloActive,
getRememberedWorkspaceCwd,
sessionPinId,
setActiveSessionId,
setAwaitingResponse,
@@ -39,11 +41,10 @@ import {
setSessionStartedAt,
setSessionsTotal,
setTurnStartedAt,
setYoloActive,
workspaceCwdForNewSession
setYoloActive
} from '@/store/session'
import { reportBackendContract } from '@/store/updates'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo, UsageStats } from '@/types/hermes'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes'
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes'
import type { ClientSessionState, SidebarNavItem } from '../../types'
@@ -209,67 +210,14 @@ function patchSessionWorkspace(sessionId: string, cwd: string | undefined) {
setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session)))
}
function sessionMatchesStoredId(session: SessionInfo, storedSessionId: string): boolean {
return session.id === storedSessionId || session._lineage_root_id === storedSessionId
}
function upsertResolvedSession(session: SessionInfo, storedSessionId: string) {
const lineage = session._lineage_root_id ?? session.id
setSessions(prev => [
session,
...prev.filter(existing => {
if (sessionMatchesStoredId(existing, storedSessionId)) {
return false
}
return (existing._lineage_root_id ?? existing.id) !== lineage
})
])
}
async function resolveStoredSession(storedSessionId: string): Promise<SessionInfo | undefined> {
const cached = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
if (cached) {
return cached
}
try {
const result = await listAllProfileSessions(500, 0, 'include', 'recent', 'all')
const resolved = result.sessions.find(session => sessionMatchesStoredId(session, storedSessionId))
if (resolved) {
upsertResolvedSession(resolved, storedSessionId)
}
return resolved
} catch {
return undefined
}
}
type SessionRuntimeStatePatch = Partial<
Pick<
ClientSessionState,
| 'branch'
| 'cwd'
| 'fast'
| 'model'
| 'personality'
| 'provider'
| 'reasoningEffort'
| 'serviceTier'
| 'yolo'
>
>
function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeStatePatch | null {
function applyRuntimeInfo(
info: SessionCreateResponse['info'] | undefined
): Partial<Pick<ClientSessionState, 'branch' | 'cwd'>> | null {
if (!info) {
return null
}
const sessionState: SessionRuntimeStatePatch = {}
const sessionState: Partial<Pick<ClientSessionState, 'branch' | 'cwd'>> = {}
reportBackendContract(info.desktop_contract)
@@ -277,14 +225,12 @@ function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeS
requestDesktopOnboarding(info.credential_warning)
}
if (typeof info.model === 'string') {
if (info.model) {
setCurrentModel(info.model)
sessionState.model = info.model
}
if (typeof info.provider === 'string') {
if (info.provider) {
setCurrentProvider(info.provider)
sessionState.provider = info.provider
}
if (info.cwd) {
@@ -298,29 +244,23 @@ function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeS
}
if (typeof info.personality === 'string') {
const personality = normalizePersonalityValue(info.personality)
setCurrentPersonality(personality)
sessionState.personality = personality
setCurrentPersonality(normalizePersonalityValue(info.personality))
}
if (typeof info.reasoning_effort === 'string') {
setCurrentReasoningEffort(info.reasoning_effort)
sessionState.reasoningEffort = info.reasoning_effort
}
if (typeof info.service_tier === 'string') {
setCurrentServiceTier(info.service_tier)
sessionState.serviceTier = info.service_tier
}
if (typeof info.fast === 'boolean') {
setCurrentFastMode(info.fast)
sessionState.fast = info.fast
}
if (typeof info.yolo === 'boolean') {
setYoloActive(info.yolo)
sessionState.yolo = info.yolo
}
if (info.usage) {
@@ -330,16 +270,6 @@ function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeS
return sessionState
}
function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | string } | undefined) {
setCurrentModel(stored?.model || '')
setCurrentProvider('')
setCurrentReasoningEffort('')
setCurrentServiceTier('')
setCurrentFastMode(false)
setYoloActive(false)
setCurrentPersonality('')
}
export function useSessionActions({
activeSessionId,
activeSessionIdRef,
@@ -381,17 +311,11 @@ export function useSessionActions({
})
setSessionStartedAt(null)
setTurnStartedAt(null)
// New chats start in the configured default project dir when set,
// otherwise the sticky last-used workspace (PR #37586).
setCurrentModel('')
setCurrentProvider('')
setCurrentReasoningEffort('')
setCurrentServiceTier('')
setCurrentFastMode(false)
setYoloActive(false)
setCurrentCwd(workspaceCwdForNewSession())
// New chats inherit the current workspace.
setCurrentCwd(getRememberedWorkspaceCwd())
setCurrentBranch('')
// Never clear the composer here — ChatBar's per-thread draft swap owns it.
clearComposerDraft()
clearComposerAttachments()
setFreshDraftReady(true)
},
[activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef]
@@ -409,17 +333,15 @@ export function useSessionActions({
// Route the new chat to the chosen profile's backend (null = primary,
// so single-profile users are unaffected).
await ensureGatewayProfile($newChatProfile.get())
const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession()
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
// Pass the owning profile so a new chat under a non-launch profile (global
// remote mode) builds its agent + persists against THAT profile's home/db.
const newChatProfile = $newChatProfile.get()
const created = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
...(cwd && { cwd }),
...(newChatProfile ? { profile: newChatProfile } : {})
})
const stored = created.stored_session_id ?? null
if (
@@ -520,42 +442,26 @@ export function useSessionActions({
// Swap the single live gateway to this session's profile before any
// gateway call (no-op when it's already on that profile / single-profile).
const storedForProfile = await resolveStoredSession(storedSessionId)
const storedForProfile = $sessions.get().find(session => session.id === storedSessionId)
const sessionProfile = storedForProfile?.profile
if (resumeRequestRef.current !== requestId) {
return
}
await ensureGatewayProfile(sessionProfile)
const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
const cachedState = cachedRuntimeId && sessionStateByRuntimeIdRef.current.get(cachedRuntimeId)
if (cachedRuntimeId && cachedState) {
const stored = $sessions.get().find(session => session.id === storedSessionId)
const cachedViewState =
!cachedState.model && stored?.model != null
? {
...cachedState,
model: stored.model || ''
}
: cachedState
if (cachedViewState !== cachedState) {
sessionStateByRuntimeIdRef.current.set(cachedRuntimeId, cachedViewState)
}
setFreshDraftReady(false)
clearNotifications()
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
setActiveSessionId(cachedRuntimeId)
activeSessionIdRef.current = cachedRuntimeId
syncSessionStateToView(cachedRuntimeId, cachedViewState)
setCurrentCwd(cachedViewState.cwd)
setCurrentBranch(cachedViewState.branch)
syncSessionStateToView(cachedRuntimeId, cachedState)
setCurrentCwd(cachedState.cwd)
setCurrentBranch(cachedState.branch)
setSessionStartedAt(Date.now())
clearComposerDraft()
clearComposerAttachments()
try {
const usage = await requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
@@ -594,8 +500,7 @@ export function useSessionActions({
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
setSessionStartedAt(Date.now())
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
applyStoredSessionPreviewRuntimeInfo(stored)
const stored = $sessions.get().find(session => session.id === storedSessionId)
if (stored) {
setCurrentUsage(current => ({
@@ -686,6 +591,8 @@ export function useSessionActions({
}),
storedSessionId
)
clearComposerDraft()
clearComposerAttachments()
} catch (err) {
if (!isCurrentResume()) {
return
@@ -808,6 +715,8 @@ export function useSessionActions({
selectedStoredSessionIdRef.current = routedSessionId
navigate(sessionRoute(routedSessionId))
clearComposerDraft()
clearComposerAttachments()
const runtimeInfo = applyRuntimeInfo(branched.info)
patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd)
@@ -844,7 +753,7 @@ export function useSessionActions({
async (storedSessionId: string) => {
clearNotifications()
const removed = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
const removed = $sessions.get().find(s => s.id === storedSessionId)
const wasSelected = selectedStoredSessionId === storedSessionId
const closingRuntimeId = wasSelected ? activeSessionId : null
const previousMessages = $messages.get()
@@ -853,7 +762,7 @@ export function useSessionActions({
// live tip after compression. Drop both so the pin can't linger.
const removedPinId = removed ? sessionPinId(removed) : storedSessionId
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
// doesn't keep claiming the removed row is still on the server.
setSessionsTotal(prev => Math.max(0, prev - 1))
@@ -888,7 +797,7 @@ export function useSessionActions({
setFreshDraftReady(false)
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
const stored = $sessions.get().find(session => session.id === storedSessionId)
if (stored) {
setCurrentUsage(current => ({
@@ -927,7 +836,7 @@ export function useSessionActions({
async (storedSessionId: string) => {
clearNotifications()
const archived = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
const archived = $sessions.get().find(s => s.id === storedSessionId)
const wasSelected = selectedStoredSessionId === storedSessionId
const previousPinned = $pinnedSessionIds.get()
// Pins are keyed on the durable lineage-root id; the stored id may be the
@@ -935,7 +844,7 @@ export function useSessionActions({
const archivedPinId = archived ? sessionPinId(archived) : storedSessionId
// Soft-hide: drop from the sidebar immediately, keep the data.
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
// Archived sessions are hidden by the listSessions(min_messages=1) query
// on the next refresh, so they count as "removed" for the load-more
// footer math.
@@ -948,16 +857,10 @@ export function useSessionActions({
try {
await setSessionArchived(storedSessionId, true, archived?.profile)
// A sidebar refresh can race the optimistic removal while the PATCH is
// in flight and briefly reinsert the still-unarchived backend row. Win
// that race after the mutation succeeds so right-click → Archive does
// not appear to do nothing until the next full refresh.
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
$pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId))
notify({ durationMs: 2_000, kind: 'success', message: copy.archived })
} catch (err) {
if (archived) {
setSessions(prev => [archived, ...prev.filter(session => !sessionMatchesStoredId(session, storedSessionId))])
setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
setSessionsTotal(prev => prev + 1)
}
@@ -2,20 +2,7 @@ import { act, cleanup, render } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
$currentServiceTier,
$turnStartedAt,
setCurrentFastMode,
setCurrentModel,
setCurrentProvider,
setCurrentReasoningEffort,
setCurrentServiceTier,
setTurnStartedAt
} from '@/store/session'
import { $turnStartedAt, setTurnStartedAt } from '@/store/session'
import { useSessionStateCache } from './use-session-state-cache'
@@ -59,22 +46,12 @@ describe('useSessionStateCache — per-session turn timer', () => {
return null as unknown as number
})
setTurnStartedAt(null)
setCurrentModel('')
setCurrentProvider('')
setCurrentReasoningEffort('')
setCurrentServiceTier('')
setCurrentFastMode(false)
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
setTurnStartedAt(null)
setCurrentModel('')
setCurrentProvider('')
setCurrentReasoningEffort('')
setCurrentServiceTier('')
setCurrentFastMode(false)
})
it("keeps a background session's running turn clock and never mirrors it to the view", () => {
@@ -138,78 +115,4 @@ describe('useSessionStateCache — per-session turn timer', () => {
})
expect($turnStartedAt.get()).toBeNull()
})
it('mirrors the focused session model metadata when switching from a cached session', () => {
let cache!: Cache
const { rerender } = render(
<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />
)
act(() => {
cache.updateSessionState(
'bg-runtime',
state => ({
...state,
fast: true,
model: 'anthropic/claude-opus-4.8',
provider: 'anthropic',
reasoningEffort: 'high',
serviceTier: 'priority'
}),
'bg-stored'
)
})
// Background metadata is cached but must not bleed into the visible statusbar.
expect($currentModel.get()).toBe('')
expect($currentReasoningEffort.get()).toBe('')
expect($currentFastMode.get()).toBe(false)
rerender(<Harness activeSessionId="bg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="bg-stored" />)
const bgState = cache.sessionStateByRuntimeIdRef.current.get('bg-runtime')
expect(bgState).toBeTruthy()
act(() => {
cache.syncSessionStateToView('bg-runtime', bgState!)
})
expect($currentModel.get()).toBe('anthropic/claude-opus-4.8')
expect($currentProvider.get()).toBe('anthropic')
expect($currentReasoningEffort.get()).toBe('high')
expect($currentServiceTier.get()).toBe('priority')
expect($currentFastMode.get()).toBe(true)
})
it('clears stale model metadata when the newly focused session has no cached value', () => {
setCurrentModel('previous-model')
setCurrentProvider('previous-provider')
setCurrentReasoningEffort('high')
setCurrentServiceTier('priority')
setCurrentFastMode(true)
let cache!: Cache
const { rerender } = render(
<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />
)
act(() => {
cache.updateSessionState('bg-runtime', state => ({ ...state }), 'bg-stored')
})
rerender(<Harness activeSessionId="bg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="bg-stored" />)
const bgState = cache.sessionStateByRuntimeIdRef.current.get('bg-runtime')
expect(bgState).toBeTruthy()
act(() => {
cache.syncSessionStateToView('bg-runtime', bgState!)
})
expect($currentModel.get()).toBe('')
expect($currentProvider.get()).toBe('')
expect($currentReasoningEffort.get()).toBe('')
expect($currentServiceTier.get()).toBe('')
expect($currentFastMode.get()).toBe(false)
})
})
@@ -5,21 +5,7 @@ import type { ChatMessage } from '@/lib/chat-messages'
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { setMutableRef } from '@/lib/mutable-ref'
import {
$busy,
$messages,
noteSessionActivity,
setCurrentFastMode,
setCurrentModel,
setCurrentPersonality,
setCurrentProvider,
setCurrentReasoningEffort,
setCurrentServiceTier,
setSessionAttention,
setSessionWorking,
setTurnStartedAt,
setYoloActive
} from '@/store/session'
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking, setTurnStartedAt } from '@/store/session'
import type { ClientSessionState } from '../../types'
@@ -54,16 +40,6 @@ interface SessionStateCacheOptions {
setMessages: (messages: ChatMessage[]) => void
}
function syncRuntimeMetadataToView(state: ClientSessionState) {
setCurrentModel(state.model ?? '')
setCurrentProvider(state.provider ?? '')
setCurrentReasoningEffort(state.reasoningEffort ?? '')
setCurrentServiceTier(state.serviceTier ?? '')
setCurrentFastMode(state.fast ?? false)
setYoloActive(state.yolo ?? false)
setCurrentPersonality(state.personality ?? '')
}
export function useSessionStateCache({
activeSessionId,
busyRef,
@@ -148,7 +124,6 @@ export function useSessionStateCache({
setMessages(nextMessages)
}
syncRuntimeMetadataToView(pending.state)
setBusy(pending.state.busy)
setMutableRef(busyRef, pending.state.busy)
setAwaitingResponse(pending.state.awaitingResponse)
@@ -173,32 +148,8 @@ export function useSessionStateCache({
return
}
syncRuntimeMetadataToView(state)
pendingViewStateRef.current = { sessionId, state }
// Terminal / attention transitions (turn finished, error, or the agent is
// now waiting on the user) MUST reach the view immediately. Electron
// throttles `requestAnimationFrame` to ~0 while the window is
// backgrounded, occluded, or unfocused, so an RAF-deferred flush can be
// stranded in `pendingViewStateRef` indefinitely — that's the "new chat
// stuck on Thinking until I refocus / F5" bug. Flush these synchronously
// (cancelling any in-flight RAF, since we're about to publish the latest
// state anyway). The plain busy heartbeat stays RAF-batched: that
// coalescing exists only to keep periodic `session.info` updates from
// churning `$messages` and jerking the scroll position while reading.
const isCriticalTransition = !state.busy || state.needsInput
if (isCriticalTransition) {
if (viewSyncRafRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(viewSyncRafRef.current)
viewSyncRafRef.current = null
}
flushPendingViewState()
return
}
if (viewSyncRafRef.current !== null) {
return
}
@@ -1,23 +1,20 @@
import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { LanguageSwitcher } from '@/components/language-switcher'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
import { Check, Palette } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { useTheme } from '@/themes/context'
import { installVscodeThemeFromMarketplace } from '@/themes/install'
import { isUserTheme, removeUserTheme, resolveTheme } from '@/themes/user-themes'
import { BUILTIN_THEMES } from '@/themes/presets'
import { MODE_OPTIONS } from './constants'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
function ThemePreview({ name }: { name: string }) {
const t = resolveTheme(name)
const t = BUILTIN_THEMES[name]
if (!t) {
return null
@@ -56,96 +53,12 @@ function ThemePreview({ name }: { name: string }) {
)
}
function VscodeThemeInstaller() {
const { t } = useI18n()
const { setTheme } = useTheme()
const a = t.settings.appearance
const [id, setId] = useState('')
const [busy, setBusy] = useState(false)
const [status, setStatus] = useState<{ kind: 'error' | 'success'; text: string } | null>(null)
const install = async () => {
const trimmed = id.trim()
if (!trimmed || busy) {
return
}
setBusy(true)
setStatus(null)
try {
const theme = await installVscodeThemeFromMarketplace(trimmed)
triggerHaptic('crisp')
setTheme(theme.name)
setStatus({ kind: 'success', text: a.installed(theme.label) })
setId('')
} catch (error) {
setStatus({ kind: 'error', text: error instanceof Error ? error.message : a.installError })
} finally {
setBusy(false)
}
}
return (
<div className="mt-3">
<div className="flex flex-wrap items-center gap-2">
<input
className="min-w-0 flex-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 font-mono text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
disabled={busy}
onChange={event => {
setId(event.target.value)
setStatus(null)
}}
onKeyDown={event => {
if (event.key === 'Enter') {
void install()
}
}}
placeholder={a.installPlaceholder}
spellCheck={false}
value={id}
/>
<button
className="inline-flex items-center gap-1.5 rounded-lg border border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] font-medium transition hover:bg-(--chrome-action-hover) disabled:opacity-50"
disabled={busy || !id.trim()}
onClick={() => void install()}
type="button"
>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
{busy ? a.installing : a.installButton}
</button>
</div>
{status && (
<p
className={cn(
'mt-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height)',
status.kind === 'error' ? 'text-(--ui-red)' : 'text-(--ui-text-tertiary)'
)}
>
{status.text}
</p>
)}
</div>
)
}
export function AppearanceSettings() {
const { t, isSavingLocale } = useI18n()
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
const toolViewMode = useStore($toolViewMode)
const profiles = useStore($profiles)
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
const a = t.settings.appearance
// Themes save per profile. Surface that only when the user actually has more
// than one profile (single-profile installs never see the distinction).
const showProfileNote = profiles.length > 1
const activeProfileName =
profiles.find(profile => normalizeProfileKey(profile.name) === activeProfileKey)?.name ?? activeProfileKey
const modeOptions = MODE_OPTIONS.map(({ id, icon }) => ({ icon, id, label: t.settings.modeOptions[id].label }))
const toolOptions = [
@@ -185,72 +98,43 @@ export function AppearanceSettings() {
<ListRow
below={
<>
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{availableThemes.map(theme => {
const active = themeName === theme.name
const removable = isUserTheme(theme.name)
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{availableThemes.map(theme => {
const active = themeName === theme.name
return (
<div className="group relative" key={theme.name}>
<button
className={cn(
'w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
onClick={() => {
triggerHaptic('crisp')
setTheme(theme.name)
}}
type="button"
>
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
</div>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{theme.description}
</div>
</div>
{active && (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
return (
<button
className={cn(
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={theme.name}
onClick={() => {
triggerHaptic('crisp')
setTheme(theme.name)
}}
type="button"
>
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
</div>
</button>
{removable && (
<button
aria-label={a.removeTheme}
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
onClick={() => {
triggerHaptic('crisp')
removeUserTheme(theme.name)
// Re-normalize off the now-missing skin → default.
if (active) {
setTheme(theme.name)
}
}}
title={a.removeTheme}
type="button"
>
<Trash2 className="size-3.5" />
</button>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{theme.description}
</div>
</div>
{active && (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
)
})}
</div>
<VscodeThemeInstaller />
{showProfileNote && (
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{a.themeProfileNote(activeProfileName)}
</p>
)}
</>
</button>
)
})}
</div>
}
description={a.themeDesc}
title={a.themeTitle}
@@ -15,7 +15,7 @@ import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment }
import { useI18n } from '@/i18n'
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
import { startManualProviderOAuth } from '@/store/onboarding'
import { CONTROL_TEXT } from './constants'
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
@@ -224,23 +224,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
}, [apiKeyDraft, selectedProviderRow])
// OAuth / external providers can't be activated with a pasted key — hand off
// to the shared onboarding flow scoped to this provider's real sign-in. The
// custom / local endpoint is NOT an OAuth provider, so it gets the dedicated
// local-endpoint form (URL + optional API key) instead of being dead-ended
// on the OAuth picker (the original "booted back to the first screen" loop).
// to the shared onboarding flow scoped to this provider's real sign-in.
const startProviderSetup = useCallback(() => {
const slug = selectedProviderRow?.slug
if (!slug) {
return
}
const lower = slug.toLowerCase()
if (lower === 'custom' || lower === 'local' || lower.startsWith('custom:')) {
startManualLocalEndpoint()
} else {
startManualProviderOAuth(slug)
if (selectedProviderRow?.slug) {
startManualProviderOAuth(selectedProviderRow.slug)
}
}, [selectedProviderRow])
@@ -2,13 +2,13 @@ import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Tip } from '@/components/ui/tooltip'
import { deleteSession, listAllProfileSessions, setSessionArchived } from '@/hermes'
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import { applyConfiguredDefaultProjectDir, ensureDefaultWorkspaceCwd, setSessions } from '@/store/session'
import { setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives'
@@ -43,14 +43,14 @@ export function SessionsSettings() {
setLoading(true)
try {
const result = await listAllProfileSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
const result = await listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
setLocalSessions(result.sessions)
} catch (err) {
notifyError(err, s.failedLoad)
} finally {
setLoading(false)
}
}, [s.failedLoad])
}, [])
useEffect(() => {
void load()
@@ -196,7 +196,6 @@ function DefaultProjectDirSetting() {
setDir(result.dir)
setFallback(result.defaultLabel)
applyConfiguredDefaultProjectDir(result.dir)
})
return () => {
@@ -222,8 +221,7 @@ function DefaultProjectDirSetting() {
const result = await settings.setDefaultProjectDir(picked.dir)
setDir(result.dir)
applyConfiguredDefaultProjectDir(result.dir)
notify({ durationMs: 4_000, kind: 'success', message: s.defaultDirUpdated })
notify({ durationMs: 2_000, kind: 'success', message: s.defaultDirUpdated })
} catch (err) {
notifyError(err, s.updateDirFailed)
} finally {
@@ -243,8 +241,6 @@ function DefaultProjectDirSetting() {
try {
await settings.setDefaultProjectDir(null)
setDir(null)
applyConfiguredDefaultProjectDir(null)
await ensureDefaultWorkspaceCwd()
} catch (err) {
notifyError(err, s.clearDirFailed)
} finally {
@@ -272,7 +268,7 @@ function DefaultProjectDirSetting() {
)}
</div>
}
description={dir || s.defaultsTo(fallback || '~')}
description={dir || s.defaultsTo(fallback || '~/hermes-projects')}
title={dir ? dir : s.notSet}
/>
</div>
+4 -30
View File
@@ -16,7 +16,6 @@ import {
} from '@/store/layout'
import { $paneWidthOverride } from '@/store/panes'
import { $connection } from '@/store/session'
import { isSecondaryWindow } from '@/store/windows'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
@@ -29,19 +28,9 @@ interface AppShellProps {
children: ReactNode
leftStatusbarItems?: readonly StatusbarItem[]
leftTitlebarTools?: readonly TitlebarTool[]
// Fixed-position overlays that must share <main>'s stacking context so pane
// resize handles (z-20) paint above them. The persistent terminal lives here:
// hoisting it to the root `overlays` layer (sibling of <main>, z above z-3)
// would cover every pane's drag handle.
mainOverlays?: ReactNode
onOpenSettings: () => void
overlays?: ReactNode
// Rails that sit at the window's left edge in the flipped layout but never
// force-collapse to hover-reveal overlays — so they cover the top-left traffic
// lights (and zero the titlebar inset) even below the collapse breakpoint.
previewPaneOpen?: boolean
statusbarItems?: readonly StatusbarItem[]
terminalPaneOpen?: boolean
titlebarTools?: readonly TitlebarTool[]
}
@@ -64,12 +53,9 @@ export function AppShell({
children,
leftStatusbarItems,
leftTitlebarTools,
mainOverlays,
onOpenSettings,
overlays,
previewPaneOpen = false,
statusbarItems,
terminalPaneOpen = false,
titlebarTools
}: AppShellProps) {
const sidebarOpen = useStore($sidebarOpen)
@@ -89,17 +75,10 @@ export function AppShell({
// The inset clears the top-left titlebar buttons when nothing covers the
// window's left edge. Default layout: the sessions sidebar sits there.
// Flipped layout: the file browser does instead. Both force-collapse to a
// hover-reveal overlay (0px track) below the collapse breakpoint, so the edge
// is uncovered there regardless of their stored open state. A standalone
// session window renders no sidebar at all, so its edge is always uncovered.
const collapsibleLeftPaneOpen = panesFlipped ? fileBrowserOpen : sidebarOpen
// The terminal + preview rails never force-collapse, so when they're the
// leftmost open pane (flipped layout) they cover the edge even when narrow.
const persistentLeftPaneOpen = panesFlipped && (terminalPaneOpen || previewPaneOpen)
const leftEdgePaneOpen =
!isSecondaryWindow() && ((!narrowViewport && collapsibleLeftPaneOpen) || persistentLeftPaneOpen)
// Flipped layout: the file browser does instead. Below the collapse
// breakpoint both rails are force-collapsed (hover-reveal overlay), so the
// edge is uncovered regardless of their stored open state.
const leftEdgePaneOpen = !narrowViewport && (panesFlipped ? fileBrowserOpen : sidebarOpen)
const titlebarContentInset = leftEdgePaneOpen
? 0
@@ -178,11 +157,6 @@ export function AppShell({
{children}
</PaneShell>
{/* Fixed overlays scoped to main's stacking context (terminal). Rendered
after PaneShell so it paints over pane content, but its z stays under
the panes' z-20 resize handles, keeping every pane resizable. */}
{mainOverlays}
<StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
</main>
@@ -3,7 +3,6 @@ import type { ReactNode } from 'react'
import { useCallback, useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { useI18n } from '@/i18n'
import {
@@ -15,7 +14,6 @@ import {
Hash,
Loader2,
Sparkles,
Terminal,
Zap,
ZapFilled
} from '@/lib/icons'
@@ -29,7 +27,6 @@ import { $previewServerRestartStatus } from '@/store/preview'
import {
$activeSessionId,
$busy,
$connection,
$currentFastMode,
$currentModel,
$currentProvider,
@@ -43,14 +40,7 @@ import {
setYoloActive
} from '@/store/session'
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
import {
$backendUpdateApply,
$backendUpdateStatus,
$desktopVersion,
$updateApply,
$updateStatus,
openUpdateOverlayFor
} from '@/store/updates'
import { $desktopVersion, $updateApply, $updateStatus, setUpdateOverlayOpen } from '@/store/updates'
import type { StatusResponse } from '@/types/hermes'
import { CRON_ROUTE } from '../../routes'
@@ -58,7 +48,6 @@ import type { StatusbarItem, StatusbarSelectModifiers } from '../statusbar-contr
interface StatusbarItemsOptions {
agentsOpen: boolean
chatOpen: boolean
commandCenterOpen: boolean
extraLeftItems: readonly StatusbarItem[]
extraRightItems: readonly StatusbarItem[]
@@ -76,7 +65,6 @@ interface StatusbarItemsOptions {
export function useStatusbarItems({
agentsOpen,
chatOpen,
commandCenterOpen,
extraLeftItems,
extraRightItems,
@@ -94,7 +82,6 @@ export function useStatusbarItems({
const { t } = useI18n()
const copy = t.shell.statusbar
const activeSessionId = useStore($activeSessionId)
const terminalTakeover = useStore($terminalTakeover)
const yoloActive = useStore($yoloActive)
const busy = useStore($busy)
const currentFastMode = useStore($currentFastMode)
@@ -110,10 +97,7 @@ export function useStatusbarItems({
const subagentsBySession = useStore($subagentsBySession)
const updateStatus = useStore($updateStatus)
const updateApply = useStore($updateApply)
const backendUpdateStatus = useStore($backendUpdateStatus)
const backendUpdateApply = useStore($backendUpdateApply)
const desktopVersion = useStore($desktopVersion)
const connection = useStore($connection)
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
@@ -210,19 +194,18 @@ export function useStatusbarItems({
? 'text-amber-600 hover:text-amber-600'
: 'text-destructive hover:text-destructive'
const clientVersionItem = useMemo<StatusbarItem>(() => {
const versionItem = useMemo<StatusbarItem>(() => {
const appVersion = desktopVersion?.appVersion
const sha = updateStatus?.currentSha?.slice(0, 7) ?? null
const behind = updateStatus?.behind ?? 0
const applying = updateApply.applying || updateApply.stage === 'restart'
const remote = connection?.mode === 'remote'
const version = appVersion ? `v${appVersion}` : (sha ?? copy.unknown)
const base = remote ? copy.clientLabel(appVersion ?? sha ?? copy.unknown) : version
const base = appVersion ? `v${appVersion}` : (sha ?? copy.unknown)
const behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
const label = applying
? `${base} · ${updateApply.stage === 'restart' ? copy.restart : copy.update}`
? updateApply.stage === 'restart'
? `${base} · ${copy.restart}`
: `${base} · ${copy.update}`
: `${base}${behindHint}`
const tooltip = [
@@ -237,18 +220,17 @@ export function useStatusbarItems({
return {
className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined,
detail: appVersion && sha && !applying && !remote ? sha : undefined,
detail: appVersion && sha && !applying ? sha : undefined,
hidden: !appVersion && !sha,
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
id: 'version-client',
id: 'version',
label,
onSelect: () => openUpdateOverlayFor('client'),
onSelect: () => setUpdateOverlayOpen(true),
title: tooltip || undefined,
variant: 'action'
}
}, [
desktopVersion?.appVersion,
connection?.mode,
copy,
updateApply.applying,
updateApply.message,
@@ -258,50 +240,6 @@ export function useStatusbarItems({
updateStatus?.currentSha
])
const backendVersionItem = useMemo<StatusbarItem | null>(() => {
if (connection?.mode !== 'remote') {
return null
}
const backendVersion = statusSnapshot?.version
const behind = backendUpdateStatus?.behind ?? 0
const applying = backendUpdateApply.applying || backendUpdateApply.stage === 'restart'
const base = copy.backendLabel(backendVersion ?? copy.unknown)
const behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
const label = applying
? `${base} · ${backendUpdateApply.stage === 'restart' ? copy.restart : copy.update}`
: `${base}${behindHint}`
const tooltip = [
applying ? backendUpdateApply.message || copy.updateInProgress : null,
!applying && behind > 0 && copy.commitsBehind(behind, 'main'),
backendVersion && copy.backendVersion(backendVersion)
]
.filter(Boolean)
.join(' · ')
return {
className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined,
hidden: !backendVersion,
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
id: 'version-backend',
label,
onSelect: () => openUpdateOverlayFor('backend'),
title: tooltip || undefined,
variant: 'action'
}
}, [
connection?.mode,
statusSnapshot?.version,
backendUpdateStatus?.behind,
backendUpdateApply.applying,
backendUpdateApply.message,
backendUpdateApply.stage,
copy
])
const coreLeftStatusbarItems = useMemo<readonly StatusbarItem[]>(
() => [
{
@@ -447,21 +385,10 @@ export function useStatusbarItems({
variant: 'action' as const
})
},
{
className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`,
hidden: !chatOpen,
icon: <Terminal className="size-3.5" />,
id: 'terminal',
onSelect: () => setTerminalTakeover(!$terminalTakeover.get()),
title: terminalTakeover ? copy.hideTerminal : copy.showTerminal,
variant: 'action'
},
clientVersionItem,
...(backendVersionItem ? [backendVersionItem] : [])
versionItem
],
[
busy,
chatOpen,
contextBar,
contextUsage,
copy,
@@ -472,11 +399,9 @@ export function useStatusbarItems({
modelMenuContent,
sessionStartedAt,
showYoloToggle,
terminalTakeover,
toggleYolo,
turnStartedAt,
clientVersionItem,
backendVersionItem,
versionItem,
yoloActive
]
)
@@ -24,7 +24,6 @@ import {
$visibleModels,
collapseModelFamilies,
DEFAULT_VISIBLE_PER_PROVIDER,
effectiveVisibleKeys,
type ModelFamily,
modelVisibilityKey,
setModelVisibilityOpen
@@ -87,17 +86,13 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
: null
const providers = modelOptions.data?.providers
const effectiveVisibleModels = useMemo(
() => effectiveVisibleKeys(visibleModels, providers ?? []),
[visibleModels, providers]
)
const switchTo = (model: string, provider: string) =>
onSelectModel({ model, persistGlobal: !activeSessionId, provider })
const groups = useMemo(
() => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels),
[providers, search, optionsModel, optionsProvider, effectiveVisibleModels]
() => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, visibleModels),
[providers, search, optionsModel, optionsProvider, visibleModels]
)
return (
@@ -162,9 +157,8 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
currentFastMode
)
// Grayed text is live session state only. Do not label inactive
// rows as "Fast" just because they have a fast-capable sibling:
// that makes an off Fast toggle look like it is already on.
// Grayed text: active row shows live state (Fast + effort);
// others show a fast-capability hint.
const meta = isCurrent
? [
fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
@@ -172,7 +166,9 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
]
.filter(Boolean)
.join(' ')
: ''
: caps?.fast || family.fastId
? copy.fast
: ''
// Every row is a hover-Edit submenu trigger. Activating it
// (pointer or keyboard) switches to the family's base model;
-41
View File
@@ -27,20 +27,6 @@ export interface ImageDetachResponse {
count?: number
}
export interface FileAttachResponse {
attached?: boolean
message?: string
// Gateway-side absolute path the file was staged to.
path?: string
// Workspace-relative path used to build ref_text.
ref_path?: string
// Rewritten @file: ref that resolves on the gateway (workspace-relative).
ref_text?: string
// True when bytes/host file were copied into the session workspace.
uploaded?: boolean
name?: string
}
export interface SlashExecResponse {
output?: string
warning?: string
@@ -61,26 +47,6 @@ export interface SessionTitleResponse {
session_key?: string
}
export interface HandoffRequestResponse {
queued?: boolean
session_key?: string
platform?: string
// Human-readable home channel name for the destination platform.
home_name?: string
}
export interface HandoffStateResponse {
// '' | 'pending' | 'running' | 'completed' | 'failed'
state?: string
platform?: string
error?: string
}
export interface HandoffFailResponse {
failed?: boolean
state?: string
}
export interface ExecCommandDispatchResponse {
type: 'exec' | 'plugin'
output?: string
@@ -123,13 +89,6 @@ export interface ClientSessionState {
messages: ChatMessage[]
branch: string
cwd: string
model: string
provider: string
reasoningEffort: string
serviceTier: string
fast: boolean
yolo: boolean
personality: string
busy: boolean
awaitingResponse: boolean
streamId: string | null
+14 -43
View File
@@ -12,19 +12,12 @@ import { useI18n } from '@/i18n'
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
import { AlertCircle, Check, CheckCircle2, Copy, Terminal } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { resolveUpdateCopy, type UpdateTarget } from '@/lib/update-copy'
import {
$backendUpdateApply,
$backendUpdateChecking,
$backendUpdateStatus,
$updateApply,
$updateChecking,
$updateOverlayOpen,
$updateOverlayTarget,
$updateStatus,
applyBackendUpdate,
applyUpdates,
checkBackendUpdates,
checkUpdates,
resetUpdateApplyState,
setUpdateOverlayOpen,
@@ -37,27 +30,15 @@ function totalItems(groups: readonly CommitGroup[]) {
export function UpdatesOverlay() {
const open = useStore($updateOverlayOpen)
const target = useStore($updateOverlayTarget)
const clientStatus = useStore($updateStatus)
const clientChecking = useStore($updateChecking)
const clientApply = useStore($updateApply)
const backendStatus = useStore($backendUpdateStatus)
const backendChecking = useStore($backendUpdateChecking)
const backendApply = useStore($backendUpdateApply)
const isBackend = target === 'backend'
const status = isBackend ? backendStatus : clientStatus
const checking = isBackend ? backendChecking : clientChecking
const apply = isBackend ? backendApply : clientApply
const check = isBackend ? checkBackendUpdates : checkUpdates
const install = isBackend ? applyBackendUpdate : applyUpdates
const status = useStore($updateStatus)
const checking = useStore($updateChecking)
const apply = useStore($updateApply)
useEffect(() => {
if (open && !status && !checking) {
void check()
void checkUpdates()
}
}, [check, checking, open, status])
}, [checking, open, status])
const behind = status?.behind ?? 0
@@ -83,7 +64,7 @@ export function UpdatesOverlay() {
}
const handleInstall = () => {
void install()
void applyUpdates()
}
return (
@@ -92,7 +73,7 @@ export function UpdatesOverlay() {
className="max-w-sm overflow-hidden border-border/70 p-0 gap-0"
showCloseButton={phase !== 'applying'}
>
{phase === 'applying' && <ApplyingView apply={apply} isBackend={isBackend} />}
{phase === 'applying' && <ApplyingView apply={apply} />}
{phase === 'manual' && (
<ManualView command={apply.command ?? 'hermes update'} onDone={() => handleClose(false)} />
@@ -109,9 +90,8 @@ export function UpdatesOverlay() {
commits={status?.commits ?? []}
onInstall={handleInstall}
onLater={() => handleClose(false)}
onRetryCheck={() => void check()}
onRetryCheck={() => void checkUpdates()}
status={status}
target={target}
/>
)}
</DialogContent>
@@ -126,8 +106,7 @@ function IdleView({
onInstall,
onLater,
onRetryCheck,
status,
target
status
}: {
behind: number
checking: boolean
@@ -136,7 +115,6 @@ function IdleView({
onLater: () => void
onRetryCheck: () => void
status: DesktopUpdateStatus | null
target: UpdateTarget
}) {
const { t } = useI18n()
const u = t.updates
@@ -189,7 +167,7 @@ function IdleView({
if (behind === 0) {
return (
<CenteredStatus
body={target === 'backend' ? u.latestBodyBackend : u.latestBody}
body={u.latestBody}
icon={<CheckCircle2 className="size-7 text-emerald-600 dark:text-emerald-400" />}
title={u.allSetTitle}
/>
@@ -200,20 +178,14 @@ function IdleView({
const shownItems = totalItems(groups)
const remaining = Math.max(0, behind - shownItems)
// Name what's being updated. In remote mode the overlay acts on the connected
// backend, not the local client — say so. When there are no commit rows to
// show (e.g. pip/non-git backend), degrade to honest "no release notes" copy
// instead of generic filler.
const { title, body } = resolveUpdateCopy({ target, shownItems, copy: u })
return (
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
<BrandMark className="size-16" />
<DialogTitle className="text-center text-xl">{title}</DialogTitle>
<DialogTitle className="text-center text-xl">{u.availableTitle}</DialogTitle>
<DialogDescription className="text-center text-sm">
{body}
{u.availableBody}
</DialogDescription>
</div>
@@ -309,11 +281,10 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
)
}
function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend: boolean }) {
function ApplyingView({ apply }: { apply: UpdateApplyState }) {
const { t } = useI18n()
const u = t.updates
const label = u.stages[apply.stage as DesktopUpdateStage] ?? u.stages.idle
const body = isBackend ? u.applyingBodyBackend : u.applyingBody
const percent =
typeof apply.percent === 'number' && Number.isFinite(apply.percent)
@@ -327,7 +298,7 @@ function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend
<DialogTitle className="text-center text-xl">{label}</DialogTitle>
<DialogDescription className="text-center text-sm">
{body}
{u.applyingBody}
</DialogDescription>
</div>
@@ -63,7 +63,7 @@ export function directiveIconSvg(type: string) {
return `<svg ${SVG_ATTRS} class="size-3 shrink-0 opacity-80">${inner}</svg>`
}
function iconElementFromPaths(paths: string[]) {
export function directiveIconElement(type: string) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('class', 'size-3 shrink-0 opacity-80')
svg.setAttribute('fill', 'none')
@@ -74,7 +74,7 @@ function iconElementFromPaths(paths: string[]) {
svg.setAttribute('viewBox', '0 0 24 24')
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
for (const d of paths) {
for (const d of iconPathsFor(type)) {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', d)
svg.append(path)
@@ -83,46 +83,6 @@ function iconElementFromPaths(paths: string[]) {
return svg
}
export function directiveIconElement(type: string) {
return iconElementFromPaths(iconPathsFor(type))
}
/** Per-type slash-command pill styling. The composer inserts these chips when a
* command is picked; the kind drives a theme-aware accent so commands, skills,
* and themes read distinctly (Cursor-style). */
export type SlashChipKind = 'command' | 'skill' | 'theme'
const SLASH_ICON_PATHS: Record<SlashChipKind, string[]> = {
command: ['M5 7l5 5l-5 5', 'M12 19l7 0'],
skill: ['M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11'],
theme: [
'M3 21v-4a4 4 0 1 1 4 4h-4',
'M21 3a16 16 0 0 0 -12.8 10.2',
'M21 3a16 16 0 0 1 -10.2 12.8',
'M10.6 9a9 9 0 0 1 4.4 4.4'
]
}
const SLASH_CHIP_VARIANT: Record<SlashChipKind, string> = {
command:
'bg-[color-mix(in_srgb,var(--ui-accent)_14%,transparent)] text-[color-mix(in_srgb,var(--ui-accent)_82%,var(--foreground))]',
skill:
'bg-[color-mix(in_srgb,var(--ui-warm)_18%,transparent)] text-[color-mix(in_srgb,var(--ui-warm)_82%,var(--foreground))]',
theme:
'bg-[color-mix(in_srgb,var(--ui-accent-secondary)_16%,transparent)] text-[color-mix(in_srgb,var(--ui-accent-secondary)_82%,var(--foreground))]'
}
export const SLASH_CHIP_BASE_CLASS =
'mx-0.5 inline-flex max-w-64 items-center gap-1 rounded px-1.5 py-0.5 align-middle text-[0.86em] font-medium leading-none'
export function slashChipClass(kind: SlashChipKind): string {
return `${SLASH_CHIP_BASE_CLASS} ${SLASH_CHIP_VARIANT[kind]}`
}
export function slashIconElement(kind: SlashChipKind) {
return iconElementFromPaths(SLASH_ICON_PATHS[kind])
}
const DirectiveIcon: FC<{ type: string }> = ({ type }) => (
<svg
className="size-3 shrink-0 opacity-80"
@@ -494,9 +494,11 @@ export function MarkdownTextContent({ isRunning, text, ...surfaceProps }: Markdo
const MarkdownTextImpl = () => {
return (
<DeferStreamingText>
<MarkdownTextSurface />
</DeferStreamingText>
<SmoothStreamingText>
<DeferStreamingText>
<MarkdownTextSurface />
</DeferStreamingText>
</SmoothStreamingText>
)
}
@@ -1,80 +0,0 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MessageRenderBoundary } from './message-render-boundary'
afterEach(cleanup)
function Boom({ error }: { error: Error | null }): null {
if (error) {
throw error
}
return null
}
const lookupError = new Error('tapClientLookup: Index 2 out of bounds (length: 2)')
describe('MessageRenderBoundary', () => {
it('renders children when nothing throws', () => {
render(
<MessageRenderBoundary resetKey="a">
<div>content</div>
</MessageRenderBoundary>
)
expect(screen.getByText('content')).toBeTruthy()
})
it('swallows the transient tapClientLookup out-of-bounds store race', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const { container } = render(
<MessageRenderBoundary resetKey="a">
<Boom error={lookupError} />
</MessageRenderBoundary>
)
expect(container.innerHTML).toBe('')
spy.mockRestore()
})
it('recovers on the next consistent snapshot when resetKey changes', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const { rerender } = render(
<MessageRenderBoundary resetKey="a">
<Boom error={lookupError} />
</MessageRenderBoundary>
)
rerender(
<MessageRenderBoundary resetKey="b">
<Boom error={null} />
</MessageRenderBoundary>
)
rerender(
<MessageRenderBoundary resetKey="b">
<div>recovered</div>
</MessageRenderBoundary>
)
expect(screen.getByText('recovered')).toBeTruthy()
spy.mockRestore()
})
it('re-throws unrelated errors so real bugs still surface', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
expect(() =>
render(
<MessageRenderBoundary resetKey="a">
<Boom error={new Error('genuine render bug')} />
</MessageRenderBoundary>
)
).toThrow('genuine render bug')
spy.mockRestore()
})
})
@@ -1,48 +0,0 @@
import { Component, type ReactNode } from 'react'
// `@assistant-ui/store`'s index-keyed child-scope lookup (`tapClientLookup`)
// throws — rather than returning undefined — when a subscriber reads an index
// that the message/parts list no longer has. This races during high-frequency
// store replacement (session switch mid-stream, gateway reconnect replay): a
// subscriber from the previous, longer list is still in React's notification
// queue and reads one slot past the new, shorter array before it can unmount.
// The throw is transient and self-heals on the next consistent snapshot, but
// without a local boundary it unwinds to the root and blanks the whole app.
// Upstream-tracked: assistant-ui/assistant-ui#4051, #3652.
const isTransientLookupError = (error: unknown): boolean =>
error instanceof Error && /tapClient(Lookup|Resource).*out of bounds/.test(error.message)
interface Props {
// Changes whenever the message list mutates; remounting clears the caught
// error so the next consistent render recovers silently.
resetKey: string
children: ReactNode
}
export class MessageRenderBoundary extends Component<Props, { error: Error | null }> {
state: { error: Error | null } = { error: null }
static getDerivedStateFromError(error: Error) {
return { error }
}
componentDidUpdate(prev: Props) {
if (this.state.error && prev.resetKey !== this.props.resetKey) {
this.setState({ error: null })
}
}
render() {
if (this.state.error) {
// Only swallow the transient store race; re-throw anything else so real
// bugs still reach the root error boundary.
if (!isTransientLookupError(this.state.error)) {
throw this.state.error
}
return null
}
return this.props.children
}
}
@@ -164,27 +164,6 @@ function assistantMultiReasoningMessage(texts: string[]): ThreadMessage {
} as ThreadMessage
}
function assistantSeparatedReasoningMessage(): ThreadMessage {
return {
id: 'assistant-reasoning-separated-1',
role: 'assistant',
content: [
{ type: 'reasoning', text: ' Complete first thought.', status: { type: 'complete' } },
{ type: 'text', text: 'Interim answer.' },
{ type: 'reasoning', text: ' Streaming second thought.', status: { type: 'running' } }
],
status: { type: 'running' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function assistantTodoMessage(
todos: Array<{ content: string; id: string; status: 'cancelled' | 'completed' | 'in_progress' | 'pending' }>,
running = true
@@ -706,18 +685,6 @@ describe('assistant-ui streaming renderer', () => {
expect(reasoningParts[1]?.textContent).toBe('Second thought.')
})
it('does not reopen an earlier completed thinking group when a later group is running', () => {
const { container } = render(<RunningMessageHarness message={assistantSeparatedReasoningMessage()} />)
const disclosures = container.querySelectorAll('[data-slot="aui_thinking-disclosure"]')
expect(disclosures.length).toBe(2)
expect(disclosures[0].querySelector('button')?.getAttribute('aria-expanded')).toBe('false')
expect(disclosures[1].querySelector('button')?.getAttribute('aria-expanded')).toBe('true')
expect(container.textContent).not.toContain('Complete first thought.')
expect(container.textContent).toContain('Interim answer.')
})
it('renders live todo rows during a running turn', () => {
const { container } = render(
<TodoHarness
@@ -16,8 +16,6 @@ import { setMutableRef } from '@/lib/mutable-ref'
import { cn } from '@/lib/utils'
import { setThreadScrolledUp } from '@/store/thread-scroll'
import { MessageRenderBoundary } from './message-render-boundary'
const ESTIMATED_ITEM_HEIGHT = 220
const OVERSCAN = 4
const AT_BOTTOM_THRESHOLD = 4
@@ -182,20 +180,18 @@ const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
key={virtualItem.key}
ref={virtualizer.measureElement}
>
<MessageRenderBoundary resetKey={messageSignature}>
{group.kind === 'turn' ? (
<div
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
data-slot="aui_turn-pair"
>
{group.indices.map(index => (
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
))}
</div>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
)}
</MessageRenderBoundary>
{group.kind === 'turn' ? (
<div
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
data-slot="aui_turn-pair"
>
{group.indices.map(index => (
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
))}
</div>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
)}
</div>
)
})}
@@ -37,12 +37,7 @@ import {
} from '@/app/chat/composer/focus'
import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions'
import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions'
import {
dragHasAttachments,
droppedFileInlineRefs,
type InlineRefInput,
insertInlineRefsIntoEditor
} from '@/app/chat/composer/inline-refs'
import { dragHasAttachments, droppedFileInlineRef, insertInlineRefsIntoEditor } from '@/app/chat/composer/inline-refs'
import {
composerPlainText,
placeCaretEnd,
@@ -52,8 +47,7 @@ import {
} from '@/app/chat/composer/rich-editor'
import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
import { extractDroppedFiles, HERMES_PATHS_MIME, isImagePath, partitionDroppedFiles } from '@/app/chat/hooks/use-composer-actions'
import { uploadComposerAttachment } from '@/app/session/hooks/use-prompt-actions'
import { extractDroppedFiles, HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
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'
@@ -82,18 +76,14 @@ import { Loader } from '@/components/ui/loader'
import type { HermesGateway } from '@/hermes'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runtime'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { LinkifiedText } from '@/lib/external-link'
import { triggerHaptic } from '@/lib/haptics'
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons'
import { extractPreviewTargets } from '@/lib/preview-targets'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
import { $connection } from '@/store/session'
import { $voicePlayback } from '@/store/voice-playback'
type ThreadLoadingState = 'response' | 'session'
@@ -477,9 +467,7 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star
s =>
s.thread.isRunning &&
s.message.status?.type === 'running' &&
s.message.parts
.slice(Math.max(0, startIndex), endIndex + 1)
.some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
s.message.parts.slice(Math.max(0, startIndex)).some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
)
// A reasoning group with no actual text is pure noise — drop the whole
@@ -722,14 +710,8 @@ function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
// edit composer render the same bubble surface (rounded glass card);
// they only differ in border weight, cursor, and padding-right (the
// read-only view reserves room for the restore icon).
//
// no-drag: sticky bubbles park at --sticky-human-top (~4px), sliding under the
// titlebar's [-webkit-app-region:drag] strips (app-shell.tsx). Electron resolves
// drag regions at the compositor level — z-index and pointer-events don't help —
// so without the carve-out, clicking a stuck bubble drags the window instead of
// opening the edit composer.
const USER_BUBBLE_BASE_CLASS =
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]'
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left'
const USER_ACTION_ICON_BUTTON_CLASS =
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
@@ -929,46 +911,26 @@ const SystemMessage: FC = () => {
const slashStatus = text.match(SLASH_STATUS_RE)
if (slashStatus?.groups) {
const output = slashStatus.groups.output.trim()
// Single-line status (e.g. "model → x") reads best centered inline; padded
// multiline output (catalogs, usage tables) needs left-aligned, wider room
// or the column alignment breaks.
const multiline = output.includes('\n')
return (
<MessagePrimitive.Root
className={cn(
'w-[60%] max-w-[44rem] self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60',
multiline ? 'text-left' : 'text-center'
)}
className="max-w-[min(86%,44rem)] self-center px-2 py-0.5 text-center text-[0.6875rem] leading-5 text-muted-foreground/60"
data-role="system"
data-slot="aui_system-message-root"
>
<span className="font-mono text-muted-foreground/55">{slashStatus.groups.command}</span>
{multiline ? (
<LinkifiedText className="mt-0.5 block whitespace-pre-wrap" explicitOnly pretty={false} text={output} />
) : (
<>
<span className="mx-1.5 text-muted-foreground/35">·</span>
<LinkifiedText className="whitespace-pre-wrap" explicitOnly pretty={false} text={output} />
</>
)}
<span className="mx-1.5 text-muted-foreground/35">·</span>
<span className="whitespace-pre-wrap">{slashStatus.groups.output.trim()}</span>
</MessagePrimitive.Root>
)
}
const multiline = text.includes('\n')
return (
<MessagePrimitive.Root
className={cn(
'w-[60%] max-w-[44rem] self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/55',
multiline ? 'text-left' : 'text-center'
)}
className="max-w-[min(86%,44rem)] self-center px-2 py-0.5 text-center text-[0.6875rem] leading-5 text-muted-foreground/55"
data-role="system"
data-slot="aui_system-message-root"
>
<LinkifiedText className="whitespace-pre-wrap" explicitOnly pretty={false} text={text} />
<span className="whitespace-pre-wrap">{text}</span>
</MessagePrimitive.Root>
)
}
@@ -999,10 +961,6 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top')
const [focusRequestId, setFocusRequestId] = useState(0)
const [submitting, setSubmitting] = useState(false)
// True while OS-drop files are being staged/uploaded into the session. Blocks
// submit and shows a spinner so confirming the edit can't race the async
// upload and drop the gateway-side ref before it lands in the draft.
const [staging, setStaging] = useState(false)
const expanded = draft.includes('\n')
const canSubmit = draft.trim().length > 0
const at = useAtCompletions({ cwd, gateway, sessionId })
@@ -1219,14 +1177,18 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
[aui, closeTrigger, refreshTrigger, requestEditFocus, trigger]
)
const insertRefStrings = useCallback(
(refs: InlineRefInput[]) => {
const insertDroppedRefs = useCallback(
(candidates: ReturnType<typeof extractDroppedFiles>) => {
const editor = editorRef.current
if (!editor || refs.length === 0) {
if (!editor) {
return false
}
const refs = candidates
.map(candidate => droppedFileInlineRef(candidate, cwd))
.filter((ref): ref is string => Boolean(ref))
const nextDraft = insertInlineRefsIntoEditor(editor, refs)
if (nextDraft === null) {
@@ -1239,60 +1201,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
return true
},
[aui, requestEditFocus]
)
const insertDroppedRefs = useCallback(
(candidates: ReturnType<typeof extractDroppedFiles>) => insertRefStrings(droppedFileInlineRefs(candidates, cwd)),
[cwd, insertRefStrings]
)
// OS/Finder drops carry an absolute path on THIS machine — the gateway can't
// read it in remote mode, and an image needs its bytes uploaded for vision.
// Stage each through the same file.attach/image.attach_bytes pipeline the main
// composer uses, then insert the *gateway-side* ref the agent can resolve —
// never the raw local path (the MahmoudR remote-attach bug, which the main
// composer fixes but this edit composer used to reproduce).
const uploadOsDropRefs = useCallback(
async (osDrops: ReturnType<typeof extractDroppedFiles>): Promise<InlineRefInput[]> => {
if (!gateway || !sessionId) {
// No session to stage into — best-effort inline refs (matches old path).
return droppedFileInlineRefs(osDrops, cwd)
}
const remote = $connection.get()?.mode === 'remote'
const requestGateway = <T,>(method: string, params?: Record<string, unknown>) => gateway.request<T>(method, params)
const refs: InlineRefInput[] = []
for (const candidate of osDrops) {
const path = candidate.path || ''
if (!path) {
continue
}
const kind: ComposerAttachment['kind'] =
candidate.file?.type.startsWith('image/') || isImagePath(candidate.file?.name || path) ? 'image' : 'file'
try {
const uploaded = await uploadComposerAttachment(
{ detail: path, id: attachmentId(kind, path), kind, label: pathLabel(path), path },
{ remote, requestGateway, sessionId }
)
const ref = attachmentDisplayText(uploaded)
if (ref) {
refs.push(ref)
}
} catch (err) {
notifyError(err, t.desktop.dropFiles)
}
}
return refs
},
[cwd, gateway, sessionId, t.desktop.dropFiles]
[aui, cwd, requestEditFocus]
)
const resetDragState = useCallback(() => {
@@ -1346,25 +1255,9 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
event.stopPropagation()
resetDragState()
// In-app drags (project tree / gutter) are workspace-relative paths that
// resolve on the gateway as-is, so they stay inline refs. OS drops need to
// be staged + uploaded first, then their gateway-side ref is inserted.
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
if (insertDroppedRefs(inAppRefs)) {
if (insertDroppedRefs(candidates)) {
triggerHaptic('selection')
}
if (osDrops.length) {
setStaging(true)
void uploadOsDropRefs(osDrops)
.then(refs => {
if (insertRefStrings(refs)) {
triggerHaptic('selection')
}
})
.finally(() => setStaging(false))
}
}
const handleInput = (event: FormEvent<HTMLDivElement>) => {
@@ -1395,7 +1288,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
const submitEdit = (editor: HTMLDivElement) => {
const nextDraft = syncDraftFromEditor(editor)
if (submitting || staging || !nextDraft.trim()) {
if (submitting || !nextDraft.trim()) {
return
}
@@ -1528,8 +1421,6 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
>
<div
aria-label={copy.editMessage}
autoCapitalize="off"
autoCorrect="off"
autoFocus
className={cn(
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 outline-none',
@@ -1551,39 +1442,13 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
onPaste={handlePaste}
ref={editorRef}
role="textbox"
spellCheck={false}
suppressContentEditableWarning
/>
<ComposerPrimitive.Input
asChild
className="sr-only"
submitMode="ctrlEnter"
tabIndex={-1}
unstable_focusOnScrollToBottom={false}
>
<textarea
aria-hidden
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="sr-only"
spellCheck={false}
tabIndex={-1}
/>
</ComposerPrimitive.Input>
{staging && (
<span
className="pointer-events-none absolute bottom-2 left-2 inline-flex items-center gap-1 rounded-full bg-background/80 px-1.5 py-0.5 text-[0.62rem] text-muted-foreground backdrop-blur-[1px]"
data-slot="aui_edit-staging"
>
<Loader2Icon className="size-3 animate-spin" />
{copy.attachingFile}
</span>
)}
<ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} />
<button
aria-label={copy.sendEdited}
className={cn('absolute right-2 bottom-2 size-5', USER_ACTION_ICON_BUTTON_CLASS)}
disabled={!canSubmit || submitting || staging}
disabled={!canSubmit || submitting}
onClick={() => {
const editor = editorRef.current
@@ -1,5 +1,5 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HermesGateway } from '@/hermes'
import { $gateway } from '@/store/gateway'
@@ -9,30 +9,13 @@ import { $activeSessionId } from '@/store/session'
import { PendingToolApproval } from './tool-approval'
import type { ToolPart } from './tool-fallback-model'
// Radix's DropdownMenu touches pointer-capture + scrollIntoView, which jsdom
// doesn't implement; stub them so the menu can open in tests.
beforeAll(() => {
const proto = window.HTMLElement.prototype as unknown as Record<string, () => unknown>
const stubs: Record<string, () => unknown> = {
hasPointerCapture: () => false,
releasePointerCapture: () => undefined,
scrollIntoView: () => undefined,
setPointerCapture: () => undefined
}
for (const [name, fn] of Object.entries(stubs)) {
proto[name] ??= fn
}
})
function part(toolName: string): ToolPart {
return { toolName, type: `tool-${toolName}` } as unknown as ToolPart
}
function setRequest(command = 'rm -rf /tmp/x', allowPermanent?: boolean) {
function setRequest(command = 'rm -rf /tmp/x') {
$activeSessionId.set('sess-1')
setApprovalRequest({ allowPermanent, command, description: 'dangerous command', sessionId: 'sess-1' })
setApprovalRequest({ command, description: 'dangerous command', sessionId: 'sess-1' })
}
function mockGateway() {
@@ -95,26 +78,4 @@ describe('PendingToolApproval', () => {
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'sess-1' })
})
})
it('offers "Always allow" in the options menu by default', async () => {
setRequest('chmod -R 777 /tmp/x')
render(<PendingToolApproval part={part('terminal')} />)
fireEvent.keyDown(screen.getByRole('button', { name: /More approval options/ }), { key: 'Enter' })
expect(await screen.findByRole('menuitem', { name: /Always allow/ })).toBeTruthy()
expect(screen.getByRole('menuitem', { name: /Allow this session/ })).toBeTruthy()
})
it('hides "Always allow" when the backend disallows a permanent allow', async () => {
// tirith content-security warning present → allowPermanent=false.
setRequest('curl https://bit.ly/abc | bash', false)
render(<PendingToolApproval part={part('terminal')} />)
fireEvent.keyDown(screen.getByRole('button', { name: /More approval options/ }), { key: 'Enter' })
// The session + reject options still render, but never the permanent allow.
expect(await screen.findByRole('menuitem', { name: /Allow this session/ })).toBeTruthy()
expect(screen.queryByRole('menuitem', { name: /Always allow/ })).toBeNull()
})
})
@@ -61,8 +61,6 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
// it goes through a confirm step rather than firing straight from the menu.
const [confirmAlways, setConfirmAlways] = useState(false)
const busy = submitting !== null
// false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
const allowPermanent = request.allowPermanent !== false
const respond = useCallback(
async (choice: ApprovalChoice) => {
@@ -146,18 +144,16 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-44">
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
{allowPermanent && (
<DropdownMenuItem
onSelect={() => {
// Defer one tick so the menu fully unmounts before the dialog
// mounts — otherwise Radix's focus-return races the dialog and
// dismisses it via onInteractOutside.
setTimeout(() => setConfirmAlways(true), 0)
}}
>
{copy.alwaysAllowMenu}
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={() => {
// Defer one tick so the menu fully unmounts before the dialog
// mounts — otherwise Radix's focus-return races the dialog and
// dismisses it via onInteractOutside.
setTimeout(() => setConfirmAlways(true), 0)
}}
>
{copy.alwaysAllowMenu}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
{copy.reject}
</DropdownMenuItem>
@@ -13,9 +13,9 @@ 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 { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { FadeText } from '@/components/ui/fade-text'
import { ToolIcon } from '@/components/ui/tool-icon'
import { useI18n } from '@/i18n'
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
@@ -136,7 +136,7 @@ function ToolGlyph({ copy, icon, status }: { copy: ToolStatusCopy; icon?: string
const node = status ? (
statusGlyph(status, copy)
) : icon ? (
<ToolIcon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
<Codicon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
) : null
return node ? <span className={TOOL_HEADER_GLYPH_WRAP_CLASS}>{node}</span> : null
@@ -279,14 +279,11 @@ function ToolEntry({ part }: ToolEntryProps) {
const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view])
// The header trailing slot only carries the live duration timer while the
// tool is running. The copy control used to live here too, but an
// `opacity-0` (yet still clickable) button straddling the caret/duration made
// the disclosure caret hard to hit. Copy now lives in the expanded body's
// top-right, where it can't fight the caret for the right edge.
const trailing =
isPending && !embedded ? (
<ActivityTimerText className={TOOL_HEADER_DURATION_CLASS} seconds={elapsed} />
) : !isPending && copyAction.text ? (
<CopyButton appearance="tool-row" label={copyAction.label} stopPropagation text={copyAction.text} />
) : undefined
return (
@@ -325,18 +322,7 @@ function ToolEntry({ part }: ToolEntryProps) {
</div>
{isPending && <PendingToolApproval part={part} />}
{open && (
<div className="relative grid w-full min-w-0 max-w-full gap-1.5 overflow-hidden p-1.5">
{copyAction.text && (
<CopyButton
appearance="inline"
className="absolute right-1.5 top-1.5 z-10 h-5 gap-0 rounded-md border border-(--ui-stroke-tertiary) bg-background/80 px-1 opacity-60 backdrop-blur-sm transition-opacity hover:opacity-100 focus-visible:opacity-100"
iconClassName="size-3"
label={copyAction.label}
showLabel={false}
stopPropagation
text={copyAction.text}
/>
)}
<div className="grid w-full min-w-0 max-w-full gap-1.5 overflow-hidden p-1.5">
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
<PreviewAttachment source="tool-result" target={view.previewTarget} />
)}
@@ -1,141 +0,0 @@
import { ExportedMessageRepository } from '@assistant-ui/core/internal'
// Clicking a user bubble must open the inline edit composer — through the
// app's incremental external-store runtime (which reimplements capability
// resolution, incl. `edit: onEdit !== undefined`) and the stock runtime.
//
// Note: this covers the React/runtime wiring only. The Electron-level failure
// mode (titlebar -webkit-app-region:drag swallowing clicks on *stuck* sticky
// bubbles) is not reproducible in jsdom — see USER_BUBBLE_BASE_CLASS's no-drag
// carve-out in thread.tsx.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { Thread } from './thread'
const createdAt = new Date('2026-05-01T00:00:00.000Z')
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
Element.prototype.scrollTo = function scrollTo() {}
function stubOffsetDimension(
prop: 'offsetHeight' | 'offsetWidth',
clientProp: 'clientHeight' | 'clientWidth',
fallback: number
) {
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
Object.defineProperty(HTMLElement.prototype, prop, {
configurable: true,
get() {
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
}
})
}
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
function userMessage(): ThreadMessage {
return {
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'edit me please' }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistantMessage(): ThreadMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
// Mirrors chat/index.tsx: incremental runtime + messageRepository + onEdit.
function IncrementalHarness({ onEdit }: { onEdit: () => Promise<void> }) {
const repository = ExportedMessageRepository.fromArray([userMessage(), assistantMessage()])
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
messageRepository: repository,
isRunning: false,
setMessages: () => {},
onNew: async () => {},
onEdit,
onCancel: async () => {},
onReload: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
// Control: stock external store runtime.
function StockHarness({ onEdit }: { onEdit: () => Promise<void> }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [userMessage(), assistantMessage()],
isRunning: false,
onNew: async () => {},
onEdit
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
describe('click-to-edit user message', () => {
it('opens the edit composer with the incremental runtime', async () => {
const { container } = render(<IncrementalHarness onEdit={async () => {}} />)
const bubble = await screen.findByRole('button', { name: 'Edit message' })
fireEvent.click(bubble)
await waitFor(() => {
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
})
})
it('opens the edit composer with the stock runtime', async () => {
const { container } = render(<StockHarness onEdit={async () => {}} />)
const bubble = await screen.findByRole('button', { name: 'Edit message' })
fireEvent.click(bubble)
await waitFor(() => {
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
})
})
})
@@ -127,9 +127,7 @@ const InlineSegmentView: FC<{ text: string }> = ({ text }) => {
const nodes = useMemo(() => splitInlineCode(text), [text])
return (
// styles.css bidi hook (#44150); whitespace-pre-line makes each line its own
// UAX#9 paragraph so it resolves direction independently.
<span className="wrap-anywhere block whitespace-pre-line" data-slot="aui_user-inline-text">
<span className="wrap-anywhere block whitespace-pre-line">
{nodes.map((node, nodeIndex) =>
node.kind === 'inline-code' ? (
<code
@@ -26,8 +26,7 @@ function setProviders(providers: OAuthProvider[]) {
reason: null,
requested: false,
firstRunSkipped: false,
manual: false,
localEndpoint: false
manual: false
} satisfies DesktopOnboardingState)
}
@@ -50,8 +49,7 @@ afterEach(() => {
reason: null,
requested: false,
firstRunSkipped: false,
manual: false,
localEndpoint: false
manual: false
})
})
@@ -430,24 +430,19 @@ const persistShowAll = (value: boolean) => {
export function Picker({ ctx }: { ctx: OnboardingContext }) {
const { t } = useI18n()
const { localEndpoint, manual, mode, providers } = useStore($desktopOnboarding)
const { manual, mode, providers } = useStore($desktopOnboarding)
const [showAll, setShowAll] = useState(readShowAll)
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
const hasOauth = ordered.length > 0
const apiKeyOptions = useApiKeyCatalog()
// localEndpoint forces the key form regardless of `mode` (which a manual
// provider refresh may flip back to 'oauth'); it preselects the local option
// and hides the "back to sign in" link since the user came specifically to
// configure a custom endpoint.
if (localEndpoint || mode === 'apikey' || !hasOauth) {
if (mode === 'apikey' || !hasOauth) {
return (
<div className="grid gap-3">
<ApiKeyForm
canGoBack={hasOauth && !localEndpoint}
initialEnvKey={localEndpoint ? 'OPENAI_BASE_URL' : undefined}
canGoBack={hasOauth}
onBack={() => setOnboardingMode('oauth')}
onSave={(envKey, value, name, apiKey) => saveOnboardingApiKey(envKey, value, name, ctx, apiKey)}
onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
options={apiKeyOptions}
/>
{manual ? null : (
@@ -635,7 +630,6 @@ export function ProviderRow({
// surfaces render the identical form.
export function ApiKeyForm({
canGoBack,
initialEnvKey,
isSet,
onBack,
onClear,
@@ -644,31 +638,16 @@ export function ApiKeyForm({
redactedValue
}: {
canGoBack: boolean
/** Preselect a specific option by env key (e.g. 'OPENAI_BASE_URL' to land on
* the local / custom endpoint form). Falls back to the first option. */
initialEnvKey?: string
isSet?: (envKey: string) => boolean
onBack: () => void
onClear?: (envKey: string) => void
onSave: (
envKey: string,
value: string,
name: string,
apiKey?: string
) => Promise<{ message?: string; ok: boolean }>
onSave: (envKey: string, value: string, name: string) => Promise<{ message?: string; ok: boolean }>
options?: ApiKeyOption[]
redactedValue?: (envKey: string) => null | string | undefined
}) {
const { t } = useI18n()
const [option, setOption] = useState<ApiKeyOption>(
() => options.find(o => o.envKey === initialEnvKey) ?? options[0]
)
const [option, setOption] = useState<ApiKeyOption>(options[0])
const [value, setValue] = useState('')
// Optional endpoint API key, only used by the local / custom endpoint option
// (whose `value` is the base URL). Cleared whenever the option changes.
const [localKey, setLocalKey] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
// `options` can change at runtime when callers filter the catalog (e.g. the
@@ -678,7 +657,6 @@ export function ApiKeyForm({
if (options.length > 0 && !options.some(o => o.envKey === option.envKey)) {
setOption(options[0])
setValue('')
setLocalKey('')
setError(null)
}
}, [option.envKey, options])
@@ -690,7 +668,6 @@ export function ApiKeyForm({
const pick = (o: ApiKeyOption) => {
setOption(o)
setValue('')
setLocalKey('')
setError(null)
requestAnimationFrame(() => {
entryRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
@@ -716,11 +693,10 @@ export function ApiKeyForm({
setSaving(true)
setError(null)
const result = await onSave(option.envKey, value, option.name, isLocal ? localKey : undefined)
const result = await onSave(option.envKey, value, option.name)
if (result.ok) {
setValue('')
setLocalKey('')
} else {
setError(result.message ?? t.onboarding.couldNotSave)
}
@@ -783,17 +759,6 @@ export function ApiKeyForm({
type={isLocal ? 'text' : 'password'}
value={value}
/>
{isLocal ? (
<Input
autoComplete="off"
className="font-mono"
onChange={e => setLocalKey(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void submit()}
placeholder={t.onboarding.localApiKeyPlaceholder}
type="password"
value={localKey}
/>
) : null}
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>
@@ -41,8 +41,7 @@ function resetStores() {
reason: null,
requested: false,
firstRunSkipped: false,
manual: false,
localEndpoint: false
manual: false
})
}
@@ -14,8 +14,6 @@ import {
$visibleModels,
collapseModelFamilies,
effectiveVisibleKeys,
emptyProviderSentinelKey,
isProviderSentinel,
modelVisibilityKey,
setVisibleModels
} from '@/store/model-visibility'
@@ -63,21 +61,10 @@ export function ModelVisibilityDialog({
const toggle = (provider: ModelOptionProvider, model: string) => {
const next = new Set(effectiveVisibleKeys($visibleModels.get(), providers))
const key = modelVisibilityKey(provider.slug, model)
const sentinel = emptyProviderSentinelKey(provider.slug)
if (next.has(key)) {
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)
)
if (!remainingForProvider) {
next.add(sentinel)
}
} else {
next.delete(sentinel)
next.add(key)
}
@@ -30,8 +30,6 @@ export interface PaneProps {
children?: ReactNode
className?: string
defaultOpen?: boolean
/** Paints a persistent hairline on the resize edge (not just the hover sash) so the pane boundary is always visible. */
divider?: boolean
/** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */
disabled?: boolean
/** Like disabled, but keeps hoverReveal alive — collapses the track without writing to the store (e.g. narrow window). */
@@ -96,35 +94,19 @@ const remPx = () =>
? 16
: Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16
const viewportPx = () => (typeof window === 'undefined' ? 1280 : window.innerWidth)
// Resolves PaneProps.minWidth/maxWidth (number | "Npx" | "Nrem" | "Nvw" | "N%") to
// pixels for drag clamping. Viewport units resolve against the current window width.
// Resolves PaneProps.minWidth/maxWidth (number | "Npx" | "Nrem") to pixels for drag clamping.
function widthToPx(value: WidthValue | undefined) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined
}
const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem|vw|%)?$/)
const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem)?$/)
if (!match) {
return undefined
}
const n = Number.parseFloat(match[1])
switch (match[2]) {
case 'rem':
return n * remPx()
case 'vw':
case '%':
return (n * viewportPx()) / 100
default:
return n
}
return Number.parseFloat(match[1]) * (match[2] === 'rem' ? remPx() : 1)
}
function isRole(child: unknown, role: 'pane' | 'main'): child is ReactElement {
@@ -235,7 +217,6 @@ export function Pane({
children,
className,
defaultOpen = true,
divider = false,
disabled = false,
hoverReveal = false,
id,
@@ -428,7 +409,6 @@ export function Pane({
role="separator"
tabIndex={0}
>
{divider && <span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-(--ui-stroke-secondary)" />}
<span className="absolute inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2 bg-(--ui-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100 group-focus-visible:opacity-100" />
</div>
)}
@@ -1,108 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { Dialog as DialogPrimitive } from 'radix-ui'
import { useEffect, useMemo, useState } from 'react'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { listAllProfileSessions } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { Check, MessageCircle } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface SessionPickerDialogProps {
/** Stored id of the session currently open, so it can be flagged in the list. */
activeStoredSessionId?: string | null
onOpenChange: (open: boolean) => void
onResume: (storedSessionId: string) => void
open: boolean
}
/**
* Desktop equivalent of the TUI's sessions overlay (`/resume`, `/sessions`,
* `/switch`): a focused, type-to-filter list of recent sessions that resumes
* the picked one. Mirrors the command palette's cmdk surface but scoped to
* sessions only, so `/resume` feels first-class instead of falling through to
* the headless slash worker (which can't render the picker).
*/
export function SessionPickerDialog({
activeStoredSessionId,
onOpenChange,
onResume,
open
}: SessionPickerDialogProps) {
const { t } = useI18n()
const [search, setSearch] = useState('')
const sessionsQuery = useQuery({
enabled: open,
queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
queryKey: ['session-picker', 'sessions']
})
useEffect(() => {
if (!open) {
setSearch('')
}
}, [open])
const sessions = useMemo(() => sessionsQuery.data?.sessions ?? [], [sessionsQuery.data])
return (
<DialogPrimitive.Root onOpenChange={onOpenChange} open={open}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/15 backdrop-blur-[1px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
aria-describedby={undefined}
className="fixed left-1/2 top-[14vh] z-[210] w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95"
>
<DialogPrimitive.Title className="sr-only">{t.commandCenter.sections.sessions}</DialogPrimitive.Title>
<Command className="bg-transparent" loop>
<CommandInput
onValueChange={setSearch}
placeholder={t.commandCenter.searchPlaceholder}
value={search}
/>
<CommandList className="max-h-[min(24rem,60vh)]">
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
<CommandGroup
className="**:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-wider **:[[cmdk-group-heading]]:text-[0.6875rem] **:[[cmdk-group-heading]]:text-muted-foreground/70"
heading={t.commandCenter.sections.sessions}
>
{sessions.map(session => {
const title = sessionTitle(session)
const preview = session.preview?.trim()
return (
<CommandItem
className="gap-2.5"
key={session.id}
onSelect={() => {
onResume(session.id)
onOpenChange(false)
}}
value={`${title} ${preview ?? ''} ${session.id}`}
>
<MessageCircle className="size-4 shrink-0 text-muted-foreground" />
<span className="flex min-w-0 flex-col leading-snug">
<span className="truncate">{title}</span>
{preview ? (
<span className="truncate text-xs text-muted-foreground/70">{preview}</span>
) : null}
</span>
<Check
className={cn(
'ml-auto size-4 shrink-0 text-foreground',
session.id !== activeStoredSessionId && 'invisible'
)}
/>
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
)
}
@@ -1,65 +0,0 @@
import type * as React from 'react'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
// Solid (filled) glyphs for in-thread tool rows. Codicons are an outline icon
// *font*, so an outline glyph has no separate fillable region — a filled look
// can't be derived from it (stroke-thickening just bolds the outline). To get
// the Cursor-style filled tool icons we render dedicated solid SVG paths,
// keyed by the same names used in `TOOL_META` (tool-fallback-model.ts).
//
// Paths are Phosphor Icons (MIT) "fill" weight, 256×256 viewBox. Inlining the
// path data mirrors the existing precedent in `directive-text.tsx`.
const TOOL_ICON_PATHS: Record<string, string> = {
diff: 'M118.18,213.08c-.11.14-.24.27-.36.4l-.16.18-.17.15a4.83,4.83,0,0,1-.42.37,3.92,3.92,0,0,1-.32.25l-.3.22-.38.23a2.91,2.91,0,0,1-.3.17l-.37.19-.34.15-.36.13a2.84,2.84,0,0,1-.38.13l-.36.1c-.14,0-.26.07-.4.09l-.42.07-.35.05a7,7,0,0,1-.79,0H64a8,8,0,0,1,0-16H92.69L55,162.34a23.85,23.85,0,0,1-7-17V95a32,32,0,1,1,16,0v50.38A8,8,0,0,0,66.34,151L104,188.69V160a8,8,0,0,1,16,0v48a7,7,0,0,1,0,.8c0,.11,0,.21,0,.32s0,.3-.07.46a2.83,2.83,0,0,1-.09.37c0,.13-.06.26-.1.39s-.08.23-.12.35l-.14.39-.15.31c-.06.13-.12.27-.19.4s-.11.18-.16.28l-.24.39-.21.28ZM208,161V110.63a23.85,23.85,0,0,0-7-17L163.31,56H192a8,8,0,0,0,0-16H143.82l-.6,0c-.14,0-.28,0-.41.06l-.37,0-.43.11-.33.08-.4.14-.34.13-.35.16-.36.18a3.14,3.14,0,0,0-.31.18c-.12.07-.25.14-.36.22a3.55,3.55,0,0,0-.31.23,3.81,3.81,0,0,0-.32.24c-.15.12-.28.24-.42.37l-.17.15-.16.18c-.12.13-.25.26-.36.4l-.26.35-.21.28-.24.39c-.05.1-.11.19-.16.28s-.13.27-.19.4l-.15.31-.14.39c0,.12-.09.23-.12.35s-.07.26-.1.39a2.83,2.83,0,0,0-.09.37c0,.16,0,.31-.07.46s0,.21-.05.32a7,7,0,0,0,0,.8V96a8,8,0,0,0,16,0V67.31L189.66,105a8,8,0,0,1,2.34,5.66V161a32,32,0,1,0,16,0Z',
edit: 'M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM192,108.68,147.31,64l24-24L216,84.68Z',
eye: 'M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,168a40,40,0,1,1,40-40A40,40,0,0,1,128,168Z',
file: 'M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM152,88V44l44,44Z',
'file-media':
'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM156,88a12,12,0,1,1-12,12A12,12,0,0,1,156,88Zm60,112H40V160.69l46.34-46.35a8,8,0,0,1,11.32,0h0L165,181.66a8,8,0,0,0,11.32-11.32l-17.66-17.65L173,138.34a8,8,0,0,1,11.31,0L216,170.07V200Z',
files:
'M213.66,66.34l-40-40A8,8,0,0,0,168,24H88A16,16,0,0,0,72,40V56H56A16,16,0,0,0,40,72V216a16,16,0,0,0,16,16H168a16,16,0,0,0,16-16V200h16a16,16,0,0,0,16-16V72A8,8,0,0,0,213.66,66.34ZM136,192H88a8,8,0,0,1,0-16h48a8,8,0,0,1,0,16Zm0-32H88a8,8,0,0,1,0-16h48a8,8,0,0,1,0,16Zm64,24H184V104a8,8,0,0,0-2.34-5.66l-40-40A8,8,0,0,0,136,56H88V40h76.69L200,75.31Z',
globe:
'M128,24h0A104,104,0,1,0,232,128,104.12,104.12,0,0,0,128,24Zm78.36,64H170.71a135.28,135.28,0,0,0-22.3-45.6A88.29,88.29,0,0,1,206.37,88ZM216,128a87.61,87.61,0,0,1-3.33,24H174.16a157.44,157.44,0,0,0,0-48h38.51A87.61,87.61,0,0,1,216,128ZM128,43a115.27,115.27,0,0,1,26,45H102A115.11,115.11,0,0,1,128,43ZM102,168H154a115.11,115.11,0,0,1-26,45A115.27,115.27,0,0,1,102,168Zm-3.9-16a140.84,140.84,0,0,1,0-48h59.88a140.84,140.84,0,0,1,0,48Zm50.35,61.6a135.28,135.28,0,0,0,22.3-45.6h35.66A88.29,88.29,0,0,1,148.41,213.6Z',
question:
'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,168a12,12,0,1,1,12-12A12,12,0,0,1,128,192Zm8-48.72V144a8,8,0,0,1-16,0v-8a8,8,0,0,1,8-8c13.23,0,24-9,24-20s-10.77-20-24-20-24,9-24,20v4a8,8,0,0,1-16,0v-4c0-19.85,17.94-36,40-36s40,16.15,40,36C168,125.38,154.24,139.93,136,143.28Z',
search:
'M168,112a56,56,0,1,1-56-56A56,56,0,0,1,168,112Zm61.66,117.66a8,8,0,0,1-11.32,0l-50.06-50.07a88,88,0,1,1,11.32-11.31l50.06,50.06A8,8,0,0,1,229.66,229.66ZM112,184a72,72,0,1,0-72-72A72.08,72.08,0,0,0,112,184Z',
terminal:
'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm-91,94.25-40,32a8,8,0,1,1-10-12.5L107.19,128,75,102.25a8,8,0,1,1,10-12.5l40,32a8,8,0,0,1,0,12.5ZM176,168H136a8,8,0,0,1,0-16h40a8,8,0,0,1,0,16Z',
tools:
'M232,96a72,72,0,0,1-100.94,66L79,222.22c-.12.14-.26.29-.39.42a32,32,0,0,1-45.26-45.26c.14-.13.28-.27.43-.39L94,124.94a72.07,72.07,0,0,1,83.54-98.78,8,8,0,0,1,3.93,13.19L144,80l5.66,26.35L176,112l40.65-37.52a8,8,0,0,1,13.19,3.93A72.6,72.6,0,0,1,232,96Z',
watch:
'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm56,112H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48a8,8,0,0,1,0,16Z'
}
export interface ToolIconProps {
className?: string
name: string
size?: number | string
}
/** Filled tool glyph. Falls back to the outline codicon font for any name not
* covered by the solid set so new tools still render an icon. */
export function ToolIcon({ className, name, size = '0.875rem' }: ToolIconProps) {
const path = TOOL_ICON_PATHS[name]
if (!path) {
return <Codicon className={className} name={name} size={size} />
}
const dimension: React.CSSProperties = { height: size, width: size }
return (
<svg
aria-hidden="true"
className={cn('shrink-0', className)}
fill="currentColor"
style={dimension}
viewBox="0 0 256 256"
>
<path d={path} />
</svg>
)
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -40
View File
@@ -18,10 +18,6 @@ declare global {
// reaper spares it while its chat is active.
touchBackend: (profile?: string | null) => Promise<{ ok: boolean }>
getGatewayWsUrl: (profile?: null | string) => Promise<string>
// Open (or focus) a standalone OS window for a single chat session so
// the user can work with multiple chats side by side. Returns ok:false
// with an error code when the sessionId is empty/invalid.
openSessionWindow: (sessionId: string) => Promise<{ ok: boolean; error?: string }>
getBootProgress: () => Promise<DesktopBootProgress>
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
@@ -55,9 +51,8 @@ declare global {
setPreviewShortcutActive?: (active: boolean) => void
openExternal: (url: string) => Promise<void>
fetchLinkTitle: (url: string) => Promise<string>
sanitizeWorkspaceCwd: (cwd?: null | string) => Promise<{ cwd: string; sanitized: boolean }>
settings: {
getDefaultProjectDir: () => Promise<{ defaultLabel: string; dir: null | string; resolvedCwd: string }>
getDefaultProjectDir: () => Promise<{ defaultLabel: string; dir: null | string }>
pickDefaultProjectDir: () => Promise<{ canceled: boolean; dir: null | string }>
setDefaultProjectDir: (dir: null | string) => Promise<{ dir: null | string }>
}
@@ -75,10 +70,6 @@ declare global {
}
onClosePreviewRequested?: (callback: () => void) => () => void
onOpenUpdatesRequested?: (callback: () => void) => () => void
onDeepLink?: (
callback: (payload: { kind: string; name: string; params: Record<string, string> }) => void,
) => () => void
signalDeepLinkReady?: () => Promise<{ ok: boolean }>
onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
@@ -101,40 +92,10 @@ declare global {
summary: () => Promise<DesktopUninstallSummary>
run: (mode: DesktopUninstallMode) => Promise<DesktopUninstallResult>
}
themes: {
// Download a VS Code Marketplace extension and return the raw color
// theme files it contributes. The renderer converts + persists them.
fetchMarketplace: (id: string) => Promise<DesktopMarketplaceThemeResult>
// Search the Marketplace for color-theme extensions. An empty query
// returns the most-installed themes.
searchMarketplace: (query: string) => Promise<DesktopMarketplaceSearchItem[]>
}
}
}
}
export interface DesktopMarketplaceSearchItem {
extensionId: string
displayName: string
publisher: string
description: string
installs: number
}
export interface DesktopMarketplaceThemeFile {
label: string
/** VS Code's `uiTheme` for this entry (vs-dark / vs / hc-black). */
uiTheme?: string
/** Raw theme JSON (JSONC) text, parsed + converted by the renderer. */
contents: string
}
export interface DesktopMarketplaceThemeResult {
extensionId: string
displayName: string
themes: DesktopMarketplaceThemeFile[]
}
export interface HermesTerminalSession {
cwd: string
id: string
+1 -12
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getSessionMessages, listAllProfileSessions, listSessions } from './hermes'
import { listAllProfileSessions, listSessions } from './hermes'
const emptySessionsResponse = {
limit: 0,
@@ -46,15 +46,4 @@ describe('Hermes REST session helpers', () => {
})
)
})
it('tags cross-profile message reads for Electron routing and backend lookup', async () => {
api.mockResolvedValue({ messages: [], session_id: 'session-1' })
await getSessionMessages('session-1', 'xiaoxuxu')
expect(api).toHaveBeenCalledWith({
path: '/api/sessions/session-1/messages?profile=xiaoxuxu',
profile: 'xiaoxuxu'
})
})
})
+2 -15
View File
@@ -7,7 +7,6 @@ import type {
AudioSpeakResponse,
AudioTranscriptionResponse,
AuxiliaryModelsResponse,
BackendUpdateCheckResponse,
ConfigSchemaResponse,
CronJob,
CronJobCreatePayload,
@@ -57,7 +56,6 @@ export type {
AudioSpeakResponse,
AudioTranscriptionResponse,
AuxiliaryModelsResponse,
BackendUpdateCheckResponse,
ConfigFieldSchema,
ConfigSchemaResponse,
CronJob,
@@ -218,7 +216,6 @@ export function getSessionMessages(id: string, profile?: string | null): Promise
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
return window.hermesDesktop.api<SessionMessagesResponse>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}`
})
}
@@ -344,14 +341,13 @@ export function setEnvVar(key: string, value: string): Promise<{ ok: boolean }>
export function validateProviderCredential(
key: string,
value: string,
apiKey?: string
value: string
): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[] }> {
return window.hermesDesktop.api<{ ok: boolean; reachable: boolean; message: string; models?: string[] }>({
...profileScoped(),
path: '/api/providers/validate',
method: 'POST',
body: { key, value, api_key: apiKey ?? '' }
body: { key, value }
})
}
@@ -690,15 +686,6 @@ export function updateHermes(): Promise<ActionResponse> {
})
}
/** Query the connected backend's own update state. In remote mode this is the
* authoritative source for the backend's behind-count + "what's changed",
* distinct from the Electron client clone's git state. */
export function checkHermesUpdate(force = false): Promise<BackendUpdateCheckResponse> {
return window.hermesDesktop.api<BackendUpdateCheckResponse>({
path: `/api/hermes/update/check${force ? '?force=true' : ''}`
})
}
export function getActionStatus(name: string, lines = 200): Promise<ActionStatusResponse> {
return window.hermesDesktop.api<ActionStatusResponse>({
path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}`
+8 -70
View File
@@ -179,15 +179,6 @@ export const en: Translations = {
'session.new': 'New session',
'session.next': 'Next session',
'session.prev': 'Previous session',
'session.slot.1': 'Switch to recent session 1',
'session.slot.2': 'Switch to recent session 2',
'session.slot.3': 'Switch to recent session 3',
'session.slot.4': 'Switch to recent session 4',
'session.slot.5': 'Switch to recent session 5',
'session.slot.6': 'Switch to recent session 6',
'session.slot.7': 'Switch to recent session 7',
'session.slot.8': 'Switch to recent session 8',
'session.slot.9': 'Switch to recent session 9',
'session.focusSearch': 'Search sessions',
'session.togglePin': 'Pin / unpin current session',
'composer.focus': 'Focus composer',
@@ -301,18 +292,7 @@ export const en: Translations = {
technical: 'Technical',
technicalDesc: 'Include raw tool args/results and low-level details.',
themeTitle: 'Theme',
themeDesc: 'Desktop palettes only. The selected mode is applied on top.',
themeProfileNote: profile => `Saved for the ${profile} profile — each profile keeps its own theme.`,
installTitle: 'Install from VS Code',
installDesc:
'Paste a Marketplace extension id (e.g. dracula-theme.theme-dracula) to convert its color theme into a desktop palette.',
installPlaceholder: 'publisher.extension',
installButton: 'Install',
installing: 'Installing…',
installError: 'Could not install that theme.',
installed: name => `Installed “${name}”.`,
removeTheme: 'Remove theme',
importedBadge: 'Imported'
themeDesc: 'Desktop palettes only. The selected mode is applied on top.'
},
fieldLabels: FIELD_LABELS,
fieldDescriptions: FIELD_DESCRIPTIONS,
@@ -529,7 +509,7 @@ export const en: Translations = {
defaultDirTitle: 'Default project directory',
defaultDirDesc:
'New sessions start in this folder unless you pick another. Leave it unset to use your home directory.',
defaultDirUpdated: 'Default project directory updated — start a new chat (Ctrl/⌘+N) for it to take effect',
defaultDirUpdated: 'Default project directory updated',
defaultsTo: label => `Defaults to ${label}.`,
change: 'Change',
choose: 'Choose',
@@ -646,17 +626,6 @@ export const en: Translations = {
settings: 'Settings',
changeTheme: 'Change theme...',
changeColorMode: 'Change color mode...',
installTheme: {
title: 'Install theme...',
placeholder: 'Search the VS Code Marketplace...',
loading: 'Searching the Marketplace...',
error: 'Could not reach the Marketplace.',
empty: 'No matching themes.',
install: 'Install',
installing: 'Installing...',
installed: 'Installed',
installs: count => `${count} installs`
},
settingsFields: 'Settings fields',
mcpServers: 'MCP servers',
archivedChats: 'Archived chats',
@@ -1105,14 +1074,12 @@ export const en: Translations = {
export: 'Export',
rename: 'Rename',
archive: 'Archive',
newWindow: 'New window',
copyIdFailed: 'Could not copy session ID',
actionsFor: title => `Actions for ${title}`,
sessionActions: 'Session actions',
sessionRunning: 'Session running',
needsInput: 'Needs your input',
waitingForAnswer: 'Waiting for your answer',
handoffOrigin: platform => `Handed off from ${platform}`,
renamed: 'Renamed',
renameFailed: 'Rename failed',
renameTitle: 'Rename session',
@@ -1151,7 +1118,7 @@ export const en: Translations = {
],
startVoice: 'Start voice conversation',
queueMessage: 'Queue message',
steer: 'Steer the current run',
steer: 'Steer the current run (⌘⏎)',
stop: 'Stop',
send: 'Send',
speaking: 'Speaking',
@@ -1270,13 +1237,9 @@ export const en: Translations = {
unsupportedMessage: 'This version of Hermes cant update itself from inside the app.',
connectionRetry: 'Check your connection and try again.',
latestBody: 'Youre running the latest version.',
latestBodyBackend: 'The backend is running the latest version.',
allSetTitle: 'Youre all set',
availableTitle: 'New update available',
availableBody: 'A new version of Hermes is ready to install.',
availableTitleBackend: 'Backend update available',
availableBodyBackend: 'A newer version of the connected Hermes backend is ready to install.',
availableBodyNoChangelog: 'A newer version is ready. Release notes arent available for this install type.',
updateNow: 'Update now',
maybeLater: 'Maybe later',
moreChanges: count => `+ ${count} more change${count === 1 ? '' : 's'} included.`,
@@ -1287,19 +1250,10 @@ export const en: Translations = {
copied: 'Copied',
done: 'Done',
applyingBody: 'The Hermes updater will take over in its own window and reopen Hermes when its done.',
applyingBodyBackend: 'The remote backend is applying the update and will restart. Hermes reconnects automatically when its back.',
applyingClose: 'Hermes will close to apply the update.',
errorTitle: 'Update didnt finish',
errorBody: 'No worries — nothing was lost. You can try again now.',
notNow: 'Not now',
applyStatus: {
preparing: 'Updating backend…',
pulling: 'Backend updating…',
restarting: 'Backend restarting to load the update…',
notAvailable: 'Update not available for this backend.',
failed: 'Backend update failed.',
noReturn: 'Backend didnt come back online. The update may not have completed — check the backend host.'
}
notNow: 'Not now'
},
install: {
@@ -1372,7 +1326,6 @@ export const en: Translations = {
getKey: 'Get a key',
replaceCurrent: 'Replace current value',
pasteApiKey: 'Paste API key',
localApiKeyPlaceholder: 'API key (optional — only if your endpoint requires one)',
couldNotSave: 'Could not save credential.',
connecting: 'Connecting',
update: 'Update',
@@ -1486,15 +1439,10 @@ export const en: Translations = {
updateInProgress: 'Update in progress',
commitsBehind: (count, branch) => `${count} commit${count === 1 ? '' : 's'} behind ${branch}`,
desktopVersion: version => `Hermes Desktop v${version}`,
backendVersion: version => `Backend v${version}`,
clientLabel: version => `client v${version}`,
backendLabel: version => `backend v${version}`,
commit: sha => `commit ${sha}`,
branch: branch => `branch ${branch}`,
closeCommandCenter: 'Close Command Center',
openCommandCenter: 'Open Command Center',
showTerminal: 'Show terminal',
hideTerminal: 'Hide terminal',
gateway: 'Gateway',
gatewayReady: 'ready',
gatewayNeedsSetup: 'needs setup',
@@ -1533,9 +1481,6 @@ export const en: Translations = {
terminal: 'Terminal',
noFolderSelected: 'No folder selected',
changeCwdTitle: 'Change working directory',
remotePickerTitle: 'Choose remote folder',
remotePickerDescription: 'Browse folders on the connected backend.',
remotePickerSelect: 'Select folder',
folderTip: cwd => `${cwd} — click to change folder`,
openFolder: 'Open folder',
refreshTree: 'Refresh tree',
@@ -1553,7 +1498,8 @@ export const en: Translations = {
tryAgain: 'Try again',
loadingTree: 'Loading file tree',
loadingFiles: 'Loading files',
terminalHide: 'Hide terminal',
terminalFocus: 'Focus terminal view',
terminalSplit: 'Return to split view',
addToChat: 'Add to chat'
},
@@ -1658,8 +1604,7 @@ export const en: Translations = {
restoreCheckpoint: 'Restore checkpoint',
restoreNext: 'Restore next checkpoint',
goForward: 'Go forward',
sendEdited: 'Send edited message',
attachingFile: 'Attaching…'
sendEdited: 'Send edited message'
},
approval: {
gatewayDisconnected: 'Hermes gateway is not connected',
@@ -1782,14 +1727,7 @@ export const en: Translations = {
clipboard: 'Clipboard',
noClipboardImage: 'No image found in clipboard',
clipboardPasteFailed: 'Clipboard paste failed',
dropFiles: 'Drop files',
handoff: {
pickPlatform: 'Choose a destination',
success: platform => `Handed off to ${platform}. Resume here anytime.`,
systemNote: platform => `↻ Handed off to ${platform} — resume here anytime.`,
failed: error => `Handoff failed: ${error}`,
timedOut: 'Timed out waiting for the gateway. Is `hermes gateway` running?'
}
dropFiles: 'Drop files'
},
errors: {
+6 -57
View File
@@ -215,17 +215,7 @@ export const ja = defineLocale({
technical: 'テクニカル',
technicalDesc: '生のツール引数、結果、低レベルの詳細を含めます。',
themeTitle: 'テーマ',
themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。',
themeProfileNote: profile => `${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。`,
installTitle: 'VS Code から導入',
installDesc: 'Marketplace の拡張機能 ID(例: dracula-theme.theme-dracula)を貼り付けると、その配色テーマをデスクトップ用パレットに変換します。',
installPlaceholder: 'publisher.extension',
installButton: 'インストール',
installing: 'インストール中…',
installError: 'そのテーマをインストールできませんでした。',
installed: name => `${name}」をインストールしました。`,
removeTheme: 'テーマを削除',
importedBadge: 'インポート済み'
themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。'
},
fieldLabels: defineFieldCopy({
model: 'デフォルトモデル',
@@ -771,17 +761,6 @@ export const ja = defineLocale({
settings: '設定',
changeTheme: 'テーマを変更...',
changeColorMode: 'カラーモードを変更...',
installTheme: {
title: 'テーマをインストール...',
placeholder: 'VS Code Marketplace を検索...',
loading: 'Marketplace を検索中...',
error: 'Marketplace に接続できませんでした。',
empty: '一致するテーマがありません。',
install: 'インストール',
installing: 'インストール中...',
installed: 'インストール済み',
installs: count => `${count} 回インストール`
},
settingsFields: '設定フィールド',
mcpServers: 'MCP サーバー',
archivedChats: 'アーカイブ済みチャット',
@@ -1238,14 +1217,12 @@ export const ja = defineLocale({
export: 'エクスポート',
rename: '名前を変更',
archive: 'アーカイブ',
newWindow: '新しいウィンドウ',
copyIdFailed: 'セッション ID をコピーできませんでした',
actionsFor: title => `${title} のアクション`,
sessionActions: 'セッションアクション',
sessionRunning: 'セッション実行中',
needsInput: '入力が必要です',
waitingForAnswer: '回答を待っています',
handoffOrigin: platform => `${platform} から引き継ぎ`,
renamed: '名前を変更しました',
renameFailed: '名前の変更に失敗しました',
renameTitle: 'セッションの名前を変更',
@@ -1401,13 +1378,9 @@ export const ja = defineLocale({
unsupportedMessage: 'このバージョンの Hermes はアプリ内から自分を更新できません。',
connectionRetry: '接続を確認してもう一度試してください。',
latestBody: '最新バージョンを実行しています。',
latestBodyBackend: 'バックエンドは最新バージョンを実行しています。',
allSetTitle: '準備完了',
availableTitle: '新しい更新が利用可能',
availableBody: '新しいバージョンの Hermes をインストールする準備ができています。',
availableTitleBackend: 'バックエンドの更新があります',
availableBodyBackend: '接続中の Hermes バックエンドの新しいバージョンをインストールできます。',
availableBodyNoChangelog: '新しいバージョンを利用できます。このインストール形式ではリリースノートは表示できません。',
updateNow: '今すぐ更新',
maybeLater: '後で',
moreChanges: count => `さらに ${count} 件の変更が含まれています。`,
@@ -1419,19 +1392,10 @@ export const ja = defineLocale({
copied: 'コピーしました',
done: '完了',
applyingBody: 'Hermes アップデーターが独自のウィンドウで引き継ぎ、完了後に Hermes を再度開きます。',
applyingBodyBackend: 'リモートバックエンドが更新を適用して再起動します。復帰すると Hermes が自動的に再接続します。',
applyingClose: 'Hermes は更新を適用するために閉じます。',
errorTitle: '更新が完了しませんでした',
errorBody: 'ご安心ください。何も失われていません。今すぐ再試行できます。',
notNow: '今は後で',
applyStatus: {
preparing: 'バックエンドを更新しています…',
pulling: 'バックエンドを更新中…',
restarting: 'バックエンドが更新を読み込むため再起動しています…',
notAvailable: 'このバックエンドでは更新を利用できません。',
failed: 'バックエンドの更新に失敗しました。',
noReturn: 'バックエンドがオンラインに戻りませんでした。更新が完了していない可能性があります。バックエンドホストを確認してください。'
}
notNow: '今は後で'
},
install: {
@@ -1618,15 +1582,10 @@ export const ja = defineLocale({
updateInProgress: '更新中',
commitsBehind: (count, branch) => `${branch} より ${count} コミット遅れています`,
desktopVersion: version => `Hermes Desktop v${version}`,
backendVersion: version => `バックエンド v${version}`,
clientLabel: version => `クライアント v${version}`,
backendLabel: version => `バックエンド v${version}`,
commit: sha => `コミット ${sha}`,
branch: branch => `ブランチ ${branch}`,
closeCommandCenter: 'コマンドセンターを閉じる',
openCommandCenter: 'コマンドセンターを開く',
showTerminal: 'ターミナルを表示',
hideTerminal: 'ターミナルを非表示',
gateway: 'ゲートウェイ',
gatewayReady: '準備完了',
gatewayNeedsSetup: '設定が必要',
@@ -1665,9 +1624,6 @@ export const ja = defineLocale({
terminal: 'ターミナル',
noFolderSelected: 'フォルダーが選択されていません',
changeCwdTitle: '作業ディレクトリを変更',
remotePickerTitle: 'リモートフォルダーを選択',
remotePickerDescription: '接続中のバックエンド上のフォルダーを参照します。',
remotePickerSelect: 'フォルダーを選択',
folderTip: cwd => `${cwd} — クリックしてフォルダーを変更`,
openFolder: 'フォルダーを開く',
refreshTree: 'ツリーを更新',
@@ -1685,7 +1641,8 @@ export const ja = defineLocale({
tryAgain: '再試行',
loadingTree: 'ファイルツリーを読み込み中',
loadingFiles: 'ファイルを読み込み中',
terminalHide: 'ターミナルを非表示',
terminalFocus: 'ターミナルビューにフォーカス',
terminalSplit: '分割ビューに戻る',
addToChat: 'チャットに追加'
},
@@ -1791,8 +1748,7 @@ export const ja = defineLocale({
restoreCheckpoint: 'チェックポイントを復元',
restoreNext: '次のチェックポイントに戻す',
goForward: '進む',
sendEdited: '編集済みメッセージを送信',
attachingFile: '添付中…'
sendEdited: '編集済みメッセージを送信'
},
approval: {
gatewayDisconnected: 'Hermes ゲートウェイが接続されていません',
@@ -1917,14 +1873,7 @@ export const ja = defineLocale({
clipboard: 'クリップボード',
noClipboardImage: 'クリップボードに画像が見つかりません',
clipboardPasteFailed: 'クリップボードからの貼り付けに失敗しました',
dropFiles: 'ファイルをドロップ',
handoff: {
pickPlatform: '送信先を選択',
success: platform => `${platform} に引き継ぎました。いつでもここで再開できます。`,
systemNote: platform => `${platform} に引き継ぎました — いつでもここで再開できます。`,
failed: error => `引き継ぎに失敗しました: ${error}`,
timedOut: 'ゲートウェイの待機がタイムアウトしました。`hermes gateway` は起動していますか?'
}
dropFiles: 'ファイルをドロップ'
},
errors: {
+2 -54
View File
@@ -219,16 +219,6 @@ export interface Translations {
technicalDesc: string
themeTitle: string
themeDesc: string
themeProfileNote: (profile: string) => string
installTitle: string
installDesc: string
installPlaceholder: string
installButton: string
installing: string
installError: string
installed: (name: string) => string
removeTheme: string
importedBadge: string
}
fieldLabels: Record<string, string>
fieldDescriptions: Record<string, string>
@@ -543,17 +533,6 @@ export interface Translations {
settings: string
changeTheme: string
changeColorMode: string
installTheme: {
title: string
placeholder: string
loading: string
error: string
empty: string
install: string
installing: string
installed: string
installs: (count: string) => string
}
settingsFields: string
mcpServers: string
archivedChats: string
@@ -852,14 +831,12 @@ export interface Translations {
export: string
rename: string
archive: string
newWindow: string
copyIdFailed: string
actionsFor: (title: string) => string
sessionActions: string
sessionRunning: string
needsInput: string
waitingForAnswer: string
handoffOrigin: (platform: string) => string
renamed: string
renameFailed: string
renameTitle: string
@@ -960,13 +937,9 @@ export interface Translations {
unsupportedMessage: string
connectionRetry: string
latestBody: string
latestBodyBackend: string
allSetTitle: string
availableTitle: string
availableBody: string
availableTitleBackend: string
availableBodyBackend: string
availableBodyNoChangelog: string
updateNow: string
maybeLater: string
moreChanges: (count: number) => string
@@ -977,19 +950,10 @@ export interface Translations {
copied: string
done: string
applyingBody: string
applyingBodyBackend: string
applyingClose: string
errorTitle: string
errorBody: string
notNow: string
applyStatus: {
preparing: string
pulling: string
restarting: string
notAvailable: string
failed: string
noReturn: string
}
}
install: {
@@ -1041,7 +1005,6 @@ export interface Translations {
getKey: string
replaceCurrent: string
pasteApiKey: string
localApiKeyPlaceholder: string
couldNotSave: string
connecting: string
update: string
@@ -1148,15 +1111,10 @@ export interface Translations {
updateInProgress: string
commitsBehind: (count: number, branch: string) => string
desktopVersion: (version: string) => string
backendVersion: (version: string) => string
clientLabel: (version: string) => string
backendLabel: (version: string) => string
commit: (sha: string) => string
branch: (branch: string) => string
closeCommandCenter: string
openCommandCenter: string
showTerminal: string
hideTerminal: string
gateway: string
gatewayReady: string
gatewayNeedsSetup: string
@@ -1195,9 +1153,6 @@ export interface Translations {
terminal: string
noFolderSelected: string
changeCwdTitle: string
remotePickerTitle: string
remotePickerDescription: string
remotePickerSelect: string
folderTip: (cwd: string) => string
openFolder: string
refreshTree: string
@@ -1215,7 +1170,8 @@ export interface Translations {
tryAgain: string
loadingTree: string
loadingFiles: string
terminalHide: string
terminalFocus: string
terminalSplit: string
addToChat: string
}
@@ -1319,7 +1275,6 @@ export interface Translations {
restoreNext: string
goForward: string
sendEdited: string
attachingFile: string
}
approval: {
gatewayDisconnected: string
@@ -1441,13 +1396,6 @@ export interface Translations {
noClipboardImage: string
clipboardPasteFailed: string
dropFiles: string
handoff: {
pickPlatform: string
success: (platform: string) => string
systemNote: (platform: string) => string
failed: (error: string) => string
timedOut: string
}
}
errors: {
+6 -57
View File
@@ -209,17 +209,7 @@ export const zhHant = defineLocale({
technical: '技術',
technicalDesc: '包含原始工具參數、結果與底層細節。',
themeTitle: '主題',
themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。',
themeProfileNote: profile => `已為「${profile}」設定檔儲存——每個設定檔保留各自的主題。`,
installTitle: '從 VS Code 安裝',
installDesc: '貼上 Marketplace 擴充功能 ID(例如 dracula-theme.theme-dracula),將其配色主題轉換為桌面調色盤。',
installPlaceholder: 'publisher.extension',
installButton: '安裝',
installing: '安裝中…',
installError: '無法安裝該主題。',
installed: name => `已安裝「${name}」。`,
removeTheme: '移除主題',
importedBadge: '已匯入'
themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。'
},
fieldLabels: defineFieldCopy({
model: '預設模型',
@@ -754,17 +744,6 @@ export const zhHant = defineLocale({
settings: '設定',
changeTheme: '變更主題...',
changeColorMode: '變更色彩模式...',
installTheme: {
title: '安裝主題...',
placeholder: '搜尋 VS Code Marketplace...',
loading: '正在搜尋 Marketplace...',
error: '無法連接到 Marketplace。',
empty: '沒有符合的主題。',
install: '安裝',
installing: '安裝中...',
installed: '已安裝',
installs: count => `${count} 次安裝`
},
settingsFields: '設定欄位',
mcpServers: 'MCP 伺服器',
archivedChats: '已封存聊天',
@@ -1204,14 +1183,12 @@ export const zhHant = defineLocale({
export: '匯出',
rename: '重新命名',
archive: '封存',
newWindow: '新視窗',
copyIdFailed: '無法複製工作階段 ID',
actionsFor: title => `${title} 的動作`,
sessionActions: '工作階段動作',
sessionRunning: '工作階段執行中',
needsInput: '需要您的輸入',
waitingForAnswer: '等待您的回答',
handoffOrigin: platform => `${platform} 轉接`,
renamed: '已重新命名',
renameFailed: '重新命名失敗',
renameTitle: '重新命名工作階段',
@@ -1367,13 +1344,9 @@ export const zhHant = defineLocale({
unsupportedMessage: '此版本的 Hermes 無法在應用程式內自行更新。',
connectionRetry: '請檢查網路連線後重試。',
latestBody: '您正在執行最新版本。',
latestBodyBackend: '後端正在執行最新版本。',
allSetTitle: '已是最新版本',
availableTitle: '有可用更新',
availableBody: '新版 Hermes 已可安裝。',
availableTitleBackend: '後端有可用更新',
availableBodyBackend: '已連接的 Hermes 後端有新版本可安裝。',
availableBodyNoChangelog: '已有新版本可用。此安裝方式無法顯示更新日誌。',
updateNow: '立即更新',
maybeLater: '稍後再說',
moreChanges: count => `另有 ${count} 項變更。`,
@@ -1384,19 +1357,10 @@ export const zhHant = defineLocale({
copied: '已複製',
done: '完成',
applyingBody: 'Hermes 更新程式會在自己的視窗中接管,並在完成後重新開啟 Hermes。',
applyingBodyBackend: '遠端後端正在套用更新並將重新啟動。恢復後 Hermes 會自動重新連線。',
applyingClose: 'Hermes 將關閉以套用更新。',
errorTitle: '更新未完成',
errorBody: '沒有資料遺失。您可以現在重試。',
notNow: '暫不',
applyStatus: {
preparing: '正在更新後端…',
pulling: '後端更新中…',
restarting: '後端正在重新啟動以載入更新…',
notAvailable: '此後端無法更新。',
failed: '後端更新失敗。',
noReturn: '後端未恢復連線。更新可能未完成——請檢查後端主機。'
}
notNow: '暫不'
},
install: {
@@ -1579,15 +1543,10 @@ export const zhHant = defineLocale({
updateInProgress: '更新中',
commitsBehind: (count, branch) => `落後 ${branch} ${count} 個提交`,
desktopVersion: version => `Hermes Desktop v${version}`,
backendVersion: version => `後端 v${version}`,
clientLabel: version => `用戶端 v${version}`,
backendLabel: version => `後端 v${version}`,
commit: sha => `提交 ${sha}`,
branch: branch => `分支 ${branch}`,
closeCommandCenter: '關閉命令中心',
openCommandCenter: '開啟命令中心',
showTerminal: '顯示終端機',
hideTerminal: '隱藏終端機',
gateway: '閘道',
gatewayReady: '就緒',
gatewayNeedsSetup: '需要設定',
@@ -1626,9 +1585,6 @@ export const zhHant = defineLocale({
terminal: '終端機',
noFolderSelected: '未選擇資料夾',
changeCwdTitle: '變更工作目錄',
remotePickerTitle: '選擇遠端資料夾',
remotePickerDescription: '瀏覽已連線後端上的資料夾。',
remotePickerSelect: '選擇資料夾',
folderTip: cwd => `${cwd} — 點擊以變更資料夾`,
openFolder: '開啟資料夾',
refreshTree: '重新整理檔案樹',
@@ -1646,7 +1602,8 @@ export const zhHant = defineLocale({
tryAgain: '重試',
loadingTree: '正在載入檔案樹',
loadingFiles: '正在載入檔案',
terminalHide: '隱藏終端機',
terminalFocus: '聚焦終端機檢視',
terminalSplit: '返回分割檢視',
addToChat: '新增至聊天'
},
@@ -1752,8 +1709,7 @@ export const zhHant = defineLocale({
restoreCheckpoint: '還原檢查點',
restoreNext: '還原至下一個檢查點',
goForward: '前進',
sendEdited: '傳送編輯後的訊息',
attachingFile: '正在附加…'
sendEdited: '傳送編輯後的訊息'
},
approval: {
gatewayDisconnected: 'Hermes 閘道未連線',
@@ -1876,14 +1832,7 @@ export const zhHant = defineLocale({
clipboard: '剪貼簿',
noClipboardImage: '剪貼簿中沒有圖片',
clipboardPasteFailed: '剪貼簿貼上失敗',
dropFiles: '拖曳檔案',
handoff: {
pickPlatform: '選擇目標平台',
success: platform => `已移交到 ${platform}。隨時可在此處恢復。`,
systemNote: platform => `↻ 已移交到 ${platform} — 隨時可在此處恢復。`,
failed: error => `移交失敗:${error}`,
timedOut: '等待閘道逾時。`hermes gateway` 是否正在執行?'
}
dropFiles: '拖曳檔案'
},
errors: {
+7 -68
View File
@@ -175,15 +175,6 @@ export const zh: Translations = {
'session.new': '新建会话',
'session.next': '下一个会话',
'session.prev': '上一个会话',
'session.slot.1': '切换到最近会话 1',
'session.slot.2': '切换到最近会话 2',
'session.slot.3': '切换到最近会话 3',
'session.slot.4': '切换到最近会话 4',
'session.slot.5': '切换到最近会话 5',
'session.slot.6': '切换到最近会话 6',
'session.slot.7': '切换到最近会话 7',
'session.slot.8': '切换到最近会话 8',
'session.slot.9': '切换到最近会话 9',
'session.focusSearch': '搜索会话',
'session.togglePin': '固定/取消固定当前会话',
'composer.focus': '聚焦输入框',
@@ -296,17 +287,7 @@ export const zh: Translations = {
technical: '技术',
technicalDesc: '包含原始工具参数/结果及底层细节。',
themeTitle: '主题',
themeDesc: '仅桌面端调色板。所选模式叠加其上。',
themeProfileNote: profile => `已为「${profile}」配置文件保存——每个配置文件保留各自的主题。`,
installTitle: '从 VS Code 安装',
installDesc: '粘贴 Marketplace 扩展 ID(例如 dracula-theme.theme-dracula),将其配色主题转换为桌面调色板。',
installPlaceholder: 'publisher.extension',
installButton: '安装',
installing: '安装中…',
installError: '无法安装该主题。',
installed: name => `已安装「${name}」。`,
removeTheme: '移除主题',
importedBadge: '已导入'
themeDesc: '仅桌面端调色板。所选模式叠加其上。'
},
fieldLabels: defineFieldCopy({
model: '默认模型',
@@ -838,17 +819,6 @@ export const zh: Translations = {
settings: '设置',
changeTheme: '更改主题...',
changeColorMode: '更改颜色模式...',
installTheme: {
title: '安装主题...',
placeholder: '搜索 VS Code Marketplace...',
loading: '正在搜索 Marketplace...',
error: '无法连接到 Marketplace。',
empty: '没有匹配的主题。',
install: '安装',
installing: '安装中...',
installed: '已安装',
installs: count => `${count} 次安装`
},
settingsFields: '设置字段',
mcpServers: 'MCP 服务器',
archivedChats: '已归档对话',
@@ -1291,14 +1261,12 @@ export const zh: Translations = {
export: '导出',
rename: '重命名',
archive: '归档',
newWindow: '新窗口',
copyIdFailed: '无法复制会话 ID',
actionsFor: title => `${title} 的操作`,
sessionActions: '会话操作',
sessionRunning: '会话运行中',
needsInput: '需要你输入',
waitingForAnswer: '正在等待你的回答',
handoffOrigin: platform => `${platform} 转接`,
renamed: '已重命名',
renameFailed: '重命名失败',
renameTitle: '重命名会话',
@@ -1337,7 +1305,7 @@ export const zh: Translations = {
],
startVoice: '开始语音对话',
queueMessage: '排队消息',
steer: '引导当前运行',
steer: '引导当前运行 (⌘⏎)',
stop: '停止',
send: '发送',
speaking: '讲话中',
@@ -1456,13 +1424,9 @@ export const zh: Translations = {
unsupportedMessage: '此版本的 Hermes 无法在应用内自行更新。',
connectionRetry: '请检查网络连接后重试。',
latestBody: '你正在运行最新版本。',
latestBodyBackend: '后端正在运行最新版本。',
allSetTitle: '已是最新',
availableTitle: '有可用更新',
availableBody: '新版 Hermes 已可安装。',
availableTitleBackend: '后端有可用更新',
availableBodyBackend: '已连接的 Hermes 后端有新版本可安装。',
availableBodyNoChangelog: '已有新版本可用。此安装方式无法显示更新日志。',
updateNow: '立即更新',
maybeLater: '稍后再说',
moreChanges: count => `另有 ${count} 项更改。`,
@@ -1473,19 +1437,10 @@ export const zh: Translations = {
copied: '已复制',
done: '完成',
applyingBody: 'Hermes 更新器会在自己的窗口中接管,并在完成后重新打开 Hermes。',
applyingBodyBackend: '远程后端正在应用更新并将重启。恢复后 Hermes 会自动重新连接。',
applyingClose: 'Hermes 将关闭以应用更新。',
errorTitle: '更新未完成',
errorBody: '没有数据丢失。你可以现在重试。',
notNow: '暂不',
applyStatus: {
preparing: '正在更新后端…',
pulling: '后端更新中…',
restarting: '后端正在重启以加载更新…',
notAvailable: '此后端无法更新。',
failed: '后端更新失败。',
noReturn: '后端未恢复在线。更新可能未完成——请检查后端主机。'
}
notNow: '暂不'
},
install: {
@@ -1554,7 +1509,6 @@ export const zh: Translations = {
getKey: '获取密钥',
replaceCurrent: '替换当前值',
pasteApiKey: '粘贴 API 密钥',
localApiKeyPlaceholder: 'API 密钥(可选 — 仅当端点需要时填写)',
couldNotSave: '无法保存凭据。',
connecting: '连接中',
update: '更新',
@@ -1666,15 +1620,10 @@ export const zh: Translations = {
updateInProgress: '正在更新',
commitsBehind: (count, branch) => `落后 ${branch} ${count} 个提交`,
desktopVersion: version => `Hermes Desktop v${version}`,
backendVersion: version => `后端 v${version}`,
clientLabel: version => `客户端 v${version}`,
backendLabel: version => `后端 v${version}`,
commit: sha => `提交 ${sha}`,
branch: branch => `分支 ${branch}`,
closeCommandCenter: '关闭命令中心',
openCommandCenter: '打开命令中心',
showTerminal: '显示终端',
hideTerminal: '隐藏终端',
gateway: '网关',
gatewayReady: '就绪',
gatewayNeedsSetup: '需要设置',
@@ -1713,9 +1662,6 @@ export const zh: Translations = {
terminal: '终端',
noFolderSelected: '未选择文件夹',
changeCwdTitle: '更改工作目录',
remotePickerTitle: '选择远程文件夹',
remotePickerDescription: '浏览已连接后端上的文件夹。',
remotePickerSelect: '选择文件夹',
folderTip: cwd => `${cwd} — 点击更改文件夹`,
openFolder: '打开文件夹',
refreshTree: '刷新文件树',
@@ -1733,7 +1679,8 @@ export const zh: Translations = {
tryAgain: '重试',
loadingTree: '正在加载文件树',
loadingFiles: '正在加载文件',
terminalHide: '隐藏终端',
terminalFocus: '聚焦终端视图',
terminalSplit: '返回分栏视图',
addToChat: '添加到对话'
},
@@ -1837,8 +1784,7 @@ export const zh: Translations = {
restoreCheckpoint: '恢复检查点',
restoreNext: '恢复下一个检查点',
goForward: '前进',
sendEdited: '发送编辑后的消息',
attachingFile: '正在附加…'
sendEdited: '发送编辑后的消息'
},
approval: {
gatewayDisconnected: 'Hermes 网关未连接',
@@ -1960,14 +1906,7 @@ export const zh: Translations = {
clipboard: '剪贴板',
noClipboardImage: '剪贴板中没有图片',
clipboardPasteFailed: '粘贴剪贴板失败',
dropFiles: '拖放文件',
handoff: {
pickPlatform: '选择目标平台',
success: platform => `已移交到 ${platform}。随时可在此处恢复。`,
systemNote: platform => `↻ 已移交到 ${platform} — 随时可在此处恢复。`,
failed: error => `移交失败:${error}`,
timedOut: '等待网关超时。`hermes gateway` 是否正在运行?'
}
dropFiles: '拖放文件'
},
errors: {
-11
View File
@@ -173,14 +173,3 @@ export function hasAnsiCodes(input: string): boolean {
// eslint-disable-next-line no-control-regex
return /\x1b\[/.test(input)
}
/** Remove all ANSI escape sequences, returning plain text. Use when output is
* rendered as text (e.g. chat system messages) rather than styled segments
* otherwise the ESC byte is invisible and the `[1;31m…` payload leaks through. */
export function stripAnsi(input: string): string {
if (!input) {
return input
}
return input.replace(OTHER_ESCAPE_RE, '').replace(CSI_RE, '')
}
-5
View File
@@ -58,14 +58,9 @@ export type GatewayEventPayload = {
// approval.request (dangerous command / execute_code) — session-keyed
command?: string
description?: string
// False when a tirith content-security warning forbids a permanent allow.
allow_permanent?: boolean
// secret.request (skill credential capture)
env_var?: string
prompt?: string
// terminal.read.request (GUI agent reading the in-app terminal pane)
start?: number
count?: number
}
export function textPart(text: string): ChatMessagePart {
+1 -37
View File
@@ -1,42 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { ComposerAttachment } from '@/store/composer'
import { coerceThinkingText, optimisticAttachmentRef } from './chat-runtime'
const DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANS'
function attachment(overrides: Partial<ComposerAttachment> & Pick<ComposerAttachment, 'kind'>): ComposerAttachment {
return { id: 'a', label: 'file.png', ...overrides }
}
describe('optimisticAttachmentRef', () => {
it('renders an image from its in-hand base64 preview (no @image: path ref)', () => {
const ref = optimisticAttachmentRef(attachment({ kind: 'image', detail: '/tmp/shot.png', previewUrl: DATA_URL }))
// The raw data URL flows through extractEmbeddedImages → inline thumbnail,
// dodging the remote /api/media 403 an @image:<localpath> ref would hit.
expect(ref).toBe(DATA_URL)
})
it('falls back to an @image: path ref when no preview is available', () => {
expect(optimisticAttachmentRef(attachment({ kind: 'image', detail: '/tmp/shot.png' }))).toBe('@image:/tmp/shot.png')
})
it('ignores a non-data preview url and uses the path ref', () => {
const ref = optimisticAttachmentRef(
attachment({ kind: 'image', detail: '/tmp/shot.png', previewUrl: 'https://example.com/x.png' })
)
expect(ref).toBe('@image:/tmp/shot.png')
})
it('passes non-image attachments straight through to attachmentDisplayText', () => {
expect(optimisticAttachmentRef(attachment({ kind: 'file', refText: '@file:src/a.ts', previewUrl: DATA_URL }))).toBe(
'@file:src/a.ts'
)
})
})
import { coerceThinkingText } from './chat-runtime'
describe('coerceThinkingText', () => {
it('strips streaming status prefixes from thinking deltas', () => {

Some files were not shown because too many files have changed in this diff Show More