mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
fmt(js): npm run fix on merge (#65229)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
5222d24f35
commit
75f45a0692
89 changed files with 603 additions and 380 deletions
|
|
@ -1,10 +1,11 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect } from 'react'
|
||||
import { $route, $bootstrap, initialize } from './store'
|
||||
import Welcome from './routes/welcome'
|
||||
|
||||
import Failure from './routes/failure'
|
||||
import Progress from './routes/progress'
|
||||
import Success from './routes/success'
|
||||
import Failure from './routes/failure'
|
||||
import Welcome from './routes/welcome'
|
||||
import { $bootstrap, $route, initialize } from './store'
|
||||
|
||||
/*
|
||||
* App shell — Hermes Setup.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import './styles.css'
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import App from './app.tsx'
|
||||
import './styles.css'
|
||||
import { watchTheme } from './theme'
|
||||
|
||||
// Follow the OS light/dark appearance. theme.ts paints the first frame on
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import { type CSSProperties } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { FileText, RefreshCw } from 'lucide-react'
|
||||
import { type CSSProperties } from 'react'
|
||||
|
||||
import { Button } from '../components/button'
|
||||
import {
|
||||
$logPath,
|
||||
$mode,
|
||||
type BootstrapStateModel,
|
||||
openLogDir,
|
||||
startInstall,
|
||||
startUpdate,
|
||||
type BootstrapStateModel
|
||||
startUpdate
|
||||
} from '../store'
|
||||
import { RefreshCw, FileText } from 'lucide-react'
|
||||
|
||||
interface FailureProps {
|
||||
bootstrap: BootstrapStateModel
|
||||
|
|
@ -55,11 +56,11 @@ export default function Failure({ bootstrap }: FailureProps) {
|
|||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={() => void (isUpdate ? startUpdate() : startInstall())} className="gap-1.5">
|
||||
<Button className="gap-1.5" onClick={() => void (isUpdate ? startUpdate() : startInstall())}>
|
||||
<RefreshCw />
|
||||
{isUpdate ? 'Retry update' : 'Retry install'}
|
||||
</Button>
|
||||
<Button variant="text" onClick={() => void openLogDir()} className="gap-1.5">
|
||||
<Button className="gap-1.5" onClick={() => void openLogDir()} variant="text">
|
||||
<FileText />
|
||||
Open logs
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import clsx from 'clsx'
|
||||
import { Check, ChevronRight, FileText, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { BrandMark } from '../components/brand-mark'
|
||||
import { Button } from '../components/button'
|
||||
import { Loader } from '../components/loader'
|
||||
import {
|
||||
cancelInstall,
|
||||
$mode,
|
||||
$progress,
|
||||
type BootstrapStateModel,
|
||||
cancelInstall,
|
||||
type StageState
|
||||
} from '../store'
|
||||
import { Check, X, ChevronRight, FileText } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { BrandMark } from '../components/brand-mark'
|
||||
import { Loader } from '../components/loader'
|
||||
|
||||
interface ProgressProps {
|
||||
bootstrap: BootstrapStateModel
|
||||
|
|
@ -42,15 +43,19 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
|||
if (bootstrap.status !== 'running') {
|
||||
return
|
||||
}
|
||||
|
||||
const id = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [bootstrap.status])
|
||||
|
||||
const isUpdate = mode === 'update'
|
||||
const title = bootstrap.status === 'completed' ? 'Done' : isUpdate ? 'Updating Hermes' : 'Setting up Hermes Agent'
|
||||
|
||||
const description = isUpdate
|
||||
? 'Hermes is updating to the latest version — this only takes a moment.'
|
||||
: 'This is a one-time setup. The Hermes installer is downloading dependencies and configuring your machine. Subsequent launches will skip this step.'
|
||||
|
||||
const pct = Math.round(progress.fraction * 100)
|
||||
|
||||
return (
|
||||
|
|
@ -90,22 +95,25 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
|||
<ol className="space-y-0.5">
|
||||
{bootstrap.stageOrder.map((name) => {
|
||||
const rec = bootstrap.stages[name]
|
||||
if (!rec) return null
|
||||
|
||||
if (!rec) {return null}
|
||||
|
||||
const meta =
|
||||
rec.state === 'running' && rec.startedAt != null
|
||||
? formatElapsed(now - rec.startedAt)
|
||||
: rec.durationMs != null && rec.state !== 'failed'
|
||||
? formatDuration(rec.durationMs)
|
||||
: null
|
||||
|
||||
return (
|
||||
<li
|
||||
key={name}
|
||||
className={clsx(
|
||||
'flex items-center gap-2.5 px-3 py-1.5 text-sm',
|
||||
rec.state === 'running'
|
||||
? 'font-medium text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
key={name}
|
||||
>
|
||||
{rec.state === 'running' && <Loader className="-ml-2 size-6 shrink-0" />}
|
||||
<span className="flex-1 truncate">{rec.info.title}</span>
|
||||
|
|
@ -126,11 +134,11 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
|||
<div className="flex-1 overflow-y-auto px-3 py-2 font-mono text-[10.5px] leading-relaxed">
|
||||
{bootstrap.logs.map((entry, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={clsx(
|
||||
'whitespace-pre-wrap',
|
||||
entry.stream === 'stderr' ? 'text-foreground/45' : 'text-foreground/70'
|
||||
)}
|
||||
key={idx}
|
||||
>
|
||||
{entry.line}
|
||||
</div>
|
||||
|
|
@ -143,17 +151,17 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
|||
|
||||
<div className="flex shrink-0 items-center justify-between border-t border-(--stroke-nous) px-6 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLogs((v) => !v)}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => setShowLogs((v) => !v)}
|
||||
type="button"
|
||||
>
|
||||
<FileText size={14} />
|
||||
{showLogs ? 'Hide details' : 'Show details'}
|
||||
<ChevronRight size={12} className={clsx('transition-transform', showLogs && 'rotate-90')} />
|
||||
<ChevronRight className={clsx('transition-transform', showLogs && 'rotate-90')} size={12} />
|
||||
</button>
|
||||
|
||||
{bootstrap.status === 'running' && (
|
||||
<Button variant="outline" size="sm" onClick={() => void cancelInstall()}>
|
||||
<Button onClick={() => void cancelInstall()} size="sm" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -167,29 +175,36 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
|||
// spinner on the left; pending stays icon-less.
|
||||
function StateIcon({ state }: { state: StageState | null }) {
|
||||
if (state === 'succeeded') {
|
||||
return <Check size={13} className="shrink-0 text-muted-foreground" />
|
||||
return <Check className="shrink-0 text-muted-foreground" size={13} />
|
||||
}
|
||||
|
||||
if (state === 'skipped') {
|
||||
return <Check size={13} className="shrink-0 text-muted-foreground/50" />
|
||||
return <Check className="shrink-0 text-muted-foreground/50" size={13} />
|
||||
}
|
||||
|
||||
if (state === 'failed') {
|
||||
return <X size={13} className="shrink-0 text-destructive" />
|
||||
return <X className="shrink-0 text-destructive" size={13} />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
||||
if (ms < 1000) {return `${ms}ms`}
|
||||
|
||||
if (ms < 60000) {return `${(ms / 1000).toFixed(1)}s`}
|
||||
const m = Math.floor(ms / 60000)
|
||||
const s = Math.round((ms % 60000) / 1000)
|
||||
|
||||
return `${m}m ${s}s`
|
||||
}
|
||||
|
||||
// Live elapsed for a running stage: bare seconds under a minute, then m:ss.
|
||||
function formatElapsed(ms: number): string {
|
||||
const s = Math.max(0, Math.floor(ms / 1000))
|
||||
if (s < 60) return `${s}s`
|
||||
|
||||
if (s < 60) {return `${s}s`}
|
||||
const m = Math.floor(s / 60)
|
||||
|
||||
return `${m}:${String(s - m * 60).padStart(2, '0')}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { AlertCircle } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { type CSSProperties } from 'react'
|
||||
|
||||
import { HackeryButton } from '../components/hackery-button'
|
||||
import { launchHermesDesktop } from '../store'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
|
||||
/*
|
||||
* Success screen. HERMES AGENT wordmark stays as the visual anchor
|
||||
|
|
@ -22,6 +23,7 @@ export default function Success() {
|
|||
async function handleLaunch() {
|
||||
setError(null)
|
||||
setLaunching(true)
|
||||
|
||||
try {
|
||||
await launchHermesDesktop()
|
||||
// On success the installer exits — control never returns here.
|
||||
|
|
@ -65,8 +67,8 @@ export default function Success() {
|
|||
/>
|
||||
|
||||
{error && (
|
||||
<div role="alert" className="flex max-w-2xl items-start gap-2 text-sm">
|
||||
<AlertCircle size={16} className="mt-0.5 shrink-0 text-destructive" />
|
||||
<div className="flex max-w-2xl items-start gap-2 text-sm" role="alert">
|
||||
<AlertCircle className="mt-0.5 shrink-0 text-destructive" size={16} />
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-destructive">Couldn’t launch the desktop app</div>
|
||||
<div className="mt-0.5 text-muted-foreground">{error}</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { type CSSProperties } from 'react'
|
||||
|
||||
import { HackeryButton } from '../components/hackery-button'
|
||||
import { startInstall } from '../store'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { atom, computed } from 'nanostores'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
/*
|
||||
* Bootstrap state store — single source of truth for installer screens.
|
||||
|
|
@ -79,12 +79,16 @@ export const $hermesHome = atom<string | null>(null)
|
|||
|
||||
export const $progress = computed($bootstrap, (b) => {
|
||||
const total = b.stageOrder.length
|
||||
if (total === 0) return { done: 0, total: 0, fraction: 0 }
|
||||
|
||||
if (total === 0) {return { done: 0, total: 0, fraction: 0 }}
|
||||
let done = 0
|
||||
|
||||
for (const name of b.stageOrder) {
|
||||
const s = b.stages[name]?.state
|
||||
if (s === 'succeeded' || s === 'skipped' || s === 'failed') done += 1
|
||||
|
||||
if (s === 'succeeded' || s === 'skipped' || s === 'failed') {done += 1}
|
||||
}
|
||||
|
||||
return { done, total, fraction: done / total }
|
||||
})
|
||||
|
||||
|
|
@ -99,7 +103,9 @@ function withStageState(
|
|||
error?: string
|
||||
): BootstrapStateModel {
|
||||
const existing = cur.stages[name]
|
||||
if (!existing) return cur
|
||||
|
||||
if (!existing) {return cur}
|
||||
|
||||
return {
|
||||
...cur,
|
||||
stages: {
|
||||
|
|
@ -163,18 +169,21 @@ type BootstrapEvent =
|
|||
let unlisten: UnlistenFn | null = null
|
||||
|
||||
export async function initialize(): Promise<void> {
|
||||
if (unlisten) return
|
||||
if (unlisten) {return}
|
||||
|
||||
// Dev-only isolated preview (see runFakeBoot): drive the screens in a plain
|
||||
// browser, no Tauri backend, no real install.
|
||||
const fake = fakeMode()
|
||||
|
||||
if (fake) {
|
||||
unlisten = () => {}
|
||||
$logPath.set('~/.hermes/logs/bootstrap-installer.log')
|
||||
$hermesHome.set('~/.hermes')
|
||||
$mode.set(fake === 'update' ? 'update' : 'install')
|
||||
|
||||
// Update auto-runs (it's a hand-off); install/failure wait for the welcome click.
|
||||
if (fake === 'update') void runFakeBoot('update')
|
||||
if (fake === 'update') {void runFakeBoot('update')}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -185,6 +194,7 @@ export async function initialize(): Promise<void> {
|
|||
invoke<string>('get_hermes_home'),
|
||||
invoke<AppMode>('get_mode')
|
||||
])
|
||||
|
||||
$logPath.set(logPath)
|
||||
$hermesHome.set(hermesHome)
|
||||
$mode.set(mode)
|
||||
|
|
@ -195,14 +205,17 @@ export async function initialize(): Promise<void> {
|
|||
unlisten = await listen<BootstrapEvent>('bootstrap', (event) => {
|
||||
const payload = event.payload
|
||||
const cur = $bootstrap.get()
|
||||
|
||||
switch (payload.type) {
|
||||
case 'manifest': {
|
||||
const stages: Record<string, StageRecord> = {}
|
||||
const order: string[] = []
|
||||
|
||||
for (const s of payload.stages) {
|
||||
stages[s.name] = { info: s, state: null }
|
||||
order.push(s.name)
|
||||
}
|
||||
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
status: 'running',
|
||||
|
|
@ -215,26 +228,34 @@ export async function initialize(): Promise<void> {
|
|||
logs: []
|
||||
})
|
||||
$route.set('progress')
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'stage': {
|
||||
if (!cur.stages[payload.name]) {
|
||||
console.warn('stage event for unknown stage', payload.name)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
$bootstrap.set(
|
||||
withStageState(cur, payload.name, payload.state, payload.durationMs, payload.error)
|
||||
)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'log': {
|
||||
const logs = [...cur.logs, { stage: payload.stage, line: payload.line, stream: payload.stream }]
|
||||
// Keep the rolling buffer bounded so the UI doesn't get OOM'd
|
||||
// during a long install (playwright chromium download is ~10k lines).
|
||||
const trimmed = logs.length > 2000 ? logs.slice(-2000) : logs
|
||||
$bootstrap.set({ ...cur, logs: trimmed })
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'complete':
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
|
|
@ -242,6 +263,7 @@ export async function initialize(): Promise<void> {
|
|||
installRoot: payload.installRoot,
|
||||
currentStage: null
|
||||
})
|
||||
|
||||
// Install: show the "launch Hermes" success screen. Update: this is a
|
||||
// hand-off — the installer relaunches the desktop and exits within a
|
||||
// few hundred ms, so routing to success just flashes that screen
|
||||
|
|
@ -249,7 +271,9 @@ export async function initialize(): Promise<void> {
|
|||
if ($mode.get() !== 'update') {
|
||||
$route.set('success')
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case 'failed':
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
|
|
@ -258,6 +282,7 @@ export async function initialize(): Promise<void> {
|
|||
currentStage: null
|
||||
})
|
||||
$route.set('failure')
|
||||
|
||||
break
|
||||
}
|
||||
})
|
||||
|
|
@ -276,10 +301,13 @@ export async function initialize(): Promise<void> {
|
|||
|
||||
export async function startInstall(opts?: { branch?: string }): Promise<void> {
|
||||
const fake = fakeMode()
|
||||
|
||||
if (fake) {
|
||||
void runFakeBoot(fake === 'failure' ? 'failure' : 'install')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Reset before kicking off so a retry from the failure screen clears
|
||||
// the previous run's state.
|
||||
$bootstrap.set(INITIAL)
|
||||
|
|
@ -297,8 +325,10 @@ export async function startInstall(opts?: { branch?: string }): Promise<void> {
|
|||
export async function startUpdate(): Promise<void> {
|
||||
if (fakeMode()) {
|
||||
void runFakeBoot('update')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Update is driven by the desktop handing off (Hermes-Setup.exe --update);
|
||||
// there's no welcome click. Reset + jump straight to progress, then let the
|
||||
// Rust side stream the synthetic update manifest.
|
||||
|
|
@ -310,20 +340,23 @@ export async function startUpdate(): Promise<void> {
|
|||
export async function cancelInstall(): Promise<void> {
|
||||
if (fakeMode()) {
|
||||
fakeCancelled = true
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await invoke('cancel_bootstrap')
|
||||
}
|
||||
|
||||
export async function launchHermesDesktop(): Promise<void> {
|
||||
if (fakeMode()) throw new Error('Preview mode — launching is disabled.')
|
||||
if (fakeMode()) {throw new Error('Preview mode — launching is disabled.')}
|
||||
const installRoot = $bootstrap.get().installRoot
|
||||
if (!installRoot) throw new Error('no install root')
|
||||
|
||||
if (!installRoot) {throw new Error('no install root')}
|
||||
await invoke('launch_hermes_desktop', { installRoot })
|
||||
}
|
||||
|
||||
export async function openLogDir(): Promise<void> {
|
||||
if (fakeMode()) return
|
||||
if (fakeMode()) {return}
|
||||
await invoke('open_log_dir')
|
||||
}
|
||||
|
||||
|
|
@ -341,8 +374,9 @@ export async function openLogDir(): Promise<void> {
|
|||
type FakeMode = 'install' | 'update' | 'failure'
|
||||
|
||||
function fakeMode(): FakeMode | null {
|
||||
if (!import.meta.env.DEV || typeof window === 'undefined') return null
|
||||
if (!import.meta.env.DEV || typeof window === 'undefined') {return null}
|
||||
const v = new URLSearchParams(window.location.search).get('fake')
|
||||
|
||||
return v === 'install' || v === 'update' || v === 'failure' ? v : null
|
||||
}
|
||||
|
||||
|
|
@ -383,15 +417,18 @@ const fakeFail = (error: string) =>
|
|||
$bootstrap.set({ ...$bootstrap.get(), status: 'failed', error, currentStage: null })
|
||||
|
||||
async function runFakeBoot(kind: FakeMode): Promise<void> {
|
||||
if (fakeRunning) return
|
||||
if (fakeRunning) {return}
|
||||
fakeRunning = true
|
||||
fakeCancelled = false
|
||||
|
||||
try {
|
||||
const stages = kind === 'update' ? FAKE_UPDATE_STAGES : FAKE_INSTALL_STAGES
|
||||
|
||||
const cancelled = () => {
|
||||
if (!fakeCancelled) return false
|
||||
if (!fakeCancelled) {return false}
|
||||
fakeFail(kind === 'update' ? 'Update cancelled.' : 'Install cancelled.')
|
||||
$route.set('failure')
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -412,14 +449,16 @@ async function runFakeBoot(kind: FakeMode): Promise<void> {
|
|||
const failAt = kind === 'failure' ? stages[Math.floor(stages.length / 2)]?.name : null
|
||||
|
||||
for (const s of stages) {
|
||||
if (cancelled()) return
|
||||
if (cancelled()) {return}
|
||||
fakeStage(s.name, 'running')
|
||||
|
||||
const durationMs = 700 + Math.floor(Math.random() * 2200)
|
||||
const lines = Math.max(2, Math.round(durationMs / 450))
|
||||
|
||||
for (let l = 0; l < lines; l++) {
|
||||
await sleep(durationMs / lines)
|
||||
if (cancelled()) return
|
||||
|
||||
if (cancelled()) {return}
|
||||
fakeLog(s.name, `[${s.name}] ${s.title.toLowerCase()} — step ${l + 1}/${lines}…`)
|
||||
}
|
||||
|
||||
|
|
@ -427,15 +466,18 @@ async function runFakeBoot(kind: FakeMode): Promise<void> {
|
|||
fakeStage(s.name, 'failed', durationMs, 'Simulated failure for preview.')
|
||||
fakeFail('Simulated failure for preview (fake boot).')
|
||||
$route.set('failure')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
fakeStage(s.name, 'succeeded', durationMs)
|
||||
}
|
||||
|
||||
$bootstrap.set({ ...$bootstrap.get(), status: 'completed', currentStage: null })
|
||||
|
||||
// Install lands on success; update stays on progress (the real updater
|
||||
// relaunches the desktop and exits from there).
|
||||
if (kind !== 'update') $route.set('success')
|
||||
if (kind !== 'update') {$route.set('success')}
|
||||
} finally {
|
||||
fakeRunning = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,16 +87,7 @@ test('fresh bootstrap args include the packaged commit pin', () => {
|
|||
activeRoot: '/tmp/hermes-agent',
|
||||
hermesHome: '/tmp/hermes'
|
||||
}),
|
||||
[
|
||||
'--dir',
|
||||
'/tmp/hermes-agent',
|
||||
'--hermes-home',
|
||||
'/tmp/hermes',
|
||||
'--branch',
|
||||
'main',
|
||||
'--commit',
|
||||
installStamp.commit
|
||||
]
|
||||
['--dir', '/tmp/hermes-agent', '--hermes-home', '/tmp/hermes', '--branch', 'main', '--commit', installStamp.commit]
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -573,15 +573,7 @@ function buildPosixPinArgs({ installStamp, activeRoot, hermesHome, pinCommit = t
|
|||
return args
|
||||
}
|
||||
|
||||
async function fetchManifest({
|
||||
scriptPath,
|
||||
installerKind,
|
||||
emit,
|
||||
hermesHome,
|
||||
activeRoot,
|
||||
installStamp,
|
||||
pinCommit
|
||||
}) {
|
||||
async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, activeRoot, installStamp, pinCommit }) {
|
||||
const isPosix = installerKind === 'posix'
|
||||
|
||||
const args = isPosix
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ test('profileRemoteOverride treats a cloud entry as a remote override', () => {
|
|||
coder: { mode: 'cloud', url: 'https://agent-1.agents.nousresearch.com', authMode: 'oauth' }
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(profileRemoteOverride(config, 'coder'), {
|
||||
url: 'https://agent-1.agents.nousresearch.com',
|
||||
authMode: 'oauth',
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/
|
|||
|
||||
function desktopPkg(): Record<string, unknown> {
|
||||
assert.ok(fs.existsSync(DESKTOP_PKG), `missing ${DESKTOP_PKG}`)
|
||||
|
||||
return JSON.parse(fs.readFileSync(DESKTOP_PKG, 'utf-8'))
|
||||
}
|
||||
|
||||
|
|
@ -46,8 +47,12 @@ function electronSpec(pkg: Record<string, unknown>): string {
|
|||
for (const section of ['dependencies', 'devDependencies'] as const) {
|
||||
const deps = (pkg[section] ?? {}) as Record<string, string>
|
||||
const spec = deps['electron']
|
||||
if (spec) return spec
|
||||
|
||||
if (spec) {
|
||||
return spec
|
||||
}
|
||||
}
|
||||
|
||||
assert.fail('electron is not listed in apps/desktop dependencies')
|
||||
}
|
||||
|
||||
|
|
@ -78,15 +83,20 @@ test('electron dependency matches build.electronVersion', () => {
|
|||
})
|
||||
|
||||
test('lockfile resolves the pinned electron', () => {
|
||||
if (!fs.existsSync(ROOT_LOCK)) return // skip if lockfile not present
|
||||
if (!fs.existsSync(ROOT_LOCK)) {
|
||||
return
|
||||
} // skip if lockfile not present
|
||||
const spec = electronSpec(desktopPkg())
|
||||
const lock = JSON.parse(fs.readFileSync(ROOT_LOCK, 'utf-8'))
|
||||
const packages = (lock.packages ?? {}) as Record<string, { version?: string }>
|
||||
|
||||
const resolved = Object.entries(packages)
|
||||
.filter(([key]) => key.endsWith('node_modules/electron'))
|
||||
.map(([, meta]) => meta.version)
|
||||
.filter((v): v is string => !!v)
|
||||
|
||||
assert.ok(resolved.length > 0, 'no electron entry found in package-lock.json')
|
||||
|
||||
for (const v of resolved) {
|
||||
assert.equal(
|
||||
v,
|
||||
|
|
|
|||
|
|
@ -227,7 +227,10 @@ test('listBaseBranches: lists local branches and flags the default', async () =>
|
|||
|
||||
assert.deepEqual(names, [trunk, 'feature'].sort())
|
||||
// No remote → all local.
|
||||
assert.equal(branches.every(b => !b.isRemote), true)
|
||||
assert.equal(
|
||||
branches.every(b => !b.isRemote),
|
||||
true
|
||||
)
|
||||
// The trunk is flagged as the default.
|
||||
assert.equal(branches.find(b => b.name === trunk).isDefault, true)
|
||||
assert.equal(branches.find(b => b.name === 'feature').isDefault, false)
|
||||
|
|
@ -254,7 +257,11 @@ test('addWorktree: base param branches off a specified local branch', async () =
|
|||
await ensureGitRepo('git', dir)
|
||||
execFileSync('git', ['branch', 'staging'], { cwd: dir })
|
||||
|
||||
const result = await addWorktree(dir, { base: 'staging', branch: 'new-from-staging', name: 'new-from-staging' }, 'git')
|
||||
const result = await addWorktree(
|
||||
dir,
|
||||
{ base: 'staging', branch: 'new-from-staging', name: 'new-from-staging' },
|
||||
'git'
|
||||
)
|
||||
|
||||
assert.equal(result.branch, 'new-from-staging')
|
||||
assert.equal(git('-C', result.path, 'merge-base', 'HEAD', 'staging').length > 0, true)
|
||||
|
|
@ -274,12 +281,27 @@ test('addWorktree: base origin/main does not set up upstream tracking', async ()
|
|||
// Seed the remote with a commit on main. Inline identity so it works
|
||||
// on CI runners with no global git config.
|
||||
execFileSync('git', ['init', '-b', 'main', remoteDir])
|
||||
execFileSync('git', ['-C', remoteDir, '-c', 'user.email=hermes@localhost', '-c', 'user.name=Hermes', 'commit', '--allow-empty', '-m', 'root'])
|
||||
execFileSync('git', [
|
||||
'-C',
|
||||
remoteDir,
|
||||
'-c',
|
||||
'user.email=hermes@localhost',
|
||||
'-c',
|
||||
'user.name=Hermes',
|
||||
'commit',
|
||||
'--allow-empty',
|
||||
'-m',
|
||||
'root'
|
||||
])
|
||||
|
||||
// Clone so origin/main exists as a remote-tracking ref.
|
||||
execFileSync('git', ['clone', remoteDir, cloneDir])
|
||||
|
||||
const result = await addWorktree(cloneDir, { base: 'origin/main', branch: 'feature-branch', name: 'feature-branch' }, 'git')
|
||||
const result = await addWorktree(
|
||||
cloneDir,
|
||||
{ base: 'origin/main', branch: 'feature-branch', name: 'feature-branch' },
|
||||
'git'
|
||||
)
|
||||
|
||||
assert.equal(result.branch, 'feature-branch')
|
||||
|
||||
|
|
|
|||
|
|
@ -378,11 +378,21 @@ async function listBaseBranches(repoPath, gitBin) {
|
|||
try {
|
||||
const out = await runGit(
|
||||
gitBin,
|
||||
['for-each-ref', '--format=%(refname:short)\t%(committerdate:iso)', '--sort=-committerdate', 'refs/heads', 'refs/remotes'],
|
||||
[
|
||||
'for-each-ref',
|
||||
'--format=%(refname:short)\t%(committerdate:iso)',
|
||||
'--sort=-committerdate',
|
||||
'refs/heads',
|
||||
'refs/remotes'
|
||||
],
|
||||
resolved
|
||||
)
|
||||
|
||||
const remoteDefault = await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], resolved)
|
||||
const remoteDefault = await gitLine(
|
||||
gitBin,
|
||||
['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'],
|
||||
resolved
|
||||
)
|
||||
const localDefault = await defaultBranch(gitBin, resolved)
|
||||
|
||||
return out
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
import { execFile, execFileSync, spawn } from 'node:child_process'
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
|
|
@ -65,7 +64,6 @@ import {
|
|||
} from './desktop-uninstall'
|
||||
import { installEmbedReferer } from './embed-referer'
|
||||
import { readDirForIpc } from './fs-read-dir'
|
||||
import { resolvePickerDefaultPath } from './wsl-path-bridge'
|
||||
import { probeGatewayWebSocket } from './gateway-ws-probe'
|
||||
import { scanGitRepos } from './git-repo-scan'
|
||||
import {
|
||||
|
|
@ -84,7 +82,14 @@ import {
|
|||
reviewUnstage
|
||||
} from './git-review-ops'
|
||||
import { gitRootForIpc } from './git-root'
|
||||
import { addWorktree, listBaseBranches, listBranches, listWorktrees, removeWorktree, switchBranch } from './git-worktree-ops'
|
||||
import {
|
||||
addWorktree,
|
||||
listBaseBranches,
|
||||
listBranches,
|
||||
listWorktrees,
|
||||
removeWorktree,
|
||||
switchBranch
|
||||
} from './git-worktree-ops'
|
||||
import {
|
||||
DATA_URL_READ_MAX_BYTES,
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
|
|
@ -127,10 +132,16 @@ import {
|
|||
MIN_WIDTH as WINDOW_MIN_WIDTH
|
||||
} from './window-state'
|
||||
import { hiddenWindowsChildOptions } from './windows-child-options'
|
||||
import { buildPathExtCandidates, chooseUpdaterArgs, getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path'
|
||||
import {
|
||||
buildPathExtCandidates,
|
||||
chooseUpdaterArgs,
|
||||
getVenvSitePackagesEntries,
|
||||
resolveVenvHermesCommand
|
||||
} from './windows-hermes-path'
|
||||
import { readWindowsUserEnvVar } from './windows-user-env'
|
||||
import { isPackagedInstallPath as isPackagedInstallPathUnderRoots } from './workspace-cwd'
|
||||
import { readWslWindowsClipboardImage } from './wsl-clipboard-image'
|
||||
import { resolvePickerDefaultPath } from './wsl-path-bridge'
|
||||
|
||||
const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR
|
||||
|
||||
|
|
@ -5485,6 +5496,7 @@ function openPortalLoginWindow() {
|
|||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
|
||||
if (pollTimer) {
|
||||
|
|
@ -5571,6 +5583,7 @@ async function discoverCloudAgents(org?: string) {
|
|||
const err = new Error(
|
||||
'You are not signed in to Hermes Cloud. Open Settings → Gateway, choose Hermes Cloud, and sign in.'
|
||||
) as any
|
||||
|
||||
err.needsCloudLogin = true
|
||||
throw err
|
||||
}
|
||||
|
|
@ -5939,6 +5952,7 @@ function buildRemoteBlock(remoteUrl, authMode, token, org?: string) {
|
|||
authMode,
|
||||
token
|
||||
}
|
||||
|
||||
const orgValue = typeof org === 'string' ? org.trim() : ''
|
||||
|
||||
if (orgValue) {
|
||||
|
|
@ -6934,6 +6948,7 @@ async function startHermes() {
|
|||
function wireCommonWindowHandlers(win, { zoom = true }: { zoom?: boolean } = {}) {
|
||||
installPreviewShortcut(win)
|
||||
installDevToolsShortcut(win)
|
||||
|
||||
if (zoom) {
|
||||
installZoomShortcuts(win)
|
||||
// Re-apply persisted zoom on show/restore (Windows drops webContents zoom on
|
||||
|
|
@ -6941,6 +6956,7 @@ function wireCommonWindowHandlers(win, { zoom = true }: { zoom?: boolean } = {})
|
|||
installZoomReassertOnWindowEvents(win, () => restorePersistedZoomLevel(win))
|
||||
win.webContents.once('did-finish-load', () => restorePersistedZoomLevel(win))
|
||||
}
|
||||
|
||||
installContextMenu(win)
|
||||
win.webContents.setWindowOpenHandler(details => {
|
||||
openExternalUrl(details.url)
|
||||
|
|
@ -8523,9 +8539,7 @@ ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
|
|||
|
||||
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
|
||||
|
||||
ipcMain.handle('hermes:git:baseBranchList', async (_event, repoPath) =>
|
||||
listBaseBranches(repoPath, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:baseBranchList', async (_event, repoPath) => listBaseBranches(repoPath, resolveGitBinary()))
|
||||
|
||||
// Compact repo status (branch, ahead/behind, change counts + files) for the
|
||||
// composer coding rail. Returns null on a non-repo / remote backend so the rail
|
||||
|
|
|
|||
|
|
@ -17,7 +17,12 @@ import path from 'node:path'
|
|||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { buildPathExtCandidates, chooseUpdaterArgs, getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path'
|
||||
import {
|
||||
buildPathExtCandidates,
|
||||
chooseUpdaterArgs,
|
||||
getVenvSitePackagesEntries,
|
||||
resolveVenvHermesCommand
|
||||
} from './windows-hermes-path'
|
||||
|
||||
test('buildPathExtCandidates: Windows tries PATHEXT extensions before the empty extension', () => {
|
||||
const extensions = buildPathExtCandidates('.COM;.EXE;.BAT;.CMD', true)
|
||||
|
|
|
|||
|
|
@ -111,21 +111,25 @@ export function getVenvSitePackagesEntries(
|
|||
|
||||
const isWindows = opts.isWindows ?? process.platform === 'win32'
|
||||
|
||||
const directoryExists = opts.directoryExists ?? ((p: string) => {
|
||||
try {
|
||||
return fs.statSync(p).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const directoryExists =
|
||||
opts.directoryExists ??
|
||||
((p: string) => {
|
||||
try {
|
||||
return fs.statSync(p).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const readFile = opts.readFile ?? ((p: string) => {
|
||||
try {
|
||||
return fs.readFileSync(p, 'utf8')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
const readFile =
|
||||
opts.readFile ??
|
||||
((p: string) => {
|
||||
try {
|
||||
return fs.readFileSync(p, 'utf8')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
|
||||
if (isWindows) {
|
||||
const sitePackages = path.join(venvRoot, 'Lib', 'site-packages')
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { parseDefaultDistro, resolvePickerDefaultPath, wslPosixToWindowsAccessible } from './wsl-path-bridge'
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export function resolveDefaultWslDistro(): string {
|
|||
timeout: 2000,
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
cachedDistro = parseDefaultDistro(out) || 'Ubuntu'
|
||||
} catch {
|
||||
cachedDistro = 'Ubuntu'
|
||||
|
|
|
|||
|
|
@ -312,21 +312,24 @@ export function useComposerActions({
|
|||
requestComposerInsert(refText, { mode: 'inline' })
|
||||
}, [])
|
||||
|
||||
const addContextRefAttachment = useCallback((refText: string, label?: string, detail?: string) => {
|
||||
const kind: ComposerAttachment['kind'] = refText.startsWith('@folder:')
|
||||
? 'folder'
|
||||
: refText.startsWith('@url:')
|
||||
? 'url'
|
||||
: 'file'
|
||||
const addContextRefAttachment = useCallback(
|
||||
(refText: string, label?: string, detail?: string) => {
|
||||
const kind: ComposerAttachment['kind'] = refText.startsWith('@folder:')
|
||||
? 'folder'
|
||||
: refText.startsWith('@url:')
|
||||
? 'url'
|
||||
: 'file'
|
||||
|
||||
attachToMain({
|
||||
id: attachmentId(kind, refText),
|
||||
kind,
|
||||
label: label || refText.replace(/^@(file|folder|url):/, ''),
|
||||
detail,
|
||||
refText
|
||||
})
|
||||
}, [attachToMain])
|
||||
attachToMain({
|
||||
id: attachmentId(kind, refText),
|
||||
kind,
|
||||
label: label || refText.replace(/^@(file|folder|url):/, ''),
|
||||
detail,
|
||||
refText
|
||||
})
|
||||
},
|
||||
[attachToMain]
|
||||
)
|
||||
|
||||
const pickContextPaths = useCallback(
|
||||
async (kind: 'file' | 'folder') => {
|
||||
|
|
|
|||
|
|
@ -176,7 +176,11 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
|
|||
...state,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{ id: `system-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, role: 'system', parts: [textPart(text)] }
|
||||
{
|
||||
id: `system-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
role: 'system',
|
||||
parts: [textPart(text)]
|
||||
}
|
||||
]
|
||||
}))
|
||||
},
|
||||
|
|
@ -354,6 +358,15 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
|
|||
steerPrompt,
|
||||
submitText
|
||||
}),
|
||||
[cancelRun, dismissError, editMessage, handleThreadMessagesChange, reloadFromMessage, restoreToMessage, steerPrompt, submitText]
|
||||
[
|
||||
cancelRun,
|
||||
dismissError,
|
||||
editMessage,
|
||||
handleThreadMessagesChange,
|
||||
reloadFromMessage,
|
||||
restoreToMessage,
|
||||
steerPrompt,
|
||||
submitText
|
||||
]
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ import { useStore } from '@nanostores/react'
|
|||
import type * as React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { closeAllTreeTabs, closeOtherTreeTabs, closeTreeTabsToRight, treeTabCloseTargets } from '@/components/pane-shell/tree/store'
|
||||
import {
|
||||
closeAllTreeTabs,
|
||||
closeOtherTreeTabs,
|
||||
closeTreeTabsToRight,
|
||||
treeTabCloseTargets
|
||||
} from '@/components/pane-shell/tree/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -686,7 +686,12 @@ export function CommandPalette() {
|
|||
items: sessions.map(session => ({
|
||||
icon: MessageCircle,
|
||||
id: `session-${session.id}`,
|
||||
keywords: ['chat', 'session', ...(session.preview ? [session.preview] : []), ...(session.git_branch ? [session.git_branch] : [])],
|
||||
keywords: [
|
||||
'chat',
|
||||
'session',
|
||||
...(session.preview ? [session.preview] : []),
|
||||
...(session.git_branch ? [session.git_branch] : [])
|
||||
],
|
||||
label: session.title,
|
||||
run: go(sessionRoute(session.id))
|
||||
}))
|
||||
|
|
@ -724,7 +729,13 @@ export function CommandPalette() {
|
|||
items: archivedSessions.map(session => ({
|
||||
icon: Archive,
|
||||
id: `archived-${session.id}`,
|
||||
keywords: ['archived', 'chat', 'session', ...(session.preview ? [session.preview] : []), ...(session.git_branch ? [session.git_branch] : [])],
|
||||
keywords: [
|
||||
'archived',
|
||||
'chat',
|
||||
'session',
|
||||
...(session.preview ? [session.preview] : []),
|
||||
...(session.git_branch ? [session.git_branch] : [])
|
||||
],
|
||||
label: session.title,
|
||||
run: go(`${SETTINGS_ROUTE}?tab=sessions&session=${encodeURIComponent(session.id)}`)
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -116,7 +116,10 @@ export function useBackgroundSync({
|
|||
return
|
||||
}
|
||||
|
||||
const dispose = visiblePoll(ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS, () => void refreshActiveMessagingTranscript())
|
||||
const dispose = visiblePoll(
|
||||
ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS,
|
||||
() => void refreshActiveMessagingTranscript()
|
||||
)
|
||||
void refreshActiveMessagingTranscript()
|
||||
|
||||
return dispose
|
||||
|
|
|
|||
|
|
@ -3,12 +3,7 @@ import { useEffect, useRef } from 'react'
|
|||
import { closeActiveTab } from '@/app/chat/close-tab'
|
||||
import { storedSessionIdForNotification } from '@/lib/session-ids'
|
||||
import { respondToApprovalAction } from '@/store/native-notifications'
|
||||
import {
|
||||
getRememberedRoute,
|
||||
getRememberedSessionId,
|
||||
setRememberedRoute,
|
||||
setRememberedSessionId
|
||||
} from '@/store/session'
|
||||
import { getRememberedRoute, getRememberedSessionId, setRememberedRoute, setRememberedSessionId } from '@/store/session'
|
||||
import { onSessionsChanged } from '@/store/session-sync'
|
||||
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '@/store/updates'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
|
|
|||
|
|
@ -2,11 +2,7 @@ import { useEffect, useRef } from 'react'
|
|||
|
||||
import { setPetActivity } from '@/store/pet'
|
||||
import { setPetScale } from '@/store/pet-gallery'
|
||||
import {
|
||||
setPetOverlayOpenAppHandler,
|
||||
setPetOverlayScaleHandler,
|
||||
setPetOverlaySubmitHandler
|
||||
} from '@/store/pet-overlay'
|
||||
import { setPetOverlayOpenAppHandler, setPetOverlayScaleHandler, setPetOverlaySubmitHandler } from '@/store/pet-overlay'
|
||||
import { $attentionSessionIds, $sessions } from '@/store/session'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
|
|
|
|||
|
|
@ -96,7 +96,10 @@ export function PreviewRailPane() {
|
|||
className={cn(ZONE_CONTENT, 'min-h-0 w-full overflow-hidden [&>aside]:pt-0')}
|
||||
style={{ '--titlebar-height': `${TITLEBAR_HEIGHT}px` } as CSSProperties}
|
||||
>
|
||||
<ChatPreviewRail onRestartServer={restartPreviewServer ?? undefined} setTitlebarToolGroup={setTitlebarToolGroup} />
|
||||
<ChatPreviewRail
|
||||
onRestartServer={restartPreviewServer ?? undefined}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -159,7 +162,11 @@ export function useStatusbarContributions(side: 'left' | 'right'): StatusbarItem
|
|||
c.render
|
||||
? ({
|
||||
id: c.id,
|
||||
render: () => <ContribBoundary id={c.id} variant="chip">{c.render!()}</ContribBoundary>
|
||||
render: () => (
|
||||
<ContribBoundary id={c.id} variant="chip">
|
||||
{c.render!()}
|
||||
</ContribBoundary>
|
||||
)
|
||||
} satisfies StatusbarItem)
|
||||
: (c.data as StatusbarItem)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -42,10 +42,7 @@ export interface CronEditorSaveValues {
|
|||
}
|
||||
|
||||
/** Build the API update payload, preserving an empty prompt on script-only jobs. */
|
||||
export function cronEditorUpdates(
|
||||
values: CronEditorSaveValues,
|
||||
options: { scriptOnlyJob: boolean }
|
||||
): CronJobUpdates {
|
||||
export function cronEditorUpdates(values: CronEditorSaveValues, options: { scriptOnlyJob: boolean }): CronJobUpdates {
|
||||
const updates: CronJobUpdates = {
|
||||
deliver: values.deliver,
|
||||
name: values.name,
|
||||
|
|
|
|||
|
|
@ -399,10 +399,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
|||
} else if (editor.mode === 'edit') {
|
||||
const scriptOnlyJob = jobIsScriptOnly(editor.job)
|
||||
|
||||
const updated = await updateCronJob(
|
||||
editor.job.id,
|
||||
cronEditorUpdates(values, { scriptOnlyJob })
|
||||
)
|
||||
const updated = await updateCronJob(editor.job.id, cronEditorUpdates(values, { scriptOnlyJob }))
|
||||
|
||||
updateCronJobs(rows => rows.map(row => (row.id === updated.id ? updated : row)))
|
||||
notify({ kind: 'success', title: c.updated, message: truncate(jobTitle(updated), 60) })
|
||||
|
|
@ -818,12 +815,7 @@ function CronEditorDialog({
|
|||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
htmlFor="cron-prompt"
|
||||
label={c.promptLabel}
|
||||
optional={scriptOnlyJob}
|
||||
optionalLabel={c.optional}
|
||||
>
|
||||
<Field htmlFor="cron-prompt" label={c.promptLabel} optional={scriptOnlyJob} optionalLabel={c.optional}>
|
||||
<Textarea
|
||||
className="min-h-24 font-mono"
|
||||
id="cron-prompt"
|
||||
|
|
|
|||
|
|
@ -362,7 +362,9 @@ export function useGatewayBoot({
|
|||
})
|
||||
|
||||
const sourceProfile = normalizeProfileKey($activeGatewayProfile.get())
|
||||
const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent({ ...event, profile: sourceProfile }))
|
||||
const offEvent = gateway.onEvent(event =>
|
||||
callbacksRef.current.handleGatewayEvent({ ...event, profile: sourceProfile })
|
||||
)
|
||||
|
||||
// Wake signals: power resume (macOS/Windows), network coming back, and the
|
||||
// window regaining focus/visibility. Each nudges an immediate reconnect.
|
||||
|
|
|
|||
|
|
@ -76,7 +76,11 @@ export function SessionSwitcher() {
|
|||
}}
|
||||
ref={selected ? activeRef : undefined}
|
||||
>
|
||||
<SwitcherDot attention={attentionIds.has(session.id)} working={workingIds.has(session.id)} unread={unreadIds.has(session.id)} />
|
||||
<SwitcherDot
|
||||
attention={attentionIds.has(session.id)}
|
||||
unread={unreadIds.has(session.id)}
|
||||
working={workingIds.has(session.id)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{sessionTitle(session)}</span>
|
||||
{i < 9 && (
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -71,11 +71,7 @@ describe('useCwdActions draft workspace target', () => {
|
|||
let handle: CwdActionsHandle | null = null
|
||||
|
||||
render(
|
||||
<Harness
|
||||
activeSessionIdRef={activeSessionIdRef}
|
||||
onReady={h => (handle = h)}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
<Harness activeSessionIdRef={activeSessionIdRef} onReady={h => (handle = h)} requestGateway={requestGateway} />
|
||||
)
|
||||
await waitFor(() => expect(handle).not.toBeNull())
|
||||
|
||||
|
|
|
|||
|
|
@ -94,9 +94,7 @@ describe('live session.info approval mode reconciliation', () => {
|
|||
await mountStream()
|
||||
$activeGatewayProfile.set('personal')
|
||||
|
||||
act(() =>
|
||||
handleEvent!({ payload: { approval_mode: 'off' }, session_id: ACTIVE_SID, type: 'session.info' })
|
||||
)
|
||||
act(() => handleEvent!({ payload: { approval_mode: 'off' }, session_id: ACTIVE_SID, type: 'session.info' }))
|
||||
|
||||
expect(approvalModeForProfile('personal')).toBe('smart')
|
||||
expect(approvalModeForProfile('work')).toBe('smart')
|
||||
|
|
|
|||
|
|
@ -136,11 +136,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
// model output or tool event proves summarization has finished and the
|
||||
// turn has resumed, so retire the phase label without waiting for the
|
||||
// whole turn to complete.
|
||||
if (
|
||||
sessionId &&
|
||||
COMPACTION_RESUME_EVENT_TYPES.has(event.type) &&
|
||||
compactedTurnRef.current.has(sessionId)
|
||||
) {
|
||||
if (sessionId && COMPACTION_RESUME_EVENT_TYPES.has(event.type) && compactedTurnRef.current.has(sessionId)) {
|
||||
setSessionCompacting(sessionId, false)
|
||||
}
|
||||
|
||||
|
|
@ -534,7 +530,9 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
setApprovalRequest({
|
||||
// false only when a tirith warning forbids it; backend omits the field otherwise.
|
||||
allowPermanent: payload?.allow_permanent !== false,
|
||||
choices: Array.isArray(payload?.choices) ? payload.choices.filter(choice => typeof choice === 'string') : undefined,
|
||||
choices: Array.isArray(payload?.choices)
|
||||
? payload.choices.filter(choice => typeof choice === 'string')
|
||||
: undefined,
|
||||
command,
|
||||
description,
|
||||
sessionId: sessionId ?? null,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,13 @@ import type { ClientSessionState } from '@/app/types'
|
|||
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
|
||||
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
||||
|
||||
import { appendText, isSessionBusyError, visibleUserIndexAtOrdinal, visibleUserOrdinal, withSessionBusyRetry } from './utils'
|
||||
import {
|
||||
appendText,
|
||||
isSessionBusyError,
|
||||
visibleUserIndexAtOrdinal,
|
||||
visibleUserOrdinal,
|
||||
withSessionBusyRetry
|
||||
} from './utils'
|
||||
|
||||
type RequestGateway = <T = unknown>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
|
||||
|
||||
|
|
@ -44,7 +50,11 @@ export async function runRewindSubmit(
|
|||
const submit = () =>
|
||||
requestGateway(
|
||||
'prompt.submit',
|
||||
{ session_id: sessionId, text, ...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal }) },
|
||||
{
|
||||
session_id: sessionId,
|
||||
text,
|
||||
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
|
||||
},
|
||||
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -147,8 +147,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
|||
let startingRouteToken = getRouteToken()
|
||||
|
||||
const sessionContextDrifted = (): boolean =>
|
||||
selectedStoredSessionIdRef.current !== startingStoredSessionId ||
|
||||
getRouteToken() !== startingRouteToken
|
||||
selectedStoredSessionIdRef.current !== startingStoredSessionId || getRouteToken() !== startingRouteToken
|
||||
|
||||
// One submit in flight per session — drop any concurrent re-fire so a
|
||||
// stalled turn can't stack the same prompt into multiple real turns.
|
||||
|
|
@ -366,10 +365,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
|||
requestGateway('prompt.submit', { session_id: sessionId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
|
||||
)
|
||||
} catch (firstErr) {
|
||||
if (
|
||||
(isSessionNotFoundError(firstErr) || isGatewayTimeoutError(firstErr)) &&
|
||||
startingStoredSessionId
|
||||
) {
|
||||
if ((isSessionNotFoundError(firstErr) || isGatewayTimeoutError(firstErr)) && startingStoredSessionId) {
|
||||
// Re-register the session in the gateway and get a fresh live ID.
|
||||
// Timeouts recover the same way as "session not found": a starved
|
||||
// backend loop (#55578 symptom d) rejects the submit even though
|
||||
|
|
|
|||
|
|
@ -35,7 +35,10 @@ vi.mock('@/hermes', async importOriginal => ({
|
|||
}))
|
||||
|
||||
const RUNTIME_SESSION_ID = 'rt-new-001'
|
||||
type HarnessHandle = Pick<ReturnType<typeof useSessionActions>, 'createBackendSessionForSend' | 'startFreshSessionDraft'>
|
||||
type HarnessHandle = Pick<
|
||||
ReturnType<typeof useSessionActions>,
|
||||
'createBackendSessionForSend' | 'startFreshSessionDraft'
|
||||
>
|
||||
|
||||
function storedSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
return {
|
||||
|
|
@ -674,5 +677,4 @@ describe('createBackendSessionForSend workspace target', () => {
|
|||
|
||||
expect(params).toMatchObject({ cwd: '/clicked-workspace' })
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -45,7 +45,14 @@ import {
|
|||
setTurnStartedAt,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { closeSessionTile, dropSessionState, openSessionTile, patchSessionTile, publishSessionState, type TileDock } from '@/store/session-states'
|
||||
import {
|
||||
closeSessionTile,
|
||||
dropSessionState,
|
||||
openSessionTile,
|
||||
patchSessionTile,
|
||||
publishSessionState,
|
||||
type TileDock
|
||||
} from '@/store/session-states'
|
||||
import { broadcastSessionsChanged } from '@/store/session-sync'
|
||||
import { isWatchWindow } from '@/store/windows'
|
||||
import type { SessionCreateResponse, SessionResumeResponse, UsageStats } from '@/types/hermes'
|
||||
|
|
@ -536,6 +543,7 @@ export function useSessionActions({
|
|||
setFreshDraftReady(false)
|
||||
setActiveSessionId(null)
|
||||
activeSessionIdRef.current = null
|
||||
|
||||
// A warm-cache hit at entry skipped the cold-path transcript clear, but the
|
||||
// warm path can still bail down to here — an empty-transcript drop, or the
|
||||
// cache getting purged during the profile-swap await — so the PREVIOUS
|
||||
|
|
@ -1067,13 +1075,7 @@ export function useSessionActions({
|
|||
notifyError(err, copy.archiveFailed)
|
||||
}
|
||||
},
|
||||
[
|
||||
copy,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionId,
|
||||
sessionStateByRuntimeIdRef,
|
||||
startFreshSessionDraft
|
||||
]
|
||||
[copy, runtimeIdByStoredSessionIdRef, selectedStoredSessionId, sessionStateByRuntimeIdRef, startFreshSessionDraft]
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ async function renderField(value: unknown, onChange = vi.fn()) {
|
|||
async function renderFieldWithRerender(value: unknown, onChange = vi.fn()) {
|
||||
const { FallbackModelsField } = await import('./fallback-models-field')
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
const view = render(
|
||||
<QueryClientProvider client={client}>
|
||||
<FallbackModelsField onChange={onChange} value={value} />
|
||||
|
|
|
|||
|
|
@ -33,7 +33,9 @@ function normalizeEntries(value: unknown): FallbackEntry[] {
|
|||
if (typeof item === 'string') {
|
||||
const slash = item.indexOf('/')
|
||||
|
||||
return slash > 0 ? { provider: item.slice(0, slash), model: item.slice(slash + 1) } : { provider: '', model: item }
|
||||
return slash > 0
|
||||
? { provider: item.slice(0, slash), model: item.slice(slash + 1) }
|
||||
: { provider: '', model: item }
|
||||
}
|
||||
|
||||
return { provider: '', model: '' }
|
||||
|
|
@ -45,7 +47,10 @@ function completeEntries(rows: FallbackEntry[]): FallbackEntry[] {
|
|||
}
|
||||
|
||||
function entriesEqual(a: FallbackEntry[], b: FallbackEntry[]): boolean {
|
||||
return a.length === b.length && a.every((entry, index) => entry.provider === b[index]?.provider && entry.model === b[index]?.model)
|
||||
return (
|
||||
a.length === b.length &&
|
||||
a.every((entry, index) => entry.provider === b[index]?.provider && entry.model === b[index]?.model)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -644,10 +644,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
return
|
||||
}
|
||||
|
||||
// Persist a cloud-mode connection (remote-shaped, oauth) and soft-reconnect.
|
||||
// Include the selected org so Settings reopens into the same org + instance.
|
||||
// Read the REF (not the cloudOrg state) so a just-resolved org from
|
||||
// discovery in this same render tick is captured, not a stale null.
|
||||
// Persist a cloud-mode connection (remote-shaped, oauth) and soft-reconnect.
|
||||
// Include the selected org so Settings reopens into the same org + instance.
|
||||
// Read the REF (not the cloudOrg state) so a just-resolved org from
|
||||
// discovery in this same render tick is captured, not a stale null.
|
||||
const next = await desktop.applyConnectionConfig({
|
||||
mode: 'cloud',
|
||||
profile: scope ?? undefined,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,20 @@ import { Tip } from '@/components/ui/tooltip'
|
|||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, Bell, Download, Globe, Info, KeyRound, Package, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons'
|
||||
import {
|
||||
Archive,
|
||||
Bell,
|
||||
Download,
|
||||
Globe,
|
||||
Info,
|
||||
KeyRound,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
Upload,
|
||||
Wrench,
|
||||
Zap
|
||||
} from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
|
|
|
|||
|
|
@ -1047,7 +1047,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
<SelectValue placeholder={m.provider} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{withActive(moaSlotProviderOptions.map(p => p.slug || 'none'), slot.provider).map(slug => {
|
||||
{withActive(
|
||||
moaSlotProviderOptions.map(p => p.slug || 'none'),
|
||||
slot.provider
|
||||
).map(slug => {
|
||||
const provider = moaSlotProviderOptions.find(p => (p.slug || 'none') === slug)
|
||||
|
||||
return (
|
||||
|
|
@ -1130,7 +1133,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
<SelectValue placeholder={m.provider} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{withActive(moaSlotProviderOptions.map(p => p.slug || 'none'), currentMoaPreset.aggregator.provider).map(slug => {
|
||||
{withActive(
|
||||
moaSlotProviderOptions.map(p => p.slug || 'none'),
|
||||
currentMoaPreset.aggregator.provider
|
||||
).map(slug => {
|
||||
const provider = moaSlotProviderOptions.find(p => (p.slug || 'none') === slug)
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ function PluginRow({ record }: { record: PluginRecord }) {
|
|||
record.status === 'error' ? (
|
||||
<span className="text-(--ui-danger,#f87171)">{record.error}</span>
|
||||
) : (
|
||||
record.file ?? record.id
|
||||
(record.file ?? record.id)
|
||||
)
|
||||
}
|
||||
title={
|
||||
|
|
|
|||
|
|
@ -18,10 +18,7 @@ import {
|
|||
syncApprovalModeForProfile
|
||||
} from '@/store/approval-mode'
|
||||
|
||||
export function useApprovalModeStatusbarItem(
|
||||
profile: string,
|
||||
requestGateway: ApprovalModeRequester
|
||||
): StatusbarItem {
|
||||
export function useApprovalModeStatusbarItem(profile: string, requestGateway: ApprovalModeRequester): StatusbarItem {
|
||||
const { t } = useI18n()
|
||||
const copy = t.shell.approvalMode
|
||||
const modes = useStore($approvalModes)
|
||||
|
|
@ -66,9 +63,7 @@ export function useApprovalModeStatusbarItem(
|
|||
<DropdownMenuRadioItem className="items-start gap-2" key={value} value={value}>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-xs text-foreground">{labels[value]}</span>
|
||||
<span className="text-[0.6875rem] leading-snug text-(--ui-text-tertiary)">
|
||||
{descriptions[value]}
|
||||
</span>
|
||||
<span className="text-[0.6875rem] leading-snug text-(--ui-text-tertiary)">{descriptions[value]}</span>
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -428,7 +428,7 @@ export function useStatusbarItems({
|
|||
},
|
||||
{
|
||||
...approvalModeItem,
|
||||
hidden: gatewayState !== 'open',
|
||||
hidden: gatewayState !== 'open'
|
||||
},
|
||||
{
|
||||
className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`,
|
||||
|
|
@ -457,7 +457,7 @@ export function useStatusbarItems({
|
|||
sessionStartedAt,
|
||||
gatewayState,
|
||||
terminalTakeover,
|
||||
turnStartedAt,
|
||||
turnStartedAt
|
||||
]
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -103,7 +103,11 @@ describe('ClarifyTool settled view', () => {
|
|||
it('labels an empty response as Skipped', () => {
|
||||
renderClarify(
|
||||
<ClarifyTool
|
||||
{...settledClarifyProps({ question: 'Anything else?' }, { question: 'Anything else?', user_response: '' }, 'clarify-2')}
|
||||
{...settledClarifyProps(
|
||||
{ question: 'Anything else?' },
|
||||
{ question: 'Anything else?', user_response: '' },
|
||||
'clarify-2'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -197,37 +197,41 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline'
|
|||
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
|
||||
</Button>
|
||||
{hasMoreOptions && <span aria-hidden className="w-px self-stretch bg-primary/20" />}
|
||||
{hasMoreOptions && <DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={copy.moreOptions}
|
||||
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
|
||||
disabled={busy}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-44">
|
||||
{allowSession && <DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>}
|
||||
{allowAlways && (
|
||||
<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)
|
||||
}}
|
||||
{hasMoreOptions && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={copy.moreOptions}
|
||||
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
|
||||
disabled={busy}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
{copy.alwaysAllowMenu}
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-44">
|
||||
{allowSession && (
|
||||
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
|
||||
)}
|
||||
{allowAlways && (
|
||||
<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>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
|
||||
{copy.reject}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -241,7 +241,13 @@ export function BootFailureOverlay() {
|
|||
|
||||
if (remoteReauth) {
|
||||
actions = [
|
||||
{ key: 'signin', label: copy.signOutAndSignIn, onClick: () => void signInRemote(), icon: <LogIn />, busy: 'signin' },
|
||||
{
|
||||
key: 'signin',
|
||||
label: copy.signOutAndSignIn,
|
||||
onClick: () => void signInRemote(),
|
||||
icon: <LogIn />,
|
||||
busy: 'signin'
|
||||
},
|
||||
{ ...settingsAction, variant: 'secondary' },
|
||||
localAction
|
||||
]
|
||||
|
|
@ -254,7 +260,14 @@ export function BootFailureOverlay() {
|
|||
// it's dropped here; keep it for remote failures where it's the fall-back.
|
||||
actions = [
|
||||
retryAction,
|
||||
{ key: 'repair', label: copy.repairInstall, onClick: () => void repair(), icon: <Wrench />, variant: 'secondary', busy: 'repair' },
|
||||
{
|
||||
key: 'repair',
|
||||
label: copy.repairInstall,
|
||||
onClick: () => void repair(),
|
||||
icon: <Wrench />,
|
||||
variant: 'secondary',
|
||||
busy: 'repair'
|
||||
},
|
||||
{ ...settingsAction, variant: 'ghost' }
|
||||
]
|
||||
hint = copy.repairHint
|
||||
|
|
|
|||
|
|
@ -55,7 +55,10 @@ export function isRemoteReauthError(error: string | null | undefined): boolean {
|
|||
// — see isRemoteReauthError). Only re-establishing the remote session fixes it;
|
||||
// the local Retry/Repair buttons can't. 'cloud' counts as remote (it resolves to
|
||||
// a remote oauth backend), so a lapsed cloud session is the same failure.
|
||||
export function isRemoteReauthFailure(config: DesktopConnectionConfig | null | undefined, error?: string | null): boolean {
|
||||
export function isRemoteReauthFailure(
|
||||
config: DesktopConnectionConfig | null | undefined,
|
||||
error?: string | null
|
||||
): boolean {
|
||||
return (
|
||||
isRemoteConfig(config) &&
|
||||
config!.remoteAuthMode === 'oauth' &&
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
import { type CSSProperties } from 'react'
|
||||
|
||||
import {
|
||||
createParticleEmitter,
|
||||
ParticleField,
|
||||
type ParticleFieldConfig
|
||||
} from '@/components/particles/particle-field'
|
||||
import { createParticleEmitter, ParticleField, type ParticleFieldConfig } from '@/components/particles/particle-field'
|
||||
import { $petActive, flashPetActivity } from '@/store/pet'
|
||||
import { $petOverlayActive, forwardPetReaction } from '@/store/pet-overlay'
|
||||
|
||||
|
|
|
|||
|
|
@ -57,11 +57,7 @@ export function GatewayConnectingOverlay() {
|
|||
const initialBootActive = boot.visible || boot.running || boot.progress < 100
|
||||
|
||||
const connecting =
|
||||
!coldBootDoneRef.current &&
|
||||
!gatewaySwitching &&
|
||||
gatewayState !== 'open' &&
|
||||
!boot.error &&
|
||||
initialBootActive
|
||||
!coldBootDoneRef.current && !gatewaySwitching && gatewayState !== 'open' && !boot.error && initialBootActive
|
||||
|
||||
// Latches once we've actually shown the overlay, so the brief frame where
|
||||
// gatewayState flips to "open" (connecting -> false) before the exit phase
|
||||
|
|
|
|||
|
|
@ -30,13 +30,7 @@ import type { ModelOptionProvider, OAuthProvider } from '@/types/hermes'
|
|||
import { DocsLink, FlowPanel, Status } from './flow'
|
||||
import { FeaturedProviderRow, KeyProviderRow, ProviderRow, sortProviders } from './providers'
|
||||
|
||||
export {
|
||||
FeaturedProviderRow,
|
||||
KeyProviderRow,
|
||||
ProviderRow,
|
||||
providerTitle,
|
||||
sortProviders
|
||||
} from './providers'
|
||||
export { FeaturedProviderRow, KeyProviderRow, ProviderRow, providerTitle, sortProviders } from './providers'
|
||||
|
||||
interface DesktopOnboardingOverlayProps {
|
||||
enabled: boolean
|
||||
|
|
@ -485,13 +479,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
|||
In manual mode the overlay already has a close affordance, so the
|
||||
"choose later" escape would be redundant — hide it. */}
|
||||
{manual ? <span /> : <ChooseLaterLink />}
|
||||
<Button
|
||||
className="-mr-2 font-medium"
|
||||
onClick={() => openKeyForm()}
|
||||
size="xs"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
<Button className="-mr-2 font-medium" onClick={() => openKeyForm()} size="xs" type="button" variant="text">
|
||||
{t.onboarding.haveApiKey}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -143,9 +143,13 @@ export function useWindowControlsRect(): Rect | null {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sameRect(a: Rect | null, b: Rect | null) {
|
||||
if (a === b) {return true}
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!a || !b) {return false}
|
||||
if (!a || !b) {
|
||||
return false
|
||||
}
|
||||
|
||||
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,7 +142,10 @@ export function modelToZones(model: GridLayout): GridZone[] | null {
|
|||
}
|
||||
|
||||
// Each zone must occupy a full rectangle of cells.
|
||||
if (indexCount[index] !== (indexRowHigh[index] - indexRowLow[index] + 1) * (indexColHigh[index] - indexColLow[index] + 1)) {
|
||||
if (
|
||||
indexCount[index] !==
|
||||
(indexRowHigh[index] - indexRowLow[index] + 1) * (indexColHigh[index] - indexColLow[index] + 1)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,8 +144,7 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
|
|||
// zone). Same largest-tenant basis as the track size — never per-tab.
|
||||
const all = shownIds.map(id => (paneFor(id)?.data ?? {}) as PaneSizing)
|
||||
|
||||
const cap = (pick: (s: PaneSizing) => string | undefined) =>
|
||||
all.every(pick) ? cssMax(all.map(pick)) : undefined
|
||||
const cap = (pick: (s: PaneSizing) => string | undefined) => (all.every(pick) ? cssMax(all.map(pick)) : undefined)
|
||||
|
||||
return {
|
||||
minWidth: cssMax(all.map(s => s.minWidth)),
|
||||
|
|
@ -457,9 +456,7 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
|
|||
// gracefully on tight windows, floored by min-width);
|
||||
// everything else splits the leftover by weight. In an
|
||||
// all-fixed run the last track grows into the leftover.
|
||||
flex: track
|
||||
? `${i === absorberIndex ? 1 : 0} 1 ${track}`
|
||||
: `${grow(i)} ${grow(i)} 0px`,
|
||||
flex: track ? `${i === absorberIndex ? 1 : 0} 1 ${track}` : `${grow(i)} ${grow(i)} 0px`,
|
||||
// Pane-declared clamps apply along THIS split's axis only
|
||||
// (a rail's width clamp shouldn't constrain its height).
|
||||
// The absorber drops its max clamp — it exists to fill
|
||||
|
|
|
|||
|
|
@ -379,17 +379,19 @@ function rootRow(): SplitNode | null {
|
|||
|
||||
const hasMain = (node: LayoutNode): boolean => {
|
||||
if (node.type === 'group') {
|
||||
return node.panes.some(id =>
|
||||
(panes.find(p => p.id === id)?.data as { placement?: string } | undefined)?.placement === 'main'
|
||||
return node.panes.some(
|
||||
id => (panes.find(p => p.id === id)?.data as { placement?: string } | undefined)?.placement === 'main'
|
||||
)
|
||||
}
|
||||
|
||||
return node.children.some(hasMain)
|
||||
}
|
||||
|
||||
return tree.children.find(child => child.type === 'split' && child.orientation === 'row' && hasMain(child)) as
|
||||
| SplitNode
|
||||
| undefined ?? null
|
||||
return (
|
||||
(tree.children.find(child => child.type === 'split' && child.orientation === 'row' && hasMain(child)) as
|
||||
| SplitNode
|
||||
| undefined) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
/** Which root-row side a pane currently lives in, or null when it's nested
|
||||
|
|
|
|||
|
|
@ -317,7 +317,10 @@ export function ZoneEditor() {
|
|||
]
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-[70] flex flex-col gap-3 p-6 [-webkit-app-region:no-drag]" style={{ background: 'color-mix(in srgb, var(--ui-bg-chrome) 88%, transparent)', backdropFilter: 'blur(6px)' }}>
|
||||
<div
|
||||
className="absolute inset-0 z-[70] flex flex-col gap-3 p-6 [-webkit-app-region:no-drag]"
|
||||
style={{ background: 'color-mix(in srgb, var(--ui-bg-chrome) 88%, transparent)', backdropFilter: 'blur(6px)' }}
|
||||
>
|
||||
{/* Toolbar — Panel-style title + hint, template chooser on the right. */}
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
|
|
@ -440,7 +443,9 @@ export function ZoneEditor() {
|
|||
<div
|
||||
className={cn(
|
||||
'absolute z-10 flex items-center justify-center',
|
||||
horizontal ? 'h-[10px] -translate-y-1/2 cursor-row-resize' : 'w-[10px] -translate-x-1/2 cursor-col-resize'
|
||||
horizontal
|
||||
? 'h-[10px] -translate-y-1/2 cursor-row-resize'
|
||||
: 'w-[10px] -translate-x-1/2 cursor-col-resize'
|
||||
)}
|
||||
data-resizer={i}
|
||||
key={`r-${i}`}
|
||||
|
|
|
|||
|
|
@ -20,12 +20,7 @@ import { triggerHaptic } from '@/lib/haptics'
|
|||
import { KeyRound, Loader2, Lock } from '@/lib/icons'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import {
|
||||
clearSecretRequest,
|
||||
clearSudoRequest,
|
||||
sessionSecretRequest,
|
||||
sessionSudoRequest
|
||||
} from '@/store/prompts'
|
||||
import { clearSecretRequest, clearSudoRequest, sessionSecretRequest, sessionSudoRequest } from '@/store/prompts'
|
||||
|
||||
// Renders the modal mid-turn prompts the gateway raises and waits on: sudo
|
||||
// password and skill secret capture. Dangerous-command / execute_code approval
|
||||
|
|
|
|||
|
|
@ -100,7 +100,11 @@ export function unloadRuntimePlugin(id: string): void {
|
|||
}
|
||||
|
||||
/** Evaluate + register one runtime plugin. Returns its id, or null on failure. */
|
||||
export async function loadRuntimePlugin(source: string, origin: string, options: LoadOptions = {}): Promise<null | string> {
|
||||
export async function loadRuntimePlugin(
|
||||
source: string,
|
||||
origin: string,
|
||||
options: LoadOptions = {}
|
||||
): Promise<null | string> {
|
||||
installPluginSdk()
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -103,8 +103,14 @@ export function resolveGatewayEventSessionId({
|
|||
}
|
||||
|
||||
const streamEvent = eventType ? UNSCOPED_STREAM_EVENT_TYPES.has(eventType) : false
|
||||
|
||||
const sessionId =
|
||||
eventType === 'message.start' ? activeSessionId : streamEvent ? unscopedStreamSessionId || activeSessionId : activeSessionId
|
||||
eventType === 'message.start'
|
||||
? activeSessionId
|
||||
: streamEvent
|
||||
? unscopedStreamSessionId || activeSessionId
|
||||
: activeSessionId
|
||||
|
||||
let nextUnscopedStreamSessionId = unscopedStreamSessionId
|
||||
|
||||
if (eventType === 'message.start' && activeSessionId) {
|
||||
|
|
|
|||
|
|
@ -153,11 +153,9 @@ class IncrementalExternalStoreThreadRuntimeCore extends ExternalStoreThreadRunti
|
|||
const optimisticId = generateId()
|
||||
this.repository.addOrUpdateMessage(
|
||||
messages.at(-1)?.id ?? null,
|
||||
fromThreadMessageLike(
|
||||
{ role: 'assistant', content: [], metadata: { isOptimistic: true } },
|
||||
optimisticId,
|
||||
{ type: 'running' }
|
||||
)
|
||||
fromThreadMessageLike({ role: 'assistant', content: [], metadata: { isOptimistic: true } }, optimisticId, {
|
||||
type: 'running'
|
||||
})
|
||||
)
|
||||
self._assistantOptimisticId = optimisticId
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ describe('sameCronSignature', () => {
|
|||
})
|
||||
|
||||
describe('sessionMessagesSignature', () => {
|
||||
const msg = (role: string, content: string) => ({ role, content }) as Parameters<typeof sessionMessagesSignature>[0][number]
|
||||
const msg = (role: string, content: string) =>
|
||||
({ role, content }) as Parameters<typeof sessionMessagesSignature>[0][number]
|
||||
|
||||
it('is stable for identical transcripts', () => {
|
||||
expect(sessionMessagesSignature([msg('user', 'hi')])).toBe(sessionMessagesSignature([msg('user', 'hi')]))
|
||||
|
|
|
|||
|
|
@ -177,7 +177,13 @@ export { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|||
export { Textarea } from '@/components/ui/textarea'
|
||||
export { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
export type { GatewayEventListener } from '@/contrib/events'
|
||||
export type { HermesPlugin, PluginContext, PluginContribution, PluginRestOptions, PluginStorage } from '@/contrib/plugin'
|
||||
export type {
|
||||
HermesPlugin,
|
||||
PluginContext,
|
||||
PluginContribution,
|
||||
PluginRestOptions,
|
||||
PluginStorage
|
||||
} from '@/contrib/plugin'
|
||||
|
||||
// -- contracts ----------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -34,8 +34,14 @@ describe('profile-scoped approval mode cache', () => {
|
|||
})
|
||||
|
||||
it('keeps profile values isolated', async () => {
|
||||
await syncApprovalModeForProfile(vi.fn(async () => ({ value: 'manual' })), 'work')
|
||||
await syncApprovalModeForProfile(vi.fn(async () => ({ value: 'off' })), 'personal')
|
||||
await syncApprovalModeForProfile(
|
||||
vi.fn(async () => ({ value: 'manual' })),
|
||||
'work'
|
||||
)
|
||||
await syncApprovalModeForProfile(
|
||||
vi.fn(async () => ({ value: 'off' })),
|
||||
'personal'
|
||||
)
|
||||
|
||||
expect(approvalModeForProfile('work')).toBe('manual')
|
||||
expect(approvalModeForProfile('personal')).toBe('off')
|
||||
|
|
@ -43,7 +49,10 @@ describe('profile-scoped approval mode cache', () => {
|
|||
})
|
||||
|
||||
it('rolls consecutive failed writes back to the last authoritative value', async () => {
|
||||
await syncApprovalModeForProfile(vi.fn(async () => ({ value: 'smart' })), 'default')
|
||||
await syncApprovalModeForProfile(
|
||||
vi.fn(async () => ({ value: 'smart' })),
|
||||
'default'
|
||||
)
|
||||
const first = deferred<{ value: string }>()
|
||||
const second = deferred<{ value: string }>()
|
||||
|
||||
|
|
@ -67,7 +76,11 @@ describe('profile-scoped approval mode cache', () => {
|
|||
|
||||
it('lets a backend event supersede an optimistic write and its later failure', async () => {
|
||||
const write = deferred<{ value: string }>()
|
||||
const pending = setApprovalModeForProfile(vi.fn(() => write.promise), 'work', 'off')
|
||||
const pending = setApprovalModeForProfile(
|
||||
vi.fn(() => write.promise),
|
||||
'work',
|
||||
'off'
|
||||
)
|
||||
|
||||
reconcileApprovalModeForProfile('work', 'smart')
|
||||
expect(approvalModeForProfile('work')).toBe('smart')
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
export type ApprovalMode = 'manual' | 'off' | 'smart'
|
||||
export type ApprovalModeRequester = (
|
||||
method: string,
|
||||
params?: Record<string, unknown>
|
||||
) => Promise<unknown>
|
||||
export type ApprovalModeRequester = (method: string, params?: Record<string, unknown>) => Promise<unknown>
|
||||
|
||||
const APPROVAL_MODES = new Set<ApprovalMode>(['manual', 'smart', 'off'])
|
||||
const revisions = new Map<string, number>()
|
||||
|
|
|
|||
|
|
@ -44,26 +44,23 @@ export const $backgroundStatusBySession = atom<Record<string, ComposerStatusItem
|
|||
// $backgroundStatusBySession is keyed by RUNTIME session id (gateway events
|
||||
// and process.list both speak that); the sidebar row knows only the STORED id.
|
||||
// $sessionStates bridges the two: runtime id → state.storedSessionId.
|
||||
export const $backgroundRunningSessionIds = computed(
|
||||
[$backgroundStatusBySession, $sessionStates],
|
||||
(bg, states) => {
|
||||
const ids = new Set<string>()
|
||||
export const $backgroundRunningSessionIds = computed([$backgroundStatusBySession, $sessionStates], (bg, states) => {
|
||||
const ids = new Set<string>()
|
||||
|
||||
for (const [runtimeId, items] of Object.entries(bg)) {
|
||||
if (!items.some(i => i.state === 'running')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const storedId = states[runtimeId]?.storedSessionId
|
||||
|
||||
if (storedId) {
|
||||
ids.add(storedId)
|
||||
}
|
||||
for (const [runtimeId, items] of Object.entries(bg)) {
|
||||
if (!items.some(i => i.state === 'running')) {
|
||||
continue
|
||||
}
|
||||
|
||||
return [...ids]
|
||||
const storedId = states[runtimeId]?.storedSessionId
|
||||
|
||||
if (storedId) {
|
||||
ids.add(storedId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return [...ids]
|
||||
})
|
||||
|
||||
// Rows the user X-ed away. The registry keeps finished processes around for a
|
||||
// while, so without this every refresh would resurrect a dismissed row.
|
||||
|
|
|
|||
|
|
@ -38,9 +38,7 @@ export interface ComposerAttachmentScope {
|
|||
update(attachment: ComposerAttachment): boolean
|
||||
}
|
||||
|
||||
export function createComposerAttachmentScope(
|
||||
$attachments = atom<ComposerAttachment[]>([])
|
||||
): ComposerAttachmentScope {
|
||||
export function createComposerAttachmentScope($attachments = atom<ComposerAttachment[]>([])): ComposerAttachmentScope {
|
||||
return {
|
||||
$attachments,
|
||||
add(attachment) {
|
||||
|
|
|
|||
|
|
@ -82,8 +82,7 @@ export interface PetReaction {
|
|||
|
||||
export const $petReaction = atom<PetReaction | null>(null)
|
||||
|
||||
export const forwardPetReaction = (kind: string) =>
|
||||
$petReaction.set({ id: ($petReaction.get()?.id ?? 0) + 1, kind })
|
||||
export const forwardPetReaction = (kind: string) => $petReaction.set({ id: ($petReaction.get()?.id ?? 0) + 1, kind })
|
||||
|
||||
function loadSavedBounds(): null | PetOverlayBounds {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -151,14 +151,11 @@ export const $activeSessionAwaitingInput = computed(
|
|||
* `$activeSessionAwaitingInput` (same sources, fixed session instead of the
|
||||
* active one). */
|
||||
export function sessionAwaitingInput(sessionId: string | null) {
|
||||
return computed(
|
||||
[$clarifyRequests, approval.$all, sudo.$all, secret.$all],
|
||||
(clarify, approvals, sudos, secrets) => {
|
||||
const key = keyFor(sessionId)
|
||||
return computed([$clarifyRequests, approval.$all, sudo.$all, secret.$all], (clarify, approvals, sudos, secrets) => {
|
||||
const key = keyFor(sessionId)
|
||||
|
||||
return Boolean(clarify[key] || approvals[key] || sudos[key] || secrets[key])
|
||||
}
|
||||
)
|
||||
return Boolean(clarify[key] || approvals[key] || sudos[key] || secrets[key])
|
||||
})
|
||||
}
|
||||
|
||||
// Drop in-flight prompts for `sessionId` (a turn ended) across all three kinds —
|
||||
|
|
|
|||
|
|
@ -319,14 +319,17 @@ export const setSessionProfileTotals = (next: Updater<Record<string, number>>) =
|
|||
export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)
|
||||
export const setWorkingSessionIds = (next: Updater<string[]>) => updateAtom($workingSessionIds, next)
|
||||
export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($activeSessionId, next)
|
||||
|
||||
export const setSelectedStoredSessionId = (next: Updater<string | null>) => {
|
||||
updateAtom($selectedStoredSessionId, next)
|
||||
// Opening a session clears its unread state — the user is now looking at it.
|
||||
const id = $selectedStoredSessionId.get()
|
||||
|
||||
if (id && $unreadFinishedSessionIds.get().includes(id)) {
|
||||
toggleMembership(setUnreadFinishedSessionIds, id, false)
|
||||
}
|
||||
}
|
||||
|
||||
export const setMessages = (next: Updater<ChatMessage[]>) => updateAtom($messages, next)
|
||||
export const setFreshDraftReady = (next: Updater<boolean>) => updateAtom($freshDraftReady, next)
|
||||
export const setResumeFailedSessionId = (next: Updater<string | null>) => updateAtom($resumeFailedSessionId, next)
|
||||
|
|
@ -524,8 +527,7 @@ const toggleMembership = (set: (next: Updater<string[]>) => void, id: string, on
|
|||
// these so the user can tab back and find newly-completed work. Cleared on
|
||||
// session open (setSelectedStoredSessionId) and on gateway-mode wipe.
|
||||
export const $unreadFinishedSessionIds = atom<string[]>([])
|
||||
export const setUnreadFinishedSessionIds = (next: Updater<string[]>) =>
|
||||
updateAtom($unreadFinishedSessionIds, next)
|
||||
export const setUnreadFinishedSessionIds = (next: Updater<string[]>) => updateAtom($unreadFinishedSessionIds, next)
|
||||
|
||||
// Stored session ids with a blocking prompt (clarify) waiting on the user.
|
||||
// Separate from $workingSessionIds: a session can be "working" (turn running)
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
export {
|
||||
JsonRpcGatewayClient,
|
||||
type ConnectionState,
|
||||
type GatewayClientOptions,
|
||||
type GatewayEvent,
|
||||
type GatewayEventName,
|
||||
type GatewayRequestId,
|
||||
type JsonRpcFrame,
|
||||
JsonRpcGatewayClient,
|
||||
type WebSocketLike
|
||||
} from './json-rpc-gateway'
|
||||
export {
|
||||
GatewayReauthRequiredError,
|
||||
buildHermesWebSocketUrl,
|
||||
isGatewayReauthRequired,
|
||||
resolveGatewayWsUrl,
|
||||
type GatewayAuthMode,
|
||||
GatewayReauthRequiredError,
|
||||
type GatewayWsConnection,
|
||||
type HermesWebSocketUrlOptions,
|
||||
isGatewayReauthRequired,
|
||||
resolveGatewayWsUrl,
|
||||
type ResolveGatewayWsUrlDeps,
|
||||
type WebSocketAuthParam
|
||||
} from './websocket-url'
|
||||
|
|
|
|||
|
|
@ -167,6 +167,7 @@ export class JsonRpcGatewayClient {
|
|||
|
||||
settled = true
|
||||
cleanup()
|
||||
|
||||
// Drop the half-open socket so the next connect() starts clean
|
||||
// instead of short-circuiting on a zombie 'connecting' state.
|
||||
if (this.socket === socket) {
|
||||
|
|
@ -178,6 +179,7 @@ export class JsonRpcGatewayClient {
|
|||
|
||||
this.socket = null
|
||||
}
|
||||
|
||||
this.setState('error')
|
||||
reject(new Error(this.options.connectErrorMessage))
|
||||
}, this.options.connectTimeoutMs)
|
||||
|
|
@ -249,6 +251,7 @@ export class JsonRpcGatewayClient {
|
|||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let onAbort: (() => void) | undefined
|
||||
|
||||
const detach = () => {
|
||||
if (onAbort && signal) {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
|
|
@ -280,13 +283,16 @@ export class JsonRpcGatewayClient {
|
|||
if (signal) {
|
||||
onAbort = () => {
|
||||
const call = this.pending.get(id)
|
||||
|
||||
if (call?.timer) {
|
||||
clearTimeout(call.timer)
|
||||
}
|
||||
|
||||
this.pending.delete(id)
|
||||
detach()
|
||||
reject(new DOMException('Aborted', 'AbortError'))
|
||||
}
|
||||
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ function normalizeBasePath(basePath: string | undefined): string {
|
|||
}
|
||||
|
||||
const withLead = basePath.startsWith('/') ? basePath : `/${basePath}`
|
||||
|
||||
return withLead.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -86,7 +86,6 @@ describe('App.handleReadable error recovery (issue #31486)', () => {
|
|||
|
||||
void chunk
|
||||
})
|
||||
|
||||
;(app as any).handleReadable()
|
||||
|
||||
// First handler run threw mid-loop. The remaining chunk is still in
|
||||
|
|
@ -110,7 +109,6 @@ describe('App.handleReadable error recovery (issue #31486)', () => {
|
|||
;(app as any).rawModeEnabledCount = 0
|
||||
throw new Error('synthetic')
|
||||
})
|
||||
|
||||
;(app as any).handleReadable()
|
||||
vi.runAllTimers()
|
||||
|
||||
|
|
|
|||
|
|
@ -60,7 +60,12 @@ describe('approvalAction — pure key dispatch for ApprovalPrompt', () => {
|
|||
})
|
||||
|
||||
it('offers only once and deny for Smart DENY owner override', () => {
|
||||
const opts = approvalOptions({ allowPermanent: true, command: 'rm -rf /', description: 'blocked', smartDenied: true })
|
||||
const opts = approvalOptions({
|
||||
allowPermanent: true,
|
||||
command: 'rm -rf /',
|
||||
description: 'blocked',
|
||||
smartDenied: true
|
||||
})
|
||||
|
||||
expect(opts).toEqual(['once', 'deny'])
|
||||
expect(approvalAction('2', {}, 0, opts)).toEqual({ kind: 'choose', choice: 'deny' })
|
||||
|
|
|
|||
|
|
@ -39,12 +39,15 @@ const uiTuiRoot = resolve(here, '..', '..')
|
|||
const bundlePath = resolve(uiTuiRoot, 'dist', 'entry.js')
|
||||
|
||||
function bundleIsFresh(): boolean {
|
||||
if (!existsSync(bundlePath)) return false
|
||||
if (!existsSync(bundlePath)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const bundleMtime = statSync(bundlePath).mtimeMs
|
||||
const sourceMtime = statSync(
|
||||
resolve(uiTuiRoot, 'packages/hermes-ink/src/entry-exports.ts'),
|
||||
).mtimeMs
|
||||
|
||||
const sourceMtime = statSync(resolve(uiTuiRoot, 'packages/hermes-ink/src/entry-exports.ts')).mtimeMs
|
||||
|
||||
return bundleMtime >= sourceMtime
|
||||
} catch {
|
||||
return false
|
||||
|
|
@ -57,16 +60,13 @@ beforeAll(() => {
|
|||
if (!bundleIsFresh()) {
|
||||
// Refresh the bundle so the regression test runs against current
|
||||
// sources, not whatever was last committed by hand.
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[resolve(uiTuiRoot, 'scripts/build.mjs')],
|
||||
{
|
||||
cwd: uiTuiRoot,
|
||||
stdio: ['ignore', 'ignore', 'inherit'],
|
||||
timeout: 120_000,
|
||||
},
|
||||
)
|
||||
execFileSync(process.execPath, [resolve(uiTuiRoot, 'scripts/build.mjs')], {
|
||||
cwd: uiTuiRoot,
|
||||
stdio: ['ignore', 'ignore', 'inherit'],
|
||||
timeout: 120_000
|
||||
})
|
||||
}
|
||||
|
||||
bundleSrc = readFileSync(bundlePath, 'utf8')
|
||||
}, 180_000)
|
||||
|
||||
|
|
@ -79,7 +79,10 @@ describe('TUI bundle (issue #31227)', () => {
|
|||
// module in a circular graph hangs forever the first time it's
|
||||
// entered.
|
||||
const matches = bundleSrc.match(/async "(packages|src|node_modules)\/[^"]+"\s*\(\)/g) ?? []
|
||||
expect(matches, `Found ${matches.length} async __esm wrappers — these can deadlock #31227. First few:\n${matches.slice(0, 3).join('\n')}`).toEqual([])
|
||||
expect(
|
||||
matches,
|
||||
`Found ${matches.length} async __esm wrappers — these can deadlock #31227. First few:\n${matches.slice(0, 3).join('\n')}`
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('does not bundle the upstream ink package or ink-text-input', () => {
|
||||
|
|
|
|||
|
|
@ -21,9 +21,7 @@ describe('ModelPicker provider filtering', () => {
|
|||
})
|
||||
|
||||
it('returns -1 when provider is undefined', () => {
|
||||
const rows = [
|
||||
{ name: 'A', provider: provider('a') }
|
||||
]
|
||||
const rows = [{ name: 'A', provider: provider('a') }]
|
||||
|
||||
expect(providerIndexAfterClearingFilter(rows, undefined)).toBe(-1)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { busyIndicatorWidth, StatusBarSegments, statusBarSegments, statusRuleWidths } from '../components/appChrome.js'
|
||||
import type { StatusBarSegments } from '../components/appChrome.js'
|
||||
import { busyIndicatorWidth, statusBarSegments, statusRuleWidths } from '../components/appChrome.js'
|
||||
|
||||
describe('statusRuleWidths', () => {
|
||||
it('keeps the status rule within the terminal width', () => {
|
||||
|
|
@ -68,7 +69,7 @@ describe('statusBarSegments', () => {
|
|||
compressions: true,
|
||||
voice: true,
|
||||
bg: true,
|
||||
subagents: true,
|
||||
subagents: true
|
||||
} satisfies StatusBarSegments)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { cursorLayout } from '../lib/inputMetrics.js'
|
||||
import { fastAppendEffect, fastBackspaceEffect, resolveCursorLayout } from '../components/textInput.js'
|
||||
import { cursorLayout } from '../lib/inputMetrics.js'
|
||||
|
||||
// Closes Copilot follow-up on PR #26717: the original cursor-drift
|
||||
// fix bumped Ink's displayCursor / cursorDeclaration on fast-echo, but
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ describe('resume scroll settle', () => {
|
|||
let sticky = true
|
||||
let lastManualScrollAt = 0
|
||||
const scrollToBottom = vi.fn()
|
||||
|
||||
const cancel = scheduleResumeScrollToBottom(
|
||||
{
|
||||
current: {
|
||||
|
|
@ -101,6 +102,7 @@ describe('resume scroll settle', () => {
|
|||
it('cancels pending resume snaps', () => {
|
||||
vi.useFakeTimers()
|
||||
const scrollToBottom = vi.fn()
|
||||
|
||||
const cancel = scheduleResumeScrollToBottom(
|
||||
{
|
||||
current: {
|
||||
|
|
@ -122,6 +124,7 @@ describe('resume scroll settle', () => {
|
|||
vi.useFakeTimers()
|
||||
let sticky = false
|
||||
const scrollToBottom = vi.fn()
|
||||
|
||||
const cancel = scheduleResumeScrollToBottom(
|
||||
{
|
||||
current: {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { patchUiState } from '../../uiStore.js'
|
|||
import type { SlashCommand } from '../types.js'
|
||||
|
||||
const TUI_SESSION_MODEL_RE = new RegExp(`(?:^|\\s)${TUI_SESSION_MODEL_FLAG}(?:\\s|$)`)
|
||||
|
||||
const modelValueForConfigSet = (arg: string) => {
|
||||
const trimmed = arg.trim()
|
||||
|
||||
|
|
|
|||
|
|
@ -70,21 +70,23 @@ export function submitPrompt(text: string, deps: SubmitPromptDeps, showUserMessa
|
|||
turnController.bufRef = ''
|
||||
turnController.interrupted = false
|
||||
|
||||
deps.gw.request<PromptSubmitResponse>('prompt.submit', { session_id: liveSid, text: submitText }).catch((e: Error) => {
|
||||
// Defensive: prompt.submit no longer rejects a mid-turn send with
|
||||
// "session busy" (the gateway queues it and returns success), but keep
|
||||
// the re-queue path as a safety net for any future/legacy gateway that
|
||||
// still errors, so a message is never silently dropped.
|
||||
if (isSessionBusyError(e)) {
|
||||
deps.enqueue(submitText)
|
||||
patchUiState({ busy: true, status: 'queued for next turn' })
|
||||
deps.gw
|
||||
.request<PromptSubmitResponse>('prompt.submit', { session_id: liveSid, text: submitText })
|
||||
.catch((e: Error) => {
|
||||
// Defensive: prompt.submit no longer rejects a mid-turn send with
|
||||
// "session busy" (the gateway queues it and returns success), but keep
|
||||
// the re-queue path as a safety net for any future/legacy gateway that
|
||||
// still errors, so a message is never silently dropped.
|
||||
if (isSessionBusyError(e)) {
|
||||
deps.enqueue(submitText)
|
||||
patchUiState({ busy: true, status: 'queued for next turn' })
|
||||
|
||||
return deps.sys(`queued: "${submitText.slice(0, 50)}${submitText.length > 50 ? '…' : ''}"`)
|
||||
}
|
||||
return deps.sys(`queued: "${submitText.slice(0, 50)}${submitText.length > 50 ? '…' : ''}"`)
|
||||
}
|
||||
|
||||
deps.sys(`error: ${e.message}`)
|
||||
patchUiState({ busy: false, status: 'ready' })
|
||||
})
|
||||
deps.sys(`error: ${e.message}`)
|
||||
patchUiState({ busy: false, status: 'ready' })
|
||||
})
|
||||
}
|
||||
|
||||
// Always ask the backend whether this looks like a file drop. The backend's
|
||||
|
|
|
|||
|
|
@ -707,8 +707,7 @@ class TurnController {
|
|||
// committed entry rather than merging into streaming reasoning.
|
||||
this.closeReasoningSegment()
|
||||
|
||||
const header =
|
||||
index && count ? `◇ Reference ${index}/${count} — ${label}` : `◇ Reference — ${label}`
|
||||
const header = index && count ? `◇ Reference ${index}/${count} — ${label}` : `◇ Reference — ${label}`
|
||||
|
||||
const body = text.trim()
|
||||
const thinking = body ? `${header}\n${body}` : header
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export const scheduleResumeScrollToBottom = (
|
|||
delays: readonly number[] = [0, 80, 240]
|
||||
) => {
|
||||
const startedAt = Date.now()
|
||||
|
||||
const timers = delays.map((delay, index) =>
|
||||
setTimeout(() => {
|
||||
const scroll = scrollRef.current
|
||||
|
|
@ -148,6 +149,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
|
|||
targetSid ? rpc<SessionCloseResponse>('session.close', { session_id: targetSid }) : Promise.resolve(null),
|
||||
[rpc]
|
||||
)
|
||||
|
||||
const cancelResumeScrollRef = useRef<null | (() => void)>(null)
|
||||
|
||||
const resetSession = useCallback(() => {
|
||||
|
|
@ -378,7 +380,6 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
|
|||
if (previousSid && previousSid !== r.session_id) {
|
||||
void closeSession(previousSid)
|
||||
}
|
||||
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
sys(`error: ${e.message}`)
|
||||
|
|
|
|||
|
|
@ -31,17 +31,8 @@ const spliceMatches = (text: string, matches: RegExpMatchArray[], results: strin
|
|||
matches.reduceRight((acc, m, i) => acc.slice(0, m.index!) + results[i] + acc.slice(m.index! + m[0].length), text)
|
||||
|
||||
export function useSubmission(opts: UseSubmissionOptions) {
|
||||
const {
|
||||
appendMessage,
|
||||
composerActions,
|
||||
composerRefs,
|
||||
composerState,
|
||||
gw,
|
||||
setLastUserMsg,
|
||||
slashRef,
|
||||
submitRef,
|
||||
sys
|
||||
} = opts
|
||||
const { appendMessage, composerActions, composerRefs, composerState, gw, setLastUserMsg, slashRef, submitRef, sys } =
|
||||
opts
|
||||
|
||||
const lastEmptyAt = useRef(0)
|
||||
const typingIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
|
|
|||
|
|
@ -92,7 +92,14 @@ export const PetPane = memo(function PetPane() {
|
|||
}
|
||||
|
||||
return (
|
||||
<NoSelect bottom={PET_BOTTOM} flexShrink={0} paddingLeft={PET_PAD_LEFT} paddingTop={1} position="absolute" right={PET_RIGHT}>
|
||||
<NoSelect
|
||||
bottom={PET_BOTTOM}
|
||||
flexShrink={0}
|
||||
paddingLeft={PET_PAD_LEFT}
|
||||
paddingTop={1}
|
||||
position="absolute"
|
||||
right={PET_RIGHT}
|
||||
>
|
||||
{kitty ? <PetKitty color={kitty.color} placeholder={kitty.placeholder} /> : null}
|
||||
{!kitty && grid ? <PetSprite grid={grid} /> : null}
|
||||
</NoSelect>
|
||||
|
|
|
|||
|
|
@ -181,6 +181,7 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
|
|||
|
||||
const doDelete = () => {
|
||||
const node = activeNode
|
||||
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
|
@ -204,18 +205,22 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
|
|||
|
||||
const doEdit = async () => {
|
||||
const node = activeNode
|
||||
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
|
||||
try {
|
||||
const detail = await gw.request<NodeDetail>('learning.detail', { id: node.id })
|
||||
|
||||
if (!detail.ok || detail.content == null) {
|
||||
return setNotice(detail.message || 'cannot edit')
|
||||
}
|
||||
|
||||
const edited = await openInEditor(detail.content, detail.kind === 'skill' ? '.md' : '.txt')
|
||||
|
||||
if (edited == null || edited.trim() === detail.content.trim()) {
|
||||
return setNotice('no changes')
|
||||
}
|
||||
|
|
@ -450,7 +455,7 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
|
|||
<Text bold color={t.color.primary}>
|
||||
✦ Journey
|
||||
</Text>
|
||||
<Text color={t.color.muted}> learned skills & memories over time</Text>
|
||||
<Text color={t.color.muted}> learned skills & memories over time</Text>
|
||||
</Text>
|
||||
<Text wrap="wrap">
|
||||
{data.legend.map((item, i) => (
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ type Stage = 'provider' | 'key' | 'model' | 'disconnect'
|
|||
|
||||
type ProviderRow = { name: string; provider: ModelOptionProvider }
|
||||
|
||||
export function providerIndexAfterClearingFilter(providerRows: ProviderRow[], provider: ModelOptionProvider | undefined) {
|
||||
export function providerIndexAfterClearingFilter(
|
||||
providerRows: ProviderRow[],
|
||||
provider: ModelOptionProvider | undefined
|
||||
) {
|
||||
if (!provider) {
|
||||
return -1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -692,7 +692,13 @@ export type GatewayEvent =
|
|||
type: 'clarify.request'
|
||||
}
|
||||
| {
|
||||
payload: { allow_permanent?: boolean; choices?: string[]; command: string; description: string; smart_denied?: boolean }
|
||||
payload: {
|
||||
allow_permanent?: boolean
|
||||
choices?: string[]
|
||||
command: string
|
||||
description: string
|
||||
smart_denied?: boolean
|
||||
}
|
||||
session_id?: string
|
||||
type: 'approval.request'
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue