feat(desktop): reconcile live tool events, polish thread chrome, harden boot

- chat-messages: match tool rows by overlapping query/context/preview values
  so preview-first `tool.progress` rows reliably adopt later stable-id
  `tool.start` payloads instead of spawning ghost rows or mis-merging
  parallel same-name calls; preserve prior args/result across phases.
- tui_gateway: emit full args + parsed result on `tool.start` / `tool.complete`,
  drop redundant `tool.started` re-emit from `tool.progress`.
- electron/main: prefer SOURCE_REPO_ROOT before PATH `hermes` in dev so
  local backend edits actually run; split hardening helpers into
  `electron/hardening.cjs` with tests.
- thread/tool UI: one-shot enter animation keyed by stable ids, braille
  spinner for running rows, Cursor-like disclosure rows, drill-down +
  duration/count formatting via new tool-fallback-model.
- composer: extract `text-utils`, drop liquid-glass overrides.
- right-rail: split preview-pane into preview-console / preview-file.
- runtime: incremental external-store runtime + runtime-readiness gate;
  onboarding store + tests; route-resume hook test.
- regression tests for live tool reconciliation (parallel tools, id-less
  progress, preview-first rows, structured args/results).
This commit is contained in:
Brooklyn Nicholson
2026-05-11 21:38:47 -04:00
parent fdf73f0adf
commit d208f2c2c0
64 changed files with 5614 additions and 2703 deletions
+1
View File
@@ -154,6 +154,7 @@ export function clearNotifications() {
timers.clear()
const all = $notifications.get()
$notifications.set([])
for (const item of all) {
item.onDismiss?.()
}
+144
View File
@@ -0,0 +1,144 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OAuthProvider } from '@/types/hermes'
import {
$desktopOnboarding,
type DesktopOnboardingState,
type OnboardingContext,
refreshOnboarding,
requestDesktopOnboarding
} from './onboarding'
function provider(id: string, name = id): OAuthProvider {
return {
cli_command: `hermes login ${id}`,
docs_url: `https://example.com/${id}`,
flow: 'pkce',
id,
name,
status: { logged_in: false }
}
}
function baseState(overrides: Partial<DesktopOnboardingState> = {}): DesktopOnboardingState {
return {
configured: false,
flow: { status: 'idle' },
mode: 'oauth',
providers: null,
reason: null,
requested: false,
...overrides
}
}
function installApiMock(api: (request: { path: string }) => Promise<unknown>) {
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { api }
})
}
function runtimeMismatchGateway(): OnboardingContext['requestGateway'] {
return async method => {
if (method === 'setup.status') {
return { provider_configured: true } as never
}
if (method === 'setup.runtime_check') {
return { error: 'Selected runtime is not available.', ok: false } as never
}
throw new Error(`unexpected gateway method: ${method}`)
}
}
function onboardingContext(requestGateway: OnboardingContext['requestGateway']): OnboardingContext {
return { requestGateway }
}
describe('refreshOnboarding', () => {
beforeEach(() => {
window.localStorage.clear()
$desktopOnboarding.set(baseState())
})
afterEach(() => {
window.localStorage.clear()
$desktopOnboarding.set(baseState())
vi.restoreAllMocks()
})
it('refreshes OAuth providers again when onboarding was explicitly requested', async () => {
const api = vi.fn(async ({ path }: { path: string }) => {
if (path === '/api/providers/oauth') {
return { providers: [provider('fresh')] }
}
throw new Error(`unexpected api path: ${path}`)
})
installApiMock(api)
$desktopOnboarding.set(baseState({ providers: [provider('cached')] }))
requestDesktopOnboarding('Need provider setup')
const ready = await refreshOnboarding(onboardingContext(runtimeMismatchGateway()))
expect(ready).toBe(false)
expect(api).toHaveBeenCalledTimes(1)
expect($desktopOnboarding.get().providers?.map(p => p.id)).toEqual(['fresh'])
expect($desktopOnboarding.get().reason).toContain('Selected runtime is not available.')
expect($desktopOnboarding.get().reason).toContain('setup.status reports configured credentials')
})
it('keeps cached providers when onboarding was not re-requested', async () => {
const api = vi.fn(async ({ path }: { path: string }) => {
if (path === '/api/providers/oauth') {
return { providers: [provider('fresh')] }
}
throw new Error(`unexpected api path: ${path}`)
})
installApiMock(api)
$desktopOnboarding.set(baseState({ providers: [provider('cached')] }))
const ready = await refreshOnboarding(onboardingContext(runtimeMismatchGateway()))
expect(ready).toBe(false)
expect(api).not.toHaveBeenCalled()
expect($desktopOnboarding.get().providers?.map(p => p.id)).toEqual(['cached'])
})
it('deduplicates concurrent provider refresh calls', async () => {
let resolveProviders!: (value: { providers: OAuthProvider[] }) => void
const providersPromise = new Promise<{ providers: OAuthProvider[] }>(resolve => {
resolveProviders = value => {
resolve(value)
}
})
const api = vi.fn(async ({ path }: { path: string }) => {
if (path === '/api/providers/oauth') {
return providersPromise
}
throw new Error(`unexpected api path: ${path}`)
})
installApiMock(api)
$desktopOnboarding.set(baseState({ requested: true }))
const first = refreshOnboarding(onboardingContext(runtimeMismatchGateway()))
const second = refreshOnboarding(onboardingContext(runtimeMismatchGateway()))
await vi.waitFor(() => expect(api).toHaveBeenCalledTimes(1))
resolveProviders({ providers: [provider('shared')] })
await Promise.all([first, second])
expect($desktopOnboarding.get().providers?.map(p => p.id)).toEqual(['shared'])
})
})
+70 -39
View File
@@ -8,6 +8,7 @@ import {
startOAuthLogin,
submitOAuthCode
} from '@/hermes'
import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { notify, notifyError } from '@/store/notifications'
import type { OAuthProvider, OAuthStartResponse } from '@/types/hermes'
@@ -46,6 +47,7 @@ export interface OnboardingContext {
const CONFIGURED_CACHE_KEY = 'hermes-desktop-onboarded-v1'
const POLL_MS = 2000
const COPY_FLASH_MS = 1500
const DEFAULT_ONBOARDING_REASON = 'No inference provider is configured.'
function readCachedConfigured(): boolean | null {
if (typeof window === 'undefined') {
@@ -87,6 +89,7 @@ const INITIAL: DesktopOnboardingState = {
export const $desktopOnboarding = atom<DesktopOnboardingState>(INITIAL)
let pollTimer: number | null = null
let providersRefreshPromise: null | Promise<void> = null
const errMessage = (e: unknown) => (e instanceof Error ? e.message : String(e))
@@ -104,42 +107,61 @@ function clearPoll() {
}
}
async function safeReq<T>(ctx: OnboardingContext, method: string, fallback: T): Promise<T> {
try {
return await ctx.requestGateway<T>(method)
} catch {
return fallback
}
}
async function checkRuntime(ctx: OnboardingContext) {
const [status, runtime] = await Promise.all([
safeReq<{ provider_configured?: boolean }>(ctx, 'setup.status', {}),
safeReq<{ error?: string; ok?: boolean }>(ctx, 'setup.runtime_check', { ok: false })
])
return runtime.ok !== undefined ? Boolean(runtime.ok) : Boolean(status.provider_configured)
async function checkRuntime(ctx: OnboardingContext): Promise<RuntimeReadinessResult> {
return evaluateRuntimeReadiness(ctx.requestGateway, {
defaultReason: DEFAULT_ONBOARDING_REASON,
unknownReady: false
})
}
function notifyReady(provider: string) {
notify({ kind: 'success', title: 'Hermes is ready', message: `${provider} connected.` })
}
async function reloadAndConnect(ctx: OnboardingContext, providerName: string, onFail: () => void) {
async function reloadAndConnect(ctx: OnboardingContext, providerName: string, onFail: (reason: null | string) => void) {
await ctx.requestGateway('reload.env').catch(() => undefined)
const ok = await checkRuntime(ctx)
const runtime = await checkRuntime(ctx)
if (ok) {
if (runtime.ready) {
notifyReady(providerName)
completeDesktopOnboarding()
ctx.onCompleted?.()
} else {
onFail()
onFail(runtime.reason)
}
}
export function requestDesktopOnboarding(reason = 'No inference provider is configured.') {
patch({ reason, requested: true })
function providerResolutionFailure(reason: null | string) {
const detail = reason?.trim()
return detail
? `Connected, but Hermes still cannot resolve a usable provider. ${detail}`
: 'Connected, but Hermes still cannot resolve a usable provider.'
}
async function refreshProviders() {
if (providersRefreshPromise) {
await providersRefreshPromise
return
}
providersRefreshPromise = (async () => {
try {
const { providers } = await listOAuthProviders()
patch({ mode: providers.length > 0 ? 'oauth' : 'apikey', providers })
} catch {
patch({ mode: 'apikey', providers: [] })
} finally {
providersRefreshPromise = null
}
})()
await providersRefreshPromise
}
export function requestDesktopOnboarding(reason = DEFAULT_ONBOARDING_REASON) {
patch({ reason: reason.trim() || DEFAULT_ONBOARDING_REASON, requested: true })
}
export function completeDesktopOnboarding() {
@@ -160,26 +182,26 @@ export function setOnboardingMode(mode: OnboardingMode) {
}
export async function refreshOnboarding(ctx: OnboardingContext) {
if (await checkRuntime(ctx)) {
const runtime = await checkRuntime(ctx)
if (runtime.ready) {
completeDesktopOnboarding()
ctx.onCompleted?.()
return true
}
writeCachedConfigured(false)
patch({ configured: false })
const state = $desktopOnboarding.get()
const reason = runtime.reason || state.reason || DEFAULT_ONBOARDING_REASON
if ($desktopOnboarding.get().providers !== null) {
writeCachedConfigured(false)
patch({ configured: false, reason })
if (state.providers !== null && !state.requested) {
return false
}
try {
const { providers } = await listOAuthProviders()
patch({ providers, mode: providers.length > 0 ? 'oauth' : 'apikey' })
} catch {
patch({ providers: [], mode: 'apikey' })
}
await refreshProviders()
return false
}
@@ -219,11 +241,11 @@ async function pollDevice(provider: OAuthProvider, start: DeviceStart, ctx: Onbo
if (status === 'approved') {
clearPoll()
setFlow({ status: 'success', provider })
await reloadAndConnect(ctx, provider.name, () =>
await reloadAndConnect(ctx, provider.name, reason =>
setFlow({
status: 'error',
provider,
message: 'Connected, but Hermes still cannot resolve a usable provider.'
message: providerResolutionFailure(reason)
})
)
} else if (status !== 'pending') {
@@ -259,11 +281,11 @@ export async function submitOnboardingCode(ctx: OnboardingContext) {
if (resp.ok && resp.status === 'approved') {
setFlow({ status: 'success', provider })
await reloadAndConnect(ctx, provider.name, () =>
await reloadAndConnect(ctx, provider.name, reason =>
setFlow({
status: 'error',
provider,
message: 'Connected, but Hermes still cannot resolve a usable provider.'
message: providerResolutionFailure(reason)
})
)
} else {
@@ -338,11 +360,13 @@ export async function recheckExternalSignin(ctx: OnboardingContext) {
}
const { provider } = flow
await reloadAndConnect(ctx, provider.name, () =>
await reloadAndConnect(ctx, provider.name, reason =>
setFlow({
status: 'error',
provider,
message: `Hermes still cannot reach ${provider.name}. Run \`${provider.cli_command}\` in a terminal first.`
message:
reason?.trim() ||
`Hermes still cannot reach ${provider.name}. Run \`${provider.cli_command}\` in a terminal first.`
})
)
}
@@ -357,12 +381,19 @@ export async function saveOnboardingApiKey(envKey: string, value: string, label:
try {
await setEnvVar(envKey, trimmed)
let stillFailing = false
await reloadAndConnect(ctx, label, () => {
let runtimeFailure: null | string = null
await reloadAndConnect(ctx, label, reason => {
stillFailing = true
runtimeFailure = reason
})
if (stillFailing) {
return { ok: false, message: `Saved, but Hermes still cannot reach ${label}. Double-check the value.` }
const failureDetail = (runtimeFailure ?? '').trim()
return {
ok: false,
message: failureDetail || `Saved, but Hermes still cannot reach ${label}. Double-check the value.`
}
}
return { ok: true }
+13 -1
View File
@@ -1,4 +1,4 @@
import { atom } from 'nanostores'
import { atom, computed, type ReadableAtom } from 'nanostores'
import { persistBoolean, storedBoolean } from '@/lib/storage'
@@ -14,6 +14,7 @@ export const $toolViewMode = atom<ToolViewMode>(
storedBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, false) ? 'technical' : 'product'
)
export const $toolDisclosureStates = atom<ToolDisclosureStates>(loadToolDisclosureStates())
const disclosureOpenCache = new Map<string, ReadableAtom<boolean | undefined>>()
$toolViewMode.subscribe(mode => persistBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, mode === 'technical'))
$toolDisclosureStates.subscribe(persistToolDisclosureStates)
@@ -22,6 +23,17 @@ export function setToolViewMode(mode: ToolViewMode) {
$toolViewMode.set(mode)
}
export function $toolDisclosureOpen(id: string): ReadableAtom<boolean | undefined> {
let cached = disclosureOpenCache.get(id)
if (!cached) {
cached = computed($toolDisclosureStates, states => states[id])
disclosureOpenCache.set(id, cached)
}
return cached
}
function loadToolDisclosureStates(): ToolDisclosureStates {
if (typeof window === 'undefined') {
return {}