diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index df43d4101b..34c0eac45c 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -297,13 +297,15 @@ def _working_attempt_is_current(compressor: Any, generation: Any) -> bool: """True when *generation* is still the last attempt that began summary work. Without a published marker (attribute-less compressor, or the attempt never reached - dispatch) supersession falls back to the entry-generation ownership check.""" + dispatch) supersession falls back to the entry-generation ownership check; a compressor + that no attempt has ever claimed cannot have been superseded.""" if not generation: return True with _COMPRESSOR_ATTEMPT_LOCK: marker = getattr(compressor, "_compression_working_attempt_generation", None) if marker is None: - return int(getattr(compressor, "_compression_attempt_generation", 0) or 0) == generation + entry_generation = int(getattr(compressor, "_compression_attempt_generation", 0) or 0) + return not entry_generation or entry_generation == generation return int(marker) == int(generation) diff --git a/apps/desktop/electron/app-icon.test.ts b/apps/desktop/electron/app-icon.test.ts index 1be8c572d5..580d569254 100644 --- a/apps/desktop/electron/app-icon.test.ts +++ b/apps/desktop/electron/app-icon.test.ts @@ -5,7 +5,7 @@ import path from 'node:path' import { test } from 'vitest' -import { appIconCandidates, decodingFileProbe, resolveAppIcon } from './app-icon' +import { appIconCandidates, decodingFileProbe, resolveAppIcon, shouldOverrideDockIcon } from './app-icon' // Regression: a packaged app.asar can contain a TRUNCATED apple-touch-icon.png // (interrupted electron-builder run, partial copy). Electron's @@ -52,6 +52,49 @@ test('decodingFileProbe rejects a directory', () => { } }) +// #96857: on a packaged build every path inside app.asar is stat'ed through Electron's asar +// fs.Stats shim, which emits Node's DEP0180 ("fs.Stats constructor is deprecated") on every launch. +// A packaged tree always has real on-disk icons (dist/** is asarUnpack'ed; extraResources copies +// icon.ico to resources/), so resolving the ladder must never probe inside the archive, even though +// the packed copies exist too. +test('resolving a packaged icon never probes a path inside app.asar', () => { + const resources = path.join('/opt', 'Hermes', 'resources') + const appRoot = path.join(resources, 'app.asar') + const unpackedPathFor = (p: string) => p.replace(/app\.asar(?=$|[\\/])/, 'app.asar.unpacked') + const unpackedIcon = path.join(unpackedPathFor(appRoot), 'dist', 'apple-touch-icon.png') + + const resourcesIco = path.join(resources, 'icon.ico') + + // package.json build: files (packed) + asarUnpack dist/** + extraResources icon.ico. + const shipped = new Set([ + resourcesIco, + path.join(appRoot, 'assets', 'icon.ico'), + path.join(appRoot, 'public', 'apple-touch-icon.png'), + path.join(appRoot, 'dist', 'apple-touch-icon.png'), + unpackedIcon + ]) + + for (const isWindows of [false, true]) { + const probed: string[] = [] + + const picked = resolveAppIcon( + appIconCandidates({ isWindows, appRoot, resourcesPath: resources, unpackedPathFor }), + p => { + probed.push(p) + + return shipped.has(p) + } + ) + + assert.equal(picked, isWindows ? resourcesIco : unpackedIcon) + assert.deepEqual( + probed.filter(p => p.startsWith(appRoot + path.sep)), + [], + `isWindows=${isWindows}` + ) + } +}) + test('appIconCandidates keeps the documented precedence ladder', () => { const mac = appIconCandidates({ isWindows: false, @@ -60,9 +103,9 @@ test('appIconCandidates keeps the documented precedence ladder', () => { }) assert.deepEqual(mac, [ + path.join('/Applications/Hermes.app/Contents/Resources.unpacked', 'dist', 'apple-touch-icon.png'), path.join('/Applications/Hermes.app/Contents/Resources', 'public', 'apple-touch-icon.png'), - path.join('/Applications/Hermes.app/Contents/Resources', 'dist', 'apple-touch-icon.png'), - path.join('/Applications/Hermes.app/Contents/Resources.unpacked', 'dist', 'apple-touch-icon.png') + path.join('/Applications/Hermes.app/Contents/Resources', 'dist', 'apple-touch-icon.png') ]) // Windows prepends the two full-bleed .ico rungs ahead of the PNG ladder. @@ -81,3 +124,22 @@ test('appIconCandidates keeps the documented precedence ladder', () => { 'resources/ icon.ico is the highest-precedence Windows rung' ) }) + +// #73195: a runtime app.dock.setIcon(png) replaces the bundle's icon for the +// life of the process, so macOS 26 cannot apply the Clear/Tinted Liquid Glass +// styles to it. A packaged .app already carries the Hermes icon; only dev runs +// (the stock Electron bundle) need the runtime override. +test('shouldOverrideDockIcon leaves a packaged macOS app on its bundle icon', () => { + assert.equal(shouldOverrideDockIcon({ platform: 'darwin', isPackaged: true }), false) +}) + +test('shouldOverrideDockIcon still brands the dock in a macOS dev run', () => { + assert.equal(shouldOverrideDockIcon({ platform: 'darwin', isPackaged: false }), true) +}) + +test('shouldOverrideDockIcon never applies off macOS (there is no dock)', () => { + for (const platform of ['win32', 'linux'] as const) { + assert.equal(shouldOverrideDockIcon({ platform, isPackaged: false }), false) + assert.equal(shouldOverrideDockIcon({ platform, isPackaged: true }), false) + } +}) diff --git a/apps/desktop/electron/app-icon.ts b/apps/desktop/electron/app-icon.ts index 975638a32a..a7e763414f 100644 --- a/apps/desktop/electron/app-icon.ts +++ b/apps/desktop/electron/app-icon.ts @@ -78,8 +78,25 @@ export function appIconCandidates(opts: { return [ ...(isWindows ? [path.join(resourcesPath ?? '', 'icon.ico'), path.join(appRoot, 'assets', 'icon.ico')] : []), + // The unpacked on-disk copy first (dist/** is asarUnpack'ed), as resolveWebDist() does: statting + // any path inside app.asar goes through Electron's asar fs.Stats shim, which emits Node's DEP0180 + // on every packaged launch (#96857). In a dev tree unpackedPathFor is the identity. + path.join(unpackedPathFor(appRoot), 'dist', 'apple-touch-icon.png'), path.join(appRoot, 'public', 'apple-touch-icon.png'), - path.join(appRoot, 'dist', 'apple-touch-icon.png'), - path.join(unpackedPathFor(appRoot), 'dist', 'apple-touch-icon.png') + path.join(appRoot, 'dist', 'apple-touch-icon.png') ] } + +/** + * Whether to replace the macOS Dock icon at runtime with `app.dock.setIcon()`. + * + * A packaged `.app` already carries the Hermes icon in its bundle + * (electron-builder `build.icon`). Overriding it with a flat PNG at runtime + * hides that bundle icon for the life of the process, so macOS 26 can't apply + * the Clear/Tinted Liquid Glass styles to it (#73195). Only a dev run needs the + * override: there the bundle is the stock Electron.app and the Dock would show + * the Electron logo. + */ +export function shouldOverrideDockIcon(opts: { platform: NodeJS.Platform; isPackaged: boolean }): boolean { + return opts.platform === 'darwin' && !opts.isPackaged +} diff --git a/apps/desktop/electron/connection-registry.test.ts b/apps/desktop/electron/connection-registry.test.ts index de49aba630..2c37e4179f 100644 --- a/apps/desktop/electron/connection-registry.test.ts +++ b/apps/desktop/electron/connection-registry.test.ts @@ -1082,6 +1082,53 @@ test('token only persists on token-auth remotes; oauth/cloud drop it', () => { assert.equal(cloud.token, undefined) }) +test('a cloud entry is saved as oauth even when the payload says token (#89529)', () => { + // Cloud never keeps a pasted token (above), so token auth would leave the + // entry with no credential and Test failing with "no saved session token". + for (const authMode of [undefined, 'token'] as const) { + const cloud = normalizeConnectionInput( + { kind: 'cloud', label: 'C', url: 'https://c.hermes.cloud', authMode, token: { enc: 'x' } }, + emptyRegistry() + ) + + assert.equal(cloud.authMode, 'oauth') + assert.equal(cloud.token, undefined) + } + + // Remote keeps its explicit choice and its token default. + const remote = normalizeConnectionInput({ kind: 'remote', label: 'R', url: 'http://r:1' }, emptyRegistry()) + + assert.equal(remote.authMode, 'token') +}) + +test('a stored cloud entry left on token auth with no token reads back as oauth (#89529)', () => { + const registry = normalizeRegistry({ + version: REGISTRY_VERSION, + primary: 'local', + connections: [ + { id: 'local', kind: 'local', label: 'This device' }, + { id: 'cloud-bare', kind: 'cloud', label: 'Bare cloud', url: 'https://a.hermes.cloud', authMode: 'token' }, + { + id: 'cloud-keyed', + kind: 'cloud', + label: 'Keyed cloud', + url: 'https://b.hermes.cloud', + authMode: 'token', + token: { v: 1 } + }, + { id: 'homelab', kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119', authMode: 'token' } + ] + }) + + const byId = Object.fromEntries(registry.connections.map(c => [c.id, c])) + + assert.equal(byId['cloud-bare'].authMode, 'oauth') + // A real saved credential is never discarded, and remotes are untouched. + assert.equal(byId['cloud-keyed'].authMode, 'token') + assert.deepEqual(byId['cloud-keyed'].token, { v: 1 }) + assert.equal(byId.homelab.authMode, 'token') +}) + test('an ssh entry keeps its session token through a label rename', () => { // #103795, second half: saveRegistryConnection resolves the surviving // envelope (resolvePersistedRemoteToken keeps the stored one when the diff --git a/apps/desktop/electron/connection-registry.ts b/apps/desktop/electron/connection-registry.ts index 02a576356a..69b4a48ef7 100644 --- a/apps/desktop/electron/connection-registry.ts +++ b/apps/desktop/electron/connection-registry.ts @@ -875,6 +875,17 @@ export interface ConnectionInput { * uniqueness context; when `input.id` matches an existing entry this is an * edit and that entry is excluded from the label-collision check. */ +/** + * Auth mode a stored remote-shaped entry actually uses. A Hermes Cloud gateway + * signs in through its OAuth session and never keeps a pasted token (the save + * path drops one), so a cloud entry on token auth with no token has no + * credential at all and Test can only fail (#89529). Read it as oauth; a cloud + * entry that does carry a token keeps its mode. + */ +function storedAuthMode(kind: ConnectionKind, authMode: unknown, token: unknown): 'oauth' | 'token' { + return kind === 'cloud' && !token ? 'oauth' : normAuthMode(authMode) +} + export function normalizeConnectionInput(input: ConnectionInput, registry: ConnectionRegistry): RegistryConnection { const label = String(input.label || '').trim() @@ -974,7 +985,8 @@ export function normalizeConnectionInput(input: ConnectionInput, registry: Conne throw new Error(`A connection to this gateway URL already exists ("${urlDupe.label}").`) } - const authMode = normAuthMode(input.authMode) + // Cloud never stores a token (below), so it is always oauth. + const authMode = storedAuthMode(kind, input.authMode, undefined) const entry: RegistryConnection = { id, kind, label, url, authMode } // A token is only meaningful for token-auth remotes. Dropping it here is @@ -1225,7 +1237,7 @@ export function normalizeRegistry(raw: unknown): ConnectionRegistry { } clean.url = url - clean.authMode = normAuthMode(entry.authMode) + clean.authMode = storedAuthMode(kind, entry.authMode, entry.token) if (entry.token !== undefined) { clean.token = entry.token diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 1236e5d85c..6e1eb2d2a4 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -44,7 +44,7 @@ import { readStatusCode, withRetry } from './api-transport' -import { appIconCandidates, resolveAppIcon } from './app-icon' +import { appIconCandidates, resolveAppIcon, shouldOverrideDockIcon } from './app-icon' import { stageAppInstallerFile } from './app-installer-file' import { appVersionInfo, type AppVersionInfo, assertSourceUpdateChannel, packagedReleaseChannel } from './app-version' import { runAppInstallerChecker } from './appinstaller-checker' @@ -344,7 +344,7 @@ import { wireOauthSessionResponse } from './oauth-session-response' import { listWindowsProcesses, reapPackageRootedProcesses } from './package-process-reap' import { createParentStartMarkerResolver, parentWatchdogEnv } from './parent-process-identity' import { bundledPayload, installIdForRoot, type PayloadInfo } from './payload-backend' -import { registerPetOverlayIpc } from './pet-overlay-ipc' +import { placePetOverlay, registerPetOverlayIpc } from './pet-overlay-ipc' import { pendingNotice as pendingPluginCompatNotice, recordDismissed as recordPluginCompatDismissed @@ -397,6 +397,7 @@ import { import { migrateActiveProfileIfMissing as migrateActiveProfileIfMissingPure } from './profile-migration' import { prepareProfileRenameLifecycle, profileRenameFromRequest } from './profile-rename-routing' import { + assembleSidebarSessionSlices, buildSidebarSessionSliceParams, fetchPrimaryProfileSessions, fetchRegistrySessionRows, @@ -409,7 +410,7 @@ import { } from './profile-session-routing' import { createQuickEntryShortcut, quickEntryWindowBounds, sanitizeQuickEntrySettings } from './quick-entry' import { createQuitFinalization } from './quit-finalization' -import { type ActiveWork, mergeActiveWork, normalizeActiveWork, quitPromptFor } from './quit-guard' +import { type ActiveWork, backendOwnedByApp, mergeActiveWork, normalizeActiveWork, quitPromptFor } from './quit-guard' import { backendQuitNeedsWait, createQuitTeardownCoordinator, type QuitTeardownTask } from './quit-teardown' import * as remoteLifecycle from './remote-lifecycle' import { @@ -430,7 +431,7 @@ import { oauthLoginLoadUrlOptions, resolveRemoteRequestHeaders } from './remote-ws-headers' -import { missingRendererAssets } from './renderer-bundle' +import { missingRendererAssets, presentRendererIndexes } from './renderer-bundle' import { planLaunchSwitches, readDesktopLaunchConfig } from './renderer-heap-flags' import { loadRendererLoadErrorPage } from './renderer-load-error-page' import { attachRendererConsoleCapture, formatRendererBoundaryReport } from './renderer-log' @@ -442,6 +443,7 @@ import { type SecretStoragePolicy, writeSecretStoragePolicy } from './secret-storage-policy' +import { selectPathsDialogProperties } from './select-paths-dialog' import { describeGitSpawnFailure, GIT_UNUSABLE, selectRunnableBinary } from './select-runnable-binary' import { buildInstanceWindowUrl, @@ -2652,8 +2654,10 @@ function looksLikeDesktopAppBinary(commandPath) { const resourcesDir = path.join(path.dirname(resolved), 'resources') + // existsSync for the archive: Electron's asar shim stats app.asar itself as a directory (so + // fileExists was always false) and constructs the deprecated fs.Stats doing it (#96857). return ( - fileExists(path.join(resourcesDir, 'app.asar')) || directoryExists(path.join(resourcesDir, 'app.asar.unpacked')) + fs.existsSync(path.join(resourcesDir, 'app.asar')) || directoryExists(path.join(resourcesDir, 'app.asar.unpacked')) ) } @@ -4257,7 +4261,8 @@ function resolveWebDist() { // so the cause isn't silent. const fallback = path.join(APP_ROOT, 'dist') - if (IS_PACKAGED && /app\.asar(?=$|[\\/])/.test(fallback) && !directoryExists(fallback)) { + // existsSync, not directoryExists: a stat inside app.asar constructs the deprecated fs.Stats (#96857). + if (IS_PACKAGED && /app\.asar(?=$|[\\/])/.test(fallback) && !fs.existsSync(fallback)) { rememberLog( `[web-dist] dashboard frontend dir resolved to an asar-internal path that ` + `is not a real directory: ${fallback}. Static routes will 404. ` + @@ -4291,7 +4296,7 @@ function resolveRendererIndexWithMissing(): { index: string; missing: string[] } // unpackedPathFor is a no-op outside an asar, so both candidates collapse // to APP_ROOT/dist and the original order is preserved. const candidates = IS_PACKAGED ? [webDistIndex, asarIndex] : [asarIndex, webDistIndex] - const present = [...new Set(candidates)].filter(fileExists) + const present = presentRendererIndexes(candidates) // index.html and the hashed chunks it names are one generation. An update // that replaces only one of the two shipped copies (app.asar vs @@ -13246,6 +13251,12 @@ registerMachineProfile() // pushes pet state over IPC (hermes:pet-overlay:state); the overlay just renders // it. Control flows back (pop-in, composer submit) via hermes:pet-overlay:control. let petOverlayWindow = null +// Set while a close is in flight: Electron's close() is async and can be +// aborted on macOS, so the window may still be alive after closePetOverlay(). +// openPetOverlay must never reuse (or leave) a closing window — otherwise two +// overlays can coexist and the orphaned one, rendering nothing, becomes an +// invisible mouse-enabled transparent region that eats desktop clicks. +let petOverlayClosing = false function petOverlayUrl() { if (DEV_SERVER) { @@ -13308,6 +13319,17 @@ function spawnPetOverlayWindow(bounds) { win.setAlwaysOnTop(true, IS_MAC ? 'floating' : 'screen-saver') win.setHiddenInMissionControl?.(true) + // The overlay is a transparent rectangle; only the sprite pixels should be + // interactive. The renderer toggles click-through as the cursor enters/ + // leaves the sprite (hermes:pet-overlay:ignore-mouse), but the window MUST + // start click-through from the main process: if the overlay page is slow to + // load, blank, or its renderer has died, a mouse-enabled transparent window + // sits over the desktop eating clicks (the invisible "dead zone" bug). The + // wake indicator already uses this spawn-time ignore pattern. forward:true + // keeps mousemove flowing to the page so the renderer can re-arm + // interactivity the moment the cursor touches a solid sprite pixel. + win.setIgnoreMouseEvents(true, { forward: true }) + try { // Electron docs: macOS may transform process type on each // setVisibleOnAllWorkspaces() call unless skipTransformProcessType=true, @@ -13333,6 +13355,8 @@ function spawnPetOverlayWindow(bounds) { installWindowRendererLifecycle(win, { kind: 'overlay', callbacks: { log: rememberLog } }) win.on('closed', () => { + petOverlayClosing = false + if (petOverlayWindow === win) { petOverlayWindow = null } @@ -13352,7 +13376,7 @@ function spawnPetOverlayWindow(bounds) { } function openPetOverlay(bounds) { - if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + if (petOverlayWindow && !petOverlayWindow.isDestroyed() && !petOverlayClosing) { if (bounds) { petOverlayWindow.setBounds({ x: Math.round(bounds.x), @@ -13367,6 +13391,14 @@ function openPetOverlay(bounds) { return petOverlayWindow } + // A previous close was requested but never finished (close() can be aborted + // on macOS) — force the stale window down before spawning a replacement so + // two overlays can never coexist. + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + petOverlayWindow.destroy() + } + + petOverlayClosing = false petOverlayWindow = spawnPetOverlayWindow(bounds) return petOverlayWindow @@ -13374,10 +13406,51 @@ function openPetOverlay(bounds) { function closePetOverlay() { if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + petOverlayClosing = true petOverlayWindow.close() } - petOverlayWindow = null + // The 'closed' handler nulls petOverlayWindow and clears the flag — do NOT + // null here: an open() racing a slow/aborted close would otherwise spawn a + // second overlay while the first is still on screen (see petOverlayClosing). +} + +// Re-home the popped-out pet after a display change: an overlay that still +// fits its display stays put, one pushed past a work-area edge is clamped back +// in, and one on no display is re-centered on the main window's display (see +// resolvePetOverlayBounds). The corrected spot is pushed +// back to the renderer via the existing 'bounds' control channel, which it +// persists for the next pop-out/restart. +function rehomePetOverlay() { + if (!petOverlayWindow || petOverlayWindow.isDestroyed() || petOverlayClosing) { + return + } + + const current = petOverlayWindow.getBounds() + const resolved = placePetOverlay(current, mainWindow) + + if (!resolved) { + return + } + + if ( + resolved.x === current.x && + resolved.y === current.y && + resolved.width === current.width && + resolved.height === current.height + ) { + return + } + + petOverlayWindow.setBounds(resolved) + // Re-assert click-through after a display-driven move so a re-home can never + // leave a mouse-enabled transparent region behind (same bug class as the + // spawn-time default above). + petOverlayWindow.setIgnoreMouseEvents(true, { forward: true }) + + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('hermes:pet-overlay:control', { type: 'bounds', bounds: resolved }) + } } // ── HUD mode ──────────────────────────────────────────────────────────────── @@ -14166,7 +14239,8 @@ function createWindow() { if (IS_MAC) { mainWindow.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION) - if (icon) { + // Packaged builds keep the bundle icon so macOS can style it (#73195). + if (icon && shouldOverrideDockIcon({ platform: process.platform, isPackaged: app.isPackaged })) { app.dock?.setIcon(icon) } } @@ -15945,19 +16019,7 @@ async function interceptSessionRequestForRemote(request) { fetchProfilesSessionSlice(messagingSp, remoteProfiles) ]) - return { - recents: { - sessions: rowsOf(recents), - total: Number(recents?.total) || 0, - profile_totals: recents?.profile_totals || {} - }, - cron: { sessions: rowsOf(cron) }, - messaging: { - sessions: rowsOf(messaging), - total: Number(messaging?.total) || rowsOf(messaging).length - }, - errors: [] - } + return assembleSidebarSessionSlices(recents, cron, messaging) } // Per-session read/mutation. Owner is in ?profile= (reads) or request.profile @@ -16578,11 +16640,7 @@ ipcMain.handle('hermes:readPluginSource', async (_event: unknown, filePath: unkn }) ipcMain.handle('hermes:selectPaths', async (_event, options: any = {}) => { - const properties = options?.directories ? ['openDirectory'] : ['openFile'] - - if (options?.multiple !== false) { - properties.push('multiSelections') - } + const properties = selectPathsDialogProperties(options || {}) let resolvedDefaultPath @@ -17928,6 +17986,18 @@ app.whenReady().then(() => { screen.on('display-removed', reposition) } + // The popped-out pet must never be stranded on a disconnected display: when + // the topology changes, pull an off-screen overlay back onto the display + // that holds the main window (and persist the corrected spot). Unlike the + // wake indicator this applies on every platform — the pet overlay exists + // everywhere, and rehomePetOverlay is a cheap no-op while the pet is in the + // window or still on-screen. + screen.on('display-added', rehomePetOverlay) + + screen.on('display-metrics-changed', rehomePetOverlay) + + screen.on('display-removed', rehomePetOverlay) + // A hard crash can interrupt the in-memory restore loop after exact remote // serves were drained. The owner-only recovery journal survives that crash; // its worker waits for the install marker to clear, then reopens every scope @@ -17982,8 +18052,39 @@ function configureSpellChecker() { } } +// Does quitting take the agent down with the app? Reads the primary profile's +// route through the same resolver resolveRemoteBackend uses, plus every backend +// the quit teardown below will stop (spawned children, SSH-managed servers). +// A route we can't resolve counts as owned: the lost-work warning is the safe +// side to be wrong on. +function quitStopsBackendWork(): boolean { + let primaryRouteKind: 'cloud' | 'remote' | 'ssh' | null + + try { + primaryRouteKind = + resolveDesktopRemoteRoute({ + config: readDesktopConnectionConfig(), + env: { + token: process.env.HERMES_DESKTOP_REMOTE_TOKEN, + url: process.env.HERMES_DESKTOP_REMOTE_URL + }, + profile: primaryProfileKey(), + registry: readDesktopConnectionsRegistry() + })?.kind ?? null + } catch { + return true + } + + const ownedBackendCount = + (backendConnectionState.getProcess() ? 1 : 0) + + [...backendPool.values()].filter(entry => entry?.process).length + + sshConnections.size + + return backendOwnedByApp({ ownedBackendCount, primaryRouteKind }) +} + // Ask before a quit kills a turn in flight. True when the quit was intercepted -// and the confirmation is on screen; "Quit Anyway" re-enters before-quit with +// and the confirmation is on screen; the confirm button re-enters before-quit with // the latch set and falls straight through to the teardown below. function heldQuitForActiveWork(event: Electron.Event): boolean { if (SKIP_QUIT_CONFIRM || quitConfirmedWithActiveWork || isQuittingForHandoff) { @@ -17996,7 +18097,11 @@ function heldQuitForActiveWork(event: Electron.Event): boolean { return true } - const prompt = quitPromptFor(mergeActiveWork(activeWorkByWebContents.values()), isQuittingForHandoff) + const prompt = quitPromptFor( + mergeActiveWork(activeWorkByWebContents.values()), + isQuittingForHandoff, + quitStopsBackendWork() + ) // A tray quit with live work still needs the ordinary visible confirmation. if (prompt && minimizeToTray.status().available) { @@ -18016,7 +18121,7 @@ function heldQuitForActiveWork(event: Electron.Event): boolean { void dialog .showMessageBox(parent, { - buttons: ['Keep Running', 'Quit Anyway'], + buttons: [...prompt.buttons], cancelId: 0, defaultId: 0, detail: prompt.detail, diff --git a/apps/desktop/electron/pet-overlay-ipc.ts b/apps/desktop/electron/pet-overlay-ipc.ts index 9738f4e8e0..dfe12c2a84 100644 --- a/apps/desktop/electron/pet-overlay-ipc.ts +++ b/apps/desktop/electron/pet-overlay-ipc.ts @@ -1,6 +1,8 @@ // IPC surface for the pop-out pet overlay (mascot window). Extracted from // main.ts; window handles stay injected because main.ts owns their lifecycle. -import { type BrowserWindow, ipcMain } from 'electron' +import { type BrowserWindow, ipcMain, screen } from 'electron' + +import { resolvePetOverlayBounds } from './pet-overlay' export interface PetOverlayIpcDeps { getMainWindow: () => BrowserWindow | null @@ -9,6 +11,23 @@ export interface PetOverlayIpcDeps { closePetOverlay: () => void } +// Where the overlay may actually sit: `bounds` kept wholly on a connected +// display, or re-centered on the main window's display when it is on none +// (see resolvePetOverlayBounds). Null for garbage bounds. +export function placePetOverlay(bounds, mainWindow: BrowserWindow | null) { + let anchor = null + + try { + if (mainWindow && !mainWindow.isDestroyed()) { + anchor = mainWindow.getContentBounds() + } + } catch { + // Resolve falls back to the primary display when the anchor is unknown. + } + + return resolvePetOverlayBounds(bounds, screen.getAllDisplays(), anchor) +} + export function registerPetOverlayIpc({ getMainWindow, getPetOverlayWindow, @@ -40,6 +59,13 @@ export function registerPetOverlayIpc({ // Fall back to raw bounds if the window geometry is unavailable. } + // Both paths land wholly inside a display's work area: a fresh pop-out near + // the window edge or a spot saved on a since-unplugged monitor would + // otherwise open the transparent, non-activating overlay (mostly) + // off-screen where the pet can't be found. The corrected bounds are echoed + // so the renderer persists the on-screen spot (self-healing). + screenBounds = placePetOverlay(screenBounds, mainWindow) ?? screenBounds + openPetOverlay(screenBounds) return { ok: true, bounds: screenBounds } @@ -62,9 +88,14 @@ export function registerPetOverlayIpc({ return } + const next = placePetOverlay(bounds, getMainWindow()) + + if (!next) { + return + } + const win = petOverlayWindow - const width = Math.max(80, Math.round(bounds.width)) - const height = Math.max(80, Math.round(bounds.height)) + const { width, height } = next const [curW, curH] = win.getSize() const resizing = width !== curW || height !== curH @@ -72,7 +103,7 @@ export function registerPetOverlayIpc({ win.setResizable(true) } - win.setBounds({ x: Math.round(bounds.x), y: Math.round(bounds.y), width, height }) + win.setBounds(next) if (resizing) { win.setResizable(false) @@ -146,6 +177,12 @@ export function registerPetOverlayIpc({ mainWindow.focus() } + // A drag/resize end reports the rect the overlay asked for; persist where + // set-bounds actually put it (clamped on-screen), not the raw request. + if (payload && payload.type === 'bounds') { + payload = { ...payload, bounds: placePetOverlay(payload.bounds, mainWindow) ?? payload.bounds } + } + mainWindow.webContents.send('hermes:pet-overlay:control', payload) }) } diff --git a/apps/desktop/electron/pet-overlay.test.ts b/apps/desktop/electron/pet-overlay.test.ts new file mode 100644 index 0000000000..e012a48a9c --- /dev/null +++ b/apps/desktop/electron/pet-overlay.test.ts @@ -0,0 +1,193 @@ +/** + * Unit tests for the pet-overlay geometry helpers. These cover the logic that + * protects the popped-out pet: a remembered spot on a since-unplugged monitor + * must never strand the overlay off-screen (the "pet vanished" bug), and a + * live re-home must leave an on-screen pet alone. + */ + +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { clampRectToWorkArea, resolvePetOverlayBounds } from './pet-overlay' + +// A laptop panel left behind after a bigger external monitor is unplugged. +const LAPTOP = [{ workArea: { x: 0, y: 0, width: 1366, height: 728 } }] + +// External monitor to the right of the laptop panel, now disconnected. +const LAPTOP_PLUS_EXTERNAL = [ + { workArea: { x: 0, y: 0, width: 1366, height: 728 } }, + { workArea: { x: 1366, y: 0, width: 1920, height: 1040 } } +] + +const ANCHOR_ON_LAPTOP = { x: 100, y: 80, width: 1200, height: 700 } +const ANCHOR_ON_EXTERNAL = { x: 1400, y: 100, width: 1200, height: 700 } + +const PET_BOUNDS = { x: 200, y: 150, width: 300, height: 400 } + +// ─── sanity / garbage ────────────────────────────────────────────────────── + +test('resolvePetOverlayBounds returns null for missing or garbage input', () => { + assert.equal(resolvePetOverlayBounds(null, LAPTOP, ANCHOR_ON_LAPTOP), null) + assert.equal(resolvePetOverlayBounds(undefined, LAPTOP, ANCHOR_ON_LAPTOP), null) + assert.equal( + resolvePetOverlayBounds({ x: NaN, y: 0, width: 100, height: 100 }, LAPTOP, ANCHOR_ON_LAPTOP), + null + ) + assert.equal( + resolvePetOverlayBounds({ x: 0, y: 0, width: 'wide', height: 100 }, LAPTOP, ANCHOR_ON_LAPTOP), + null + ) +}) + +test('resolvePetOverlayBounds returns requested unchanged with no displays to validate against', () => { + assert.equal(resolvePetOverlayBounds(PET_BOUNDS, null, ANCHOR_ON_LAPTOP), PET_BOUNDS) + assert.equal(resolvePetOverlayBounds(PET_BOUNDS, [], ANCHOR_ON_LAPTOP), PET_BOUNDS) +}) + +// ─── on-screen trust ─────────────────────────────────────────────────────── + +test('an on-screen saved spot is used as-is', () => { + assert.deepEqual(resolvePetOverlayBounds(PET_BOUNDS, LAPTOP, ANCHOR_ON_LAPTOP), PET_BOUNDS) +}) + +test('a spot on any connected display is used as-is, even if not the anchor display', () => { + const onExternal = { x: 1400, y: 200, width: 300, height: 400 } + + assert.deepEqual(resolvePetOverlayBounds(onExternal, LAPTOP_PLUS_EXTERNAL, ANCHOR_ON_LAPTOP), onExternal) +}) + +// ─── partly off-screen: clamp the whole rect in (#85092) ───────────────────── + +const within = (r, a) => r.x >= a.x && r.y >= a.y && r.x + r.width <= a.x + a.width && r.y + r.height <= a.y + a.height + +test('a first pop-out that is mostly off-screen is pulled wholly onto the display', () => { + // The #85092 repro: 1707x1067 logical work area (2560x1600 @ 150%), overlay + // spawned from a pet near the bottom-right corner with only a 49px sliver + // visible. That passes a 48px-overlap rule, so it must be clamped, not trusted. + const area = { x: 0, y: 0, width: 1707, height: 1067 } + const resolved = resolvePetOverlayBounds({ x: 1658, y: 806, width: 292, height: 408 }, [{ workArea: area }], null) + + assert.deepEqual(resolved, { x: 1707 - 292, y: 1067 - 408, width: 292, height: 408 }) +}) + +test('a sliver of overlap on any side is clamped to the nearest edge, not re-centered', () => { + for (const sliver of [ + { x: -270, y: 150, width: 300, height: 400 }, + { x: 200, y: -390, width: 300, height: 400 }, + { x: 1360, y: 700, width: 300, height: 400 } + ]) { + const resolved = resolvePetOverlayBounds(sliver, LAPTOP, ANCHOR_ON_LAPTOP) + + assert.ok(within(resolved, LAPTOP[0].workArea), JSON.stringify(resolved)) + assert.equal(resolved.width, 300) + assert.equal(resolved.height, 400) + } +}) + +test('a rect straddling two displays is clamped into the one it overlaps most', () => { + // 250px on the laptop, 50px on the external → stays on the laptop. + const straddle = { x: 1116, y: 200, width: 300, height: 400 } + const resolved = resolvePetOverlayBounds(straddle, LAPTOP_PLUS_EXTERNAL, ANCHOR_ON_EXTERNAL) + + assert.deepEqual(resolved, { x: 1366 - 300, y: 200, width: 300, height: 400 }) +}) + +test('clampRectToWorkArea keeps any rect wholly inside the work area', () => { + const area = { x: -1920, y: 25, width: 1920, height: 1055 } + + for (const rect of [ + { x: -5000, y: -5000, width: 300, height: 400 }, + { x: 5000, y: 5000, width: 300, height: 400 }, + { x: -1000, y: 500, width: 300, height: 400 }, + { x: -1920, y: 25, width: 4000, height: 3000 }, + { x: -100.6, y: 900.4, width: 299.5, height: 400.2 } + ]) { + const clamped = clampRectToWorkArea(rect, area) + + assert.ok(within(clamped, area), JSON.stringify(clamped)) + assert.ok([clamped.x, clamped.y, clamped.width, clamped.height].every(Number.isInteger)) + } + + // Already inside: unchanged. + assert.deepEqual(clampRectToWorkArea({ x: -1000, y: 500, width: 300, height: 400 }, area), { + x: -1000, + y: 500, + width: 300, + height: 400 + }) +}) + +// ─── off-screen re-home ──────────────────────────────────────────────────── + +test('a spot remembered on a since-unplugged external monitor is re-centered on the anchor display', () => { + // Saved while the external was connected (x 1366+); only the laptop remains. + const stale = { x: 1500, y: 400, width: 300, height: 400 } + + const resolved = resolvePetOverlayBounds(stale, LAPTOP, ANCHOR_ON_LAPTOP) + + assert.deepEqual(resolved, { + x: Math.round((1366 - 300) / 2), + y: Math.round((728 - 400) / 2), + width: 300, + height: 400 + }) +}) + +test('re-home centers on the display holding the anchor (main window), not the primary', () => { + const stale = { x: -800, y: 400, width: 300, height: 400 } + + const resolved = resolvePetOverlayBounds(stale, LAPTOP_PLUS_EXTERNAL, ANCHOR_ON_EXTERNAL) + + assert.deepEqual(resolved, { + x: Math.round(1366 + (1920 - 300) / 2), + y: Math.round((1040 - 400) / 2), + width: 300, + height: 400 + }) +}) + +test('re-home falls back to the primary display when the anchor is missing', () => { + const stale = { x: 1500, y: 400, width: 300, height: 400 } + + const resolved = resolvePetOverlayBounds(stale, LAPTOP, null) + + assert.deepEqual(resolved, { + x: Math.round((1366 - 300) / 2), + y: Math.round((728 - 400) / 2), + width: 300, + height: 400 + }) +}) + +test('re-homed size is capped to the target work area', () => { + const huge = { x: 1500, y: 400, width: 2000, height: 1500 } + + const resolved = resolvePetOverlayBounds(huge, LAPTOP, ANCHOR_ON_LAPTOP) + + assert.deepEqual(resolved, { + x: 0, + y: 0, + width: 1366, + height: 728 + }) +}) + +// ─── live re-home (display unplugged while the pet is popped out) ────────── + +test('a still-on-screen pet is left exactly where it is', () => { + assert.deepEqual(resolvePetOverlayBounds(PET_BOUNDS, LAPTOP, ANCHOR_ON_LAPTOP), PET_BOUNDS) +}) + +test('a pet stranded by an unplugged display is pulled back onto the anchor display', () => { + const stranded = { x: 1500, y: 400, width: 300, height: 400 } + + const resolved = resolvePetOverlayBounds(stranded, LAPTOP, ANCHOR_ON_LAPTOP) + + assert.deepEqual(resolved, { + x: Math.round((1366 - 300) / 2), + y: Math.round((728 - 400) / 2), + width: 300, + height: 400 + }) +}) diff --git a/apps/desktop/electron/pet-overlay.ts b/apps/desktop/electron/pet-overlay.ts new file mode 100644 index 0000000000..bfa227dfeb --- /dev/null +++ b/apps/desktop/electron/pet-overlay.ts @@ -0,0 +1,123 @@ +/** + * Pure geometry helpers for the popped-out pet overlay — deciding where its + * window may open and where it must be re-homed when the display topology + * changes. Side-effect-free so the on-screen validation is unit-testable + * without booting Electron; main.ts owns the live `screen` displays, the + * anchor window, and the overlay window itself. + * + * The bug this exists for: the renderer remembers the overlay's absolute + * screen position (localStorage hermes.desktop.pet-overlay-bounds.v1) and + * reuses it verbatim on the next pop-out / app restart. If that spot was on an + * external monitor that has since been unplugged, the overlay is created + * off-screen — and because it is a transparent, frameless, non-activating + * always-on-top panel hidden from Mission Control, an off-screen overlay is + * completely unfindable: the pet "vanishes." A window that only *mostly* left + * the screen is just as lost, so the overlay is kept wholly inside a work area + * rather than trusting a sliver of overlap (#85092). + */ + +import { matchingWorkArea } from './window-state' + +// Below this, a pet window is too small to be useful — mirrors the floor +// enforced in main.ts's spawnPetOverlayWindow. +const MIN_SIZE = 80 + +const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, hi)) + +/** + * Keep the WHOLE rect inside `workArea`: size is capped to the work area, then + * the origin is clamped so no edge crosses it. + */ +export function clampRectToWorkArea(rect, workArea) { + const width = clamp(Math.round(rect.width), MIN_SIZE, Math.round(workArea.width)) + const height = clamp(Math.round(rect.height), MIN_SIZE, Math.round(workArea.height)) + + return { + x: clamp(Math.round(rect.x), workArea.x, workArea.x + workArea.width - width), + y: clamp(Math.round(rect.y), workArea.y, workArea.y + workArea.height - height), + width, + height + } +} + +/** + * Resolve where the pet-overlay window may sit. A `requested` screen rect that + * overlaps any connected display is clamped wholly into the work area it + * overlaps most (the display Electron's `screen.getDisplayMatching` picks). + * + * A rect on no display at all (saved on a since-unplugged monitor) is + * re-centered on the display holding `anchor` (the main window's content + * bounds — where the user actually is), falling back to the primary display + * when the anchor is missing. + * + * Returns null for missing/garbage input (main falls back to its defaults); + * returns `requested` unchanged when there is nothing to validate against. + */ +export function resolvePetOverlayBounds(requested, displays, anchor) { + if (!requested) { + return null + } + + const { x, y, width, height } = requested + + if (![x, y, width, height].every(Number.isFinite)) { + return null + } + + const list = Array.isArray(displays) ? displays : [] + + if (!list.length) { + return requested + } + + const overlapping = matchingWorkArea({ x, y, width, height }, list, 1) + + if (overlapping) { + return clampRectToWorkArea(requested, overlapping) + } + + const area = workAreaForAnchor(list, anchor) + + if (!area) { + return requested + } + + const { width: w, height: h } = clampRectToWorkArea(requested, area) + + return { + x: Math.round(area.x + (area.width - w) / 2), + y: Math.round(area.y + (area.height - h) / 2), + width: w, + height: h + } +} + +// The work area of the display whose bounds contain the anchor's center (the +// display the main window sits on), or the first display when the anchor is +// missing/unknown. Null when there are no usable displays at all. +function workAreaForAnchor(displays, anchor) { + if ( + anchor && + Number.isFinite(anchor.x) && + Number.isFinite(anchor.y) && + Number.isFinite(anchor.width) && + Number.isFinite(anchor.height) + ) { + const cx = anchor.x + anchor.width / 2 + const cy = anchor.y + anchor.height / 2 + + const containing = displays.find(({ workArea: a }) => { + if (!a) { + return false + } + + return cx >= a.x && cx < a.x + a.width && cy >= a.y && cy < a.y + a.height + }) + + if (containing?.workArea) { + return containing.workArea + } + } + + return displays.find(({ workArea: a }) => a)?.workArea ?? null +} diff --git a/apps/desktop/electron/profile-session-routing.test.ts b/apps/desktop/electron/profile-session-routing.test.ts index 06ec8f158f..a436c33c72 100644 --- a/apps/desktop/electron/profile-session-routing.test.ts +++ b/apps/desktop/electron/profile-session-routing.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict' import { test } from 'vitest' import { + assembleSidebarSessionSlices, buildSidebarSessionSliceParams, fetchPrimaryProfileSessions, fetchRegistrySessionRows, @@ -70,12 +71,44 @@ test('primary session reads use the profile-aware request path', async () => { assert.equal(result, expected) }) -test('primary session reads preserve the empty-list fallback', async () => { - const result = await fetchPrimaryProfileSessions(new URLSearchParams({ profile: 'all' }), async () => { +// A failed primary read is an empty page the renderer must NOT treat as the +// profile's truth: without errors[] it replaced the sidebar with "No sessions" +// and nothing said why (#67600). Report the failed scope the same way the +// backend reports a failed profile scan. +test('primary session reads report a failed read in errors', async () => { + const result = await fetchPrimaryProfileSessions(new URLSearchParams({ profile: 'default' }), async () => { throw new Error('remote unavailable') }) - assert.deepEqual(result, { sessions: [], total: 0, profile_totals: {} }) + assert.deepEqual(result, { + sessions: [], + total: 0, + profile_totals: {}, + errors: [{ profile: 'default', error: 'remote unavailable' }] + }) +}) + +test('a failed unified primary read names the whole aggregate', async () => { + const result = await fetchPrimaryProfileSessions(new URLSearchParams({ limit: '20' }), async () => { + throw new Error('timed out') + }) + + assert.deepEqual(result.errors, [{ profile: 'all', error: 'timed out' }]) +}) + +test('reassembled sidebar slices keep each slice errors', () => { + const failed = [{ profile: 'default', error: 'timed out' }] + + const result = assembleSidebarSessionSlices( + { sessions: [], total: 0, profile_totals: {}, errors: failed }, + { sessions: [{ id: 'cron-1' }], total: 1 }, + { sessions: [], total: 0, errors: failed } + ) + + assert.deepEqual(result.recents.errors, failed) + assert.equal(result.cron.errors, undefined) + assert.deepEqual(result.messaging.errors, failed) + assert.deepEqual(result.cron.sessions, [{ id: 'cron-1' }]) }) test('remote session reads split oversized sidebar windows into API-safe pages', async () => { diff --git a/apps/desktop/electron/profile-session-routing.ts b/apps/desktop/electron/profile-session-routing.ts index 52677ba60c..83d083601d 100644 --- a/apps/desktop/electron/profile-session-routing.ts +++ b/apps/desktop/electron/profile-session-routing.ts @@ -164,15 +164,63 @@ export function buildSidebarSessionSliceParams(searchParams: URLSearchParams): S } } -/** Fetch the primary backend's profile-aware session slice, falling back to an empty result when unavailable. */ +interface SessionScanError { + profile: string + error: string +} + +function errorsOf(data: unknown): SessionScanError[] | undefined { + const errors = data && typeof data === 'object' ? (data as { errors?: unknown }).errors : undefined + + return Array.isArray(errors) && errors.length ? (errors as SessionScanError[]) : undefined +} + +/** Fetch the primary backend's profile-aware session slice. A failed read is + * still an empty page, but it carries `errors` naming the requested scope + * (`all` for the unified list), like the backend's failed profile scan, so the + * renderer keeps the rows it could not re-read instead of clearing them. */ export async function fetchPrimaryProfileSessions( searchParams: URLSearchParams, fetchJsonForProfile: FetchJsonForProfile ): Promise { try { return (await fetchJsonForProfile(null, `/api/profiles/sessions?${searchParams}`)) as ProfileSessionsResponse - } catch { - return { sessions: [], total: 0, profile_totals: {} } + } catch (error) { + const profile = (searchParams.get('profile') || '').trim() || 'all' + + return { + sessions: [], + total: 0, + profile_totals: {}, + errors: [{ profile, error: error instanceof Error ? error.message : String(error) }] + } + } +} + +/** Reassemble the batched sidebar response from its three per-slice reads, + * keeping each slice's `errors` so a failed scan is never read as an + * authoritative empty slice. */ +export function assembleSidebarSessionSlices(recents: unknown, cron: unknown, messaging: unknown) { + const slice = (data: unknown) => { + const errors = errorsOf(data) + + return { sessions: rowsOf(data), ...(errors ? { errors } : {}) } + } + + const recentsSlice = recents as Partial | undefined + + return { + recents: { + ...slice(recents), + total: Number(recentsSlice?.total) || 0, + profile_totals: recentsSlice?.profile_totals || {} + }, + cron: slice(cron), + messaging: { + ...slice(messaging), + total: Number((messaging as Partial | undefined)?.total) || rowsOf(messaging).length + }, + errors: [] } } diff --git a/apps/desktop/electron/quit-guard.test.ts b/apps/desktop/electron/quit-guard.test.ts index fdef3c5b05..58c91150d9 100644 --- a/apps/desktop/electron/quit-guard.test.ts +++ b/apps/desktop/electron/quit-guard.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { test } from 'vitest' -import { mergeActiveWork, normalizeActiveWork, quitPromptFor } from './quit-guard' +import { backendOwnedByApp, mergeActiveWork, normalizeActiveWork, quitPromptFor } from './quit-guard' test('normalizeActiveWork drops junk and keeps the count at least the title count', () => { assert.deepEqual(normalizeActiveWork(null), { count: 0, titles: [] }) @@ -50,3 +50,46 @@ test('quitPromptFor summarizes past the list cap and counts untitled work', () = assert.ok(!prompt.detail.includes('• e')) assert.ok(prompt.detail.includes('• 5 more')) }) + +// #79579: only a backend the app owns (spawned locally, or started over SSH) +// dies with it. A remote URL or Hermes Cloud backend keeps the turn running +// after the app quits, so the prompt must not claim the work is lost. +test('backendOwnedByApp: a local primary is owned even before its child attaches', () => { + assert.equal(backendOwnedByApp({ ownedBackendCount: 0, primaryRouteKind: null }), true) +}) + +test('backendOwnedByApp: an SSH primary is owned (the app starts and stops that server)', () => { + assert.equal(backendOwnedByApp({ ownedBackendCount: 0, primaryRouteKind: 'ssh' }), true) +}) + +test('backendOwnedByApp: a remote URL or cloud primary with nothing spawned is not owned', () => { + assert.equal(backendOwnedByApp({ ownedBackendCount: 0, primaryRouteKind: 'remote' }), false) + assert.equal(backendOwnedByApp({ ownedBackendCount: 0, primaryRouteKind: 'cloud' }), false) +}) + +test('backendOwnedByApp: a remote primary alongside a spawned backend stays owned', () => { + // Another window/profile may be running its turn on that local child. + assert.equal(backendOwnedByApp({ ownedBackendCount: 1, primaryRouteKind: 'remote' }), true) +}) + +test('quitPromptFor warns about lost work when the app owns the backend (local)', () => { + const owned = backendOwnedByApp({ ownedBackendCount: 1, primaryRouteKind: null }) + const prompt = quitPromptFor({ count: 1, titles: ['Fix login'] }, false, owned) + + assert.ok(prompt) + assert.ok(prompt.detail.includes('is lost')) + assert.deepEqual(prompt.buttons, ['Keep Running', 'Quit Anyway']) +}) + +for (const primaryRouteKind of ['remote', 'cloud'] as const) { + test(`quitPromptFor says the agent keeps running on a ${primaryRouteKind} backend`, () => { + const owned = backendOwnedByApp({ ownedBackendCount: 0, primaryRouteKind }) + const prompt = quitPromptFor({ count: 1, titles: ['Fix login'] }, false, owned) + + assert.ok(prompt) + assert.ok(prompt.detail.includes('• Fix login')) + assert.ok(!prompt.detail.includes('lost'), 'a backend that outlives the app loses nothing') + assert.ok(prompt.detail.includes('keeps running')) + assert.notDeepEqual(prompt.buttons, ['Keep Running', 'Quit Anyway']) + }) +} diff --git a/apps/desktop/electron/quit-guard.ts b/apps/desktop/electron/quit-guard.ts index e2bb40e59d..aee55e6575 100644 --- a/apps/desktop/electron/quit-guard.ts +++ b/apps/desktop/electron/quit-guard.ts @@ -3,6 +3,10 @@ // Renderers publish what they're running; the main process asks before it lets // that go. The decision + copy live here (pure, testable) so main.ts only owns // the IPC and the dialog call. +// +// That's only true for a backend the app owns. A remote URL or Hermes Cloud +// backend is supervised elsewhere and finishes the turn after the app quits, +// so its prompt says so instead of warning about lost work (#79579). const MAX_LISTED = 4 @@ -54,18 +58,47 @@ export function mergeActiveWork(reports: Iterable): ActiveWork { } export interface QuitPrompt { + /** [cancel, confirm]: index 0 keeps the app open, index 1 quits. */ + buttons: readonly [string, string] detail: string message: string } +export interface BackendOwnershipInput { + /** + * Backends this app stops when it quits: spawned local children plus + * SSH-managed servers, across the primary and every pooled profile. + */ + ownedBackendCount: number + /** What the primary profile resolves to; null means a locally spawned backend. */ + primaryRouteKind: 'cloud' | 'remote' | 'ssh' | null +} + +/** + * Whether quitting takes the agent down with the app. Local and SSH backends + * are started and stopped by the app. A remote URL or cloud backend is not, + * but any other backend the app spawned (another window's connection, a + * pooled profile) might be where the turn is running, so it still counts. + */ +export function backendOwnedByApp({ ownedBackendCount, primaryRouteKind }: BackendOwnershipInput): boolean { + return primaryRouteKind === null || primaryRouteKind === 'ssh' || ownedBackendCount > 0 +} + /** * The confirmation to show, or null when quitting should just proceed. * * `quittingForHandoff` covers the update / swap / uninstall relaunches: those * are the app replacing itself, not the user walking away, and a modal there * would strand the detached script waiting on a PID that never exits. + * + * `backendOwned` (see backendOwnedByApp) picks the copy: an owned backend dies + * with the app, a remote/cloud one keeps working after it closes. */ -export function quitPromptFor(work: ActiveWork, quittingForHandoff: boolean): null | QuitPrompt { +export function quitPromptFor( + work: ActiveWork, + quittingForHandoff: boolean, + backendOwned: boolean = true +): null | QuitPrompt { if (quittingForHandoff || work.count < 1) { return null } @@ -79,10 +112,13 @@ export function quitPromptFor(work: ActiveWork, quittingForHandoff: boolean): nu } return { + buttons: backendOwned ? ['Keep Running', 'Quit Anyway'] : ['Cancel', 'Quit'], detail: [ lines.join('\n'), lines.length > 0 ? '' : null, - 'Quitting stops the agent mid-turn. Any work it has not finished writing is lost.' + backendOwned + ? 'Quitting stops the agent mid-turn. Any work it has not finished writing is lost.' + : 'The agent keeps running on the remote backend. Quitting only closes Hermes on this computer; reconnect later to see the results.' ] .filter(line => line !== null) .join('\n') diff --git a/apps/desktop/electron/renderer-bundle.test.ts b/apps/desktop/electron/renderer-bundle.test.ts index b4c763a1b4..8443a7b5cf 100644 --- a/apps/desktop/electron/renderer-bundle.test.ts +++ b/apps/desktop/electron/renderer-bundle.test.ts @@ -7,6 +7,7 @@ import { missingRendererAssets, parseLazyChunkRefs, parseModuleAssetRefs, + presentRendererIndexes, type RendererBundleDeps } from './renderer-bundle' @@ -124,6 +125,59 @@ test('missingRendererAssets: an unreadable index is not treated as torn', () => assert.deepEqual(missingRendererAssets(INDEX_PATH, deps), []) }) +// #96857: a stat on a path inside app.asar goes through Electron's asar shim, which constructs the +// deprecated fs.Stats and prints DEP0180 on every packaged launch. Choosing a renderer copy must +// answer from reads and existence checks alone, for the in-archive copy as much as the unpacked one. +test('renderer index probes never stat, so app.asar paths never construct fs.Stats', () => { + const resources = path.join('/opt', 'Hermes', 'resources') + const copies = ['app.asar.unpacked', 'app.asar'].map(root => path.join(resources, root, 'dist')) + const files = new Map() + + for (const dir of copies) { + files.set(path.join(dir, 'index.html'), INDEX_HTML) + + for (const chunk of ['assets/index-a1b2c3.js', 'assets/shiki-block-COiz1pEN.js']) { + files.set(path.join(dir, chunk), '') + } + } + + const statted: string[] = [] + + const stat = (file: string) => { + statted.push(file) + throw new Error('DEP0180: fs.Stats constructor is deprecated') + } + + const fsStub = { + readFileSync: (file: string) => { + const body = files.get(file) + + if (body === undefined) { + throw Object.assign(new Error(`ENOENT: ${file}`), { code: 'ENOENT' }) + } + + return body + }, + existsSync: (file: string) => files.has(file), + statSync: stat, + lstatSync: stat + } + + const [unpackedIndex, asarIndex] = copies.map(dir => path.join(dir, 'index.html')) + + const present = presentRendererIndexes( + [unpackedIndex, asarIndex, unpackedIndex, path.join('/nope', 'index.html')], + fsStub + ) + + assert.deepEqual(present, [unpackedIndex, asarIndex]) + assert.deepEqual( + present.map(index => missingRendererAssets(index, fsStub)), + [[], []] + ) + assert.deepEqual(statted, []) +}) + // --------------------------------------------------------------------------- // Lazy-chunk (__vite__mapDeps) awareness — #93479. index.html only names the // boot-critical modules; the chunks behind React.lazy() routes (syntax-diff-*, diff --git a/apps/desktop/electron/renderer-bundle.ts b/apps/desktop/electron/renderer-bundle.ts index f2ae4af46c..275cdfe80f 100644 --- a/apps/desktop/electron/renderer-bundle.ts +++ b/apps/desktop/electron/renderer-bundle.ts @@ -74,6 +74,24 @@ export interface RendererBundleDeps { existsSync?: (file: string) => boolean } +/** The readable `index.html` candidates, deduped, in order. Probed by reading, never by stat: + * statting a path inside app.asar goes through Electron's asar shim, which constructs the + * deprecated fs.Stats and prints DEP0180 on every packaged launch (#96857). */ +export function presentRendererIndexes( + candidates: readonly string[], + { readFileSync = fs.readFileSync }: RendererBundleDeps = {} +): string[] { + return [...new Set(candidates)].filter(candidate => { + try { + readFileSync(candidate, 'utf8') + + return true + } catch { + return false + } + }) +} + /** Vite's build-time graph avoids opening megabytes of lazy chunks at startup. * Only trust a manifest paired with this index's entry and preload generation. * Old builds, malformed manifests and interrupted replacements retain the diff --git a/apps/desktop/electron/select-paths-dialog.test.ts b/apps/desktop/electron/select-paths-dialog.test.ts new file mode 100644 index 0000000000..19245b9a68 --- /dev/null +++ b/apps/desktop/electron/select-paths-dialog.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' + +import { selectPathsDialogProperties } from './select-paths-dialog' + +describe('selectPathsDialogProperties', () => { + it('lets a directory picker create a new folder (macOS hides New Folder without createDirectory)', () => { + const properties = selectPathsDialogProperties({ directories: true, multiple: false }) + + expect(properties).toContain('openDirectory') + expect(properties).toContain('createDirectory') + }) + + it('keeps file pickers file-only and multi-select by default', () => { + const properties = selectPathsDialogProperties({}) + + expect(properties).toContain('openFile') + expect(properties).toContain('multiSelections') + expect(properties).not.toContain('openDirectory') + expect(properties).not.toContain('createDirectory') + }) + + it('drops multi-select only when the caller opts out', () => { + expect(selectPathsDialogProperties({ directories: true })).toContain('multiSelections') + expect(selectPathsDialogProperties({ directories: true, multiple: false })).not.toContain('multiSelections') + }) +}) diff --git a/apps/desktop/electron/select-paths-dialog.ts b/apps/desktop/electron/select-paths-dialog.ts new file mode 100644 index 0000000000..69f8cd8b56 --- /dev/null +++ b/apps/desktop/electron/select-paths-dialog.ts @@ -0,0 +1,16 @@ +import type { OpenDialogOptions } from 'electron' + +type DialogProperty = NonNullable[number] + +// `hermes:selectPaths` → `dialog.showOpenDialog` properties. Directory pickers +// carry `createDirectory` so macOS shows New Folder: a new project needs a +// folder, and the picker is where the user expects to make one. +export function selectPathsDialogProperties(options: { directories?: boolean; multiple?: boolean } = {}) { + const properties: DialogProperty[] = options.directories ? ['openDirectory', 'createDirectory'] : ['openFile'] + + if (options.multiple !== false) { + properties.push('multiSelections') + } + + return properties +} diff --git a/apps/desktop/electron/window-state.ts b/apps/desktop/electron/window-state.ts index afd991b37f..ba780156ab 100644 --- a/apps/desktop/electron/window-state.ts +++ b/apps/desktop/electron/window-state.ts @@ -52,9 +52,9 @@ function sanitizeWindowState(raw?: any): SanitizedWindowState | null { // Return the work area with the largest meaningful overlap with `bounds`. // `displays` is Electron's screen.getAllDisplays() shape. A small sliver does -// not count: the saved position is only trusted when at least MIN_VISIBLE is +// not count: the saved position is only trusted when at least `minVisible` is // reachable on both axes. -function matchingWorkArea(bounds, displays) { +function matchingWorkArea(bounds, displays, minVisible = MIN_VISIBLE) { if (!Array.isArray(displays)) { return null } @@ -70,7 +70,7 @@ function matchingWorkArea(bounds, displays) { const x = Math.min(bounds.x + bounds.width, a.x + a.width) - Math.max(bounds.x, a.x) const y = Math.min(bounds.y + bounds.height, a.y + a.height) - Math.max(bounds.y, a.y) - if (x < MIN_VISIBLE || y < MIN_VISIBLE) { + if (x < minVisible || y < minVisible) { continue } diff --git a/apps/desktop/src/app/chat/composer/focus.test.ts b/apps/desktop/src/app/chat/composer/focus.test.ts index dbb7292ba8..76f6ce9eae 100644 --- a/apps/desktop/src/app/chat/composer/focus.test.ts +++ b/apps/desktop/src/app/chat/composer/focus.test.ts @@ -3,16 +3,23 @@ import { afterEach, describe, expect, it } from 'vitest' import { $hoveredTreeGroup } from '@/components/pane-shell/tree/store' import { + ackComposerInsert, blurComposerInput, focusComposerInput, getActiveComposer, markActiveComposer, onComposerDictationRequest, + onComposerDraftRequests, onComposerFocusRequest, + onComposerInsertRequest, onComposerModelMenuRequest, releaseActiveComposer, requestComposerDictation, requestComposerFocus, + requestComposerGetDraft, + requestComposerInsert, + requestComposerInsertAcked, + requestComposerSetDraft, requestModelMenuToggle } from './focus' import { RICH_INPUT_SLOT } from './rich-editor' @@ -335,3 +342,151 @@ describe('requestComposerDictation', () => { expect(targets).toEqual(['tile:front']) }) }) + +/** + * The plugin SDK's draft read/write bus (`host.composer`). A mounted composer + * answers for its own sessions and the active composer answers `active` + * requests; unaddressed surfaces must stay silent so a plugin addressing one + * session never reads or writes another's draft. + */ +describe('composer draft requests', () => { + const disposer: (() => void)[] = [] + + afterEach(() => { + disposer.splice(0).forEach(off => off()) + }) + + function mountDraft(id: string, text: string, active = false) { + const state = { text, wrote: null as null | string } + disposer.push( + onComposerDraftRequests( + { getIds: () => [id], isActive: () => active }, + { + read: () => state.text, + write: next => { + if (next.trim() === '') { + return false + } + + state.wrote = next + + return true + } + } + ) + ) + + return state + } + + it('reads the draft of the addressed session', async () => { + mountDraft('sess-a', 'draft A') + mountDraft('sess-b', 'draft B') + + expect(await requestComposerGetDraft(['sess-b'])).toEqual({ text: 'draft B' }) + }) + + it('answers an active request only from the composer the bus routes to', async () => { + markActiveComposer('main') + mountSurface('main') + const active = mountDraft('sess-live', 'on screen', true) + const dead = mountDraft('sess-dead', 'buried', false) + + expect(active.wrote).toBeNull() + expect(dead.wrote).toBeNull() + expect(await requestComposerSetDraft([], 'type here', { active: true })).toBe(true) + expect(active.wrote).toBe('type here') + // Without the isActive() gate the buried surface also writes (registered + // after active so its reply loses the race, but the write still fires). + expect(dead.wrote).toBeNull() + }) + + it('reads from the bus-routed composer on active access, not the first registered', async () => { + markActiveComposer('main') + mountSurface('main') + // Register the inactive surface FIRST — without the isActive() gate its + // listener answered by registration order and the wrong draft won. + mountDraft('sess-dead', 'buried', false) + mountDraft('sess-live', 'on screen', true) + + expect(await requestComposerGetDraft([], { active: true })).toEqual({ text: 'on screen' }) + }) + + it('writes only the addressed session, through exactly one owner, and reports success', async () => { + const a = mountDraft('sess-a', 'x') + // A second owner of the same id (primary pane + keep-alive tile showing + // one session) must not paint too: the first claim wins. + const aTwin = mountDraft('sess-a', 'x') + const b = mountDraft('sess-b', 'y') + + expect(await requestComposerSetDraft(['sess-a'], 'new text')).toBe(true) + expect(a.wrote).toBe('new text') + expect(aTwin.wrote).toBeNull() + expect(b.wrote).toBeNull() + }) + + it('resolves null / false when no surface answers', async () => { + mountDraft('sess-a', 'x') + + expect(await requestComposerGetDraft(['nobody'])).toBeNull() + expect(await requestComposerSetDraft(['nobody'], 'hi')).toBe(false) + }) + + it('surfaces a write refused by the owner (a blank paint is a no-op)', async () => { + mountDraft('sess-a', 'x') + + expect(await requestComposerSetDraft(['sess-a'], ' ')).toBe(false) + }) +}) + +describe('insert acknowledgement', () => { + const disposer: (() => void)[] = [] + + afterEach(() => { + disposer.splice(0).forEach(off => off()) + }) + + function mountInsert(target: string, seen: string[]) { + disposer.push( + onComposerInsertRequest(detail => { + if (detail.target === target) { + seen.push(detail.text) + + if (detail.token !== undefined) { + ackComposerInsert(detail.token, true) + } + } + }) + ) + } + + it('resolves true when the addressed composer claims the insert', async () => { + const seen: string[] = [] + + mountInsert('tile:sess-a', seen) + await expect(requestComposerInsertAcked(' snippet ', { target: 'tile:sess-a' })).resolves.toBe(true) + expect(seen).toEqual(['snippet']) + }) + + it('resolves false when no surface claims it', async () => { + await expect(requestComposerInsertAcked('hello', { target: 'tile:gone' })).resolves.toBe(false) + }) + + it('resolves false for blank text without dispatching', async () => { + const seen: string[] = [] + + mountInsert('main', seen) + await expect(requestComposerInsertAcked(' ')).resolves.toBe(false) + expect(seen).toEqual([]) + }) + + it('leaves the internal fire-and-forget insert path untracked', async () => { + const seen: string[] = [] + + mountInsert('main', seen) + requestComposerInsert('untracked', { target: 'main' }) + await new Promise(resolve => window.setTimeout(resolve, 5)) + + expect(seen).toEqual(['untracked']) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts index 82b6374820..4f51b80bba 100644 --- a/apps/desktop/src/app/chat/composer/focus.ts +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -32,6 +32,10 @@ interface InsertDetail { mode: ComposerInsertMode target: ComposerTarget text: string + /** Present when the caller wants an acknowledgement (plugin SDK + * `host.composer.insertText`); the claiming subscriber echoes it back on + * {@link INSERT_REPLY_EVENT}. Internal fire-and-forget inserts omit it. */ + token?: number } interface InsertRefsDetail { @@ -39,6 +43,12 @@ interface InsertRefsDetail { target: ComposerTarget } +/** Reply the claiming surface sends for a tokened insert. */ +interface InsertReplyDetail { + ok: boolean + token: number +} + interface AttachImagesDetail { blobs: Blob[] target: ComposerTarget @@ -46,6 +56,7 @@ interface AttachImagesDetail { const FOCUS_EVENT = 'hermes:composer-focus' const INSERT_EVENT = 'hermes:composer-insert' +const INSERT_REPLY_EVENT = 'hermes:composer-insert-reply' const ATTACH_IMAGES_EVENT = 'hermes:composer-attach-images' const INSERT_REFS_EVENT = 'hermes:composer-insert-refs' const SUBMIT_EVENT = 'hermes:composer-submit' @@ -282,6 +293,73 @@ export const requestComposerInsert = ( dispatch(INSERT_EVENT, { mode, target: resolve(target), text: trimmed }) } +/** Acked insert for the plugin SDK (`host.composer.insertText`). + * + * Same trim + deferred dispatch as {@link requestComposerInsert}, plus a + * token the claiming surface echoes on {@link INSERT_REPLY_EVENT}. Resolves + * false when the text trims to nothing, when no mounted surface claims the + * address, or when none answers within the settle window — so a plugin gets + * the same fail-closed success signal as `setDraft`/`submit` instead of a + * silent no-op. Internal callers keep the fire-and-forget form above. */ +export const requestComposerInsertAcked = ( + text: string, + { mode = 'block', target = 'active' }: { mode?: ComposerInsertMode; target?: ComposerTarget | 'active' } = {} +): Promise => { + const trimmed = text.trim() + const pending = insertReplies + + if (!trimmed || !pending) { + return Promise.resolve(false) + } + + const token = ++insertToken + const resolvedTarget = resolve(target) + + return new Promise(resolve => { + pending.set(token, resolve) + + dispatch(INSERT_EVENT, { mode, target: resolvedTarget, text: trimmed, token }) + + // No claimant (unmounted address, input disabled, mid-teardown) must not + // strand the promise: settle false and drop the slot. + window.setTimeout(() => { + if (pending.get(token) === resolve) { + pending.delete(token) + resolve(false) + } + }, INSERT_REPLY_TIMEOUT_MS) + }) +} + +/** Subscriber-side ack for {@link requestComposerInsertAcked}: the surface that + * appended the text reports success so the plugin's promise settles. A token-less + * insert (the internal bus) is a no-op here. */ +export const ackComposerInsert = (token: number | undefined, ok: boolean) => { + if (token === undefined || typeof window === 'undefined') { + return + } + + window.dispatchEvent(new CustomEvent(INSERT_REPLY_EVENT, { detail: { ok, token } })) +} + +const INSERT_REPLY_TIMEOUT_MS = 50 + +let insertToken = 0 + +const insertReplies = typeof window === 'undefined' ? null : new Map void>() + +if (typeof window !== 'undefined') { + window.addEventListener(INSERT_REPLY_EVENT, event => { + const reply = (event as CustomEvent).detail + const resolve = reply && insertReplies?.get(reply.token) + + if (resolve) { + insertReplies?.delete(reply.token) + resolve(reply.ok === true) + } + }) +} + export const onComposerFocusRequest = (handler: (detail: FocusDetail) => void) => subscribe(FOCUS_EVENT, handler) @@ -318,6 +396,211 @@ export const requestComposerInsertRefs = ( export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail) => void) => subscribe(INSERT_REFS_EVENT, handler) +// ── Draft read/write bus (plugin SDK `host.composer`) ───────────────────── +// +// A synchronous request/reply pair over the same CustomEvent bus as the +// mutations above: mounted composers answer for their own sessions, the +// requester times out to `null`/`false` when none does. Deferral is NOT used +// for the request (the reply path is already async for the caller); handlers +// run inline, matching the submit bus's preserve-the-visible-surface rule. + +/** Durable or runtime ids a mounted composer answers for (primary: both; + * tile: its stored id; plus the queue-edit key the draft stash is keyed by). + * `active` addresses the composer the bus currently routes to, whatever + * session it holds — the read/write cousin of the mutations' 'active' target. */ +export interface DraftRequestDetail { + token: number + ids: string[] + active?: boolean + /** Set-draft payload; absent on read requests. */ + text?: string + /** Stamped by the first owning surface so a second owner of the same id + * (the primary pane plus a keep-alive tile showing that session) skips it. */ + claimed?: boolean +} + +interface DraftReplyDetail { + ids?: string[] + ok?: boolean + text?: null | string + token: number +} + +const GET_DRAFT_EVENT = 'hermes:composer-get-draft' +const SET_DRAFT_EVENT = 'hermes:composer-set-draft' +const DRAFT_REPLY_EVENT = 'hermes:composer-draft-reply' +const DRAFT_REPLY_TIMEOUT_MS = 50 + +let draftToken = 0 + +const draftRequests = + typeof window === 'undefined' + ? null + : { + get: new Map void>(), + set: new Map void>() + } + +const requestDraftOnce = ( + channel: 'get' | 'set', + event: string, + detail: DraftRequestDetail +): null | Promise => { + const pending = draftRequests?.[channel] + + if (!pending) { + return null + } + + return new Promise(resolve => { + pending.set(detail.token, resolve) + + window.dispatchEvent(new CustomEvent(event, { detail })) + + // A subscriber that never replies (unmounted, input disabled, mid-teardown) + // must not strand the promise: settle null, drop the slot. + window.setTimeout(() => { + if (pending.get(detail.token) === resolve) { + pending.delete(detail.token) + resolve(null) + } + }, DRAFT_REPLY_TIMEOUT_MS) + }) +} + +if (typeof window !== 'undefined') { + window.addEventListener(DRAFT_REPLY_EVENT, event => { + const reply = (event as CustomEvent).detail + + if (!reply?.token) { + return + } + + const channel = reply.text === undefined ? 'set' : 'get' + const pending = draftRequests?.[channel] + const resolve = pending?.get(reply.token) + + if (resolve) { + pending?.delete(reply.token) + resolve(reply) + } + }) +} + +/** Read a mounted composer's LIVE draft (the stash only holds the last + * debounced persist). `ids` are the session ids to answer for; the first + * relevant surface replies. Resolves null when no mounted composer answers — + * callers fall back to `takeSessionDraft` for the persisted copy. */ +export const requestComposerGetDraft = ( + ids: string[], + opts?: { active?: boolean } +): Promise => { + const cleaned = [...new Set(ids.map(id => id?.trim()).filter(Boolean))] as string[] + const active = opts?.active === true + const token = ++draftToken + + const promise = + cleaned.length || active + ? requestDraftOnce('get', GET_DRAFT_EVENT, { active, ids: cleaned, token }) + : null + + if (!promise) { + return Promise.resolve(null) + } + + return promise.then(reply => (reply ? { text: reply.text ?? '' } : null)) +} + +/** Replace a mounted composer's draft (the app's own paint path — the text + * re-renders through `renderComposerContents`, so `@`-ref / `/`-command + * tokens hydrate as chips exactly like official paste). Returns false when + * no mounted surface answers; never writes another session's composer. */ +export const requestComposerSetDraft = ( + ids: string[], + text: string, + opts?: { active?: boolean } +): Promise => { + const cleaned = [...new Set(ids.map(id => id?.trim()).filter(Boolean))] as string[] + const active = opts?.active === true + const token = ++draftToken + + const promise = + cleaned.length || active + ? requestDraftOnce('set', SET_DRAFT_EVENT, { active, ids: cleaned, text, token }) + : null + + return promise ? promise.then(reply => reply?.ok === true) : Promise.resolve(false) +} + +/** Subscribe one mounted composer to draft read/write requests. `getIds` is + * consulted per request (the surface's session identity changes as the user + * navigates); `isActive` reports whether the focus bus currently routes to + * this composer. An `active` request is answered only by the surface the bus + * routes to; an id-addressed request only by a surface owning one of its ids + * — the rest stay ignored so N mounted composers coexist on the bus. `read` + * answers with the live text; `write` replaces the draft and reports success. */ +export const onComposerDraftRequests = ( + address: { getIds: () => string[]; isActive: () => boolean }, + handlers: { read: () => null | string; write: (text: string) => boolean } +) => { + if (typeof window === 'undefined') { + return () => undefined + } + + const listener = (event: Event) => { + const e = event as CustomEvent + + if (!e.detail) { + return + } + + // Exactly ONE surface answers a request. `active` belongs to the composer + // the focus bus routes to — every mounted surface claiming it (the previous + // behavior) let listener registration order decide instead: with keep-alive + // tabs in the stack a buried composer answered the read, and a `set` + // painted onto every mounted draft. An id-addressed request can have two + // owners (the primary pane and a keep-alive tile showing the same session); + // the first to see it claims it and the other skips. + if (e.detail.claimed) { + return + } + + if (e.detail.active) { + if (!address.isActive()) { + return + } + } else { + const ids = address.getIds() + + if (!e.detail.ids?.some(id => ids.includes(id))) { + return + } + } + + e.detail.claimed = true + + if (e.type === GET_DRAFT_EVENT) { + window.dispatchEvent( + new CustomEvent(DRAFT_REPLY_EVENT, { + detail: { text: handlers.read(), token: e.detail.token } + }) + ) + } else if (e.type === SET_DRAFT_EVENT) { + const ok = handlers.write(e.detail.text ?? '') + + window.dispatchEvent(new CustomEvent(DRAFT_REPLY_EVENT, { detail: { ok, token: e.detail.token } })) + } + } + + window.addEventListener(GET_DRAFT_EVENT, listener) + window.addEventListener(SET_DRAFT_EVENT, listener) + + return () => { + window.removeEventListener(GET_DRAFT_EVENT, listener) + window.removeEventListener(SET_DRAFT_EVENT, listener) + } +} + /** Submit a prompt through a composer as if the user typed + sent it. Lets * external panels (e.g. the review pane's "let the agent ship it" button) hand * the agent a task without the user round-tripping through the input. */ diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts index 3d6516c473..4b76081d48 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -18,6 +18,7 @@ import { adoptNewSessionDraft, type ComposerAttachment, type ComposerDraftSyncMode, + NEW_SESSION_DRAFT_KEY, onComposerDraftSyncRequest, reloadPersistedDrafts, stashSessionDraft, @@ -34,10 +35,12 @@ import { type QueueEditState } from '../composer-utils' import { + ackComposerInsert, type ComposerInsertMode, focusComposerInput, getActiveComposer, markActiveComposer, + onComposerDraftRequests, onComposerFocusRequest, onComposerInsertRefsRequest, onComposerInsertRequest, @@ -181,11 +184,11 @@ export function useComposerDraft({ ) const appendExternalText = useCallback( - (text: string, mode: ComposerInsertMode) => { + (text: string, mode: ComposerInsertMode): boolean => { const value = text.trim() if (!value) { - return + return false } // 'prefix' puts the value at the START of the draft — slash commands @@ -195,13 +198,15 @@ export function useComposerDraft({ paintDraft(`${value} ${rest}`.trimEnd()) - return + return true } const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : '' paintDraft(`${base}${sep}${value}`) + + return true }, [paintDraft] ) @@ -260,9 +265,11 @@ export function useComposerDraft({ setFocusRequestId(id => id + 1) }) - const offInsert = onComposerInsertRequest(({ mode, target: requested, text }) => { + const offInsert = onComposerInsertRequest(({ mode, target: requested, text, token }) => { if (requested === target) { - appendExternalText(text, mode) + // A tokened insert came from the plugin SDK — echo whether the text + // actually landed, so its promise never settles on a silent no-op. + ackComposerInsert(token, appendExternalText(text, mode)) } }) @@ -275,6 +282,55 @@ export function useComposerDraft({ const stashAt = (scope: string | null, text = draftRef.current, attachments = attachmentScope.$attachments.get()) => stashSessionDraft(scope, text, attachments) + // Draft read/write bus (plugin SDK `host.composer`): answer for the sessions + // this composer owns — the runtime id, the queue/stored key (tiles run with + // sessionId = their stored id; the primary's queue key is the resolved + // stored id), so a plugin addressing either identity reaches this surface. + // Reads answer the live DOM text (the stash lags by the persist debounce); + // writes go through paintDraft — the app's own programmatic-draft path, so + // `@`-ref / `/` tokens hydrate as chips like official paste. + useEffect(() => { + if (inputDisabled) { + return undefined + } + + return onComposerDraftRequests( + { + getIds: () => { + const ids = [sessionIdRef.current, activeQueueSessionKeyRef.current].filter( + (id): id is string => Boolean(id) + ) + + // A surface with no session yet IS the new-chat draft (the stash keys + // it '__new__'); once one opens, the new-chat draft belongs elsewhere. + return ids.length ? ids : [NEW_SESSION_DRAFT_KEY] + }, + isActive: () => getActiveComposer() === target + }, + { + read: () => { + const editor = editorRef.current + + return editor ? composerPlainText(editor) : draftRef.current + }, + write: text => { + const editor = editorRef.current + + // Hidden keep-alive panes still answer: multi-session plugins route + // to a specific session's composer, and paintDraft never steals the + // caret of a non-visible surface. + if (!editor || !editor.isConnected) { + return false + } + + paintDraft(text) + + return true + } + } + ) + }, [inputDisabled, paintDraft, target]) + const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => { // Diagnostic breadcrumb for #59305-class reports: identifies WHAT kind of // state got restored into the composer (session switch, queue-edit diff --git a/apps/desktop/src/app/chat/composer/model-pill.test.tsx b/apps/desktop/src/app/chat/composer/model-pill.test.tsx index 79d9248330..2dc32500b7 100644 --- a/apps/desktop/src/app/chat/composer/model-pill.test.tsx +++ b/apps/desktop/src/app/chat/composer/model-pill.test.tsx @@ -144,6 +144,7 @@ describe('ModelPill per-surface model label', () => { $model: atom('tile/claude-sonnet'), $provider: atom('anthropic'), $reasoningEffort: atom('high'), + $reasoningEffortPending: atom(false), $reasoningEffortWire: atom(''), $runtimeId: atom('tile-runtime'), $storedId: atom('stored-tile'), diff --git a/apps/desktop/src/app/chat/composer/reasoning-pill.test.tsx b/apps/desktop/src/app/chat/composer/reasoning-pill.test.tsx index c08af91649..ed10f1a495 100644 --- a/apps/desktop/src/app/chat/composer/reasoning-pill.test.tsx +++ b/apps/desktop/src/app/chat/composer/reasoning-pill.test.tsx @@ -1,10 +1,18 @@ -import { cleanup, render, screen } from '@testing-library/react' +import { act, cleanup, render, screen } from '@testing-library/react' import { atom } from 'nanostores' import { afterEach, describe, expect, it } from 'vitest' import type { ChatBarState } from '@/app/chat/composer/types' -import { type SessionView, SessionViewProvider } from '@/app/chat/session-view' -import { $defaultReasoningEffort } from '@/store/session' +import { PRIMARY_SESSION_VIEW, type SessionView, SessionViewProvider } from '@/app/chat/session-view' +import { applySessionInfoStatePatch, sessionInfoStatePatch } from '@/app/session/hooks/use-message-stream/utils' +import { createClientSessionState } from '@/lib/chat-runtime' +import { + $activeSessionId, + $defaultReasoningEffort, + $selectedStoredSessionId, + setCurrentReasoningEffort +} from '@/store/session' +import { $sessionStates, publishSessionState } from '@/store/session-states' import { ReasoningPill } from './reasoning-pill' @@ -28,6 +36,7 @@ const tileView = (reasoningEffort: string, reasoningEffortWire = ''): SessionVie $model: atom('tile/claude-sonnet'), $provider: atom('anthropic'), $reasoningEffort: atom(reasoningEffort), + $reasoningEffortPending: atom(false), $reasoningEffortWire: atom(reasoningEffortWire), $runtimeId: atom('tile-runtime'), $storedId: atom('stored-tile'), @@ -37,6 +46,9 @@ const tileView = (reasoningEffort: string, reasoningEffortWire = ''): SessionVie afterEach(() => { cleanup() $defaultReasoningEffort.set('') + $activeSessionId.set(null) + $selectedStoredSessionId.set(null) + $sessionStates.set({}) }) describe('ReasoningPill', () => { @@ -84,6 +96,61 @@ describe('ReasoningPill', () => { expect(screen.getByTestId('reasoning-pill').textContent).toBe('High') }) + it("never paints the profile default while a resumed session's own effort is still loading (#79807)", () => { + // Profile says ultra; the session being reopened is pinned to max. + $defaultReasoningEffort.set('ultra') + setCurrentReasoningEffort('') + $selectedStoredSessionId.set('stored-1') + $activeSessionId.set(null) + + render( + + + + ) + + const label = () => screen.getByTestId('reasoning-pill').textContent + + // session.resume in flight: no runtime slice yet. + expect(label()).not.toContain('Ultra') + + // Cold resume answered before the agent build, so no effort was reported. + act(() => { + publishSessionState('rt-1', { ...createClientSessionState('stored-1'), reasoningEffortPending: true }) + $activeSessionId.set('rt-1') + }) + expect(label()).not.toContain('Ultra') + + // The built agent's session.info carries the session's real level. + act(() => { + const state = $sessionStates.get()['rt-1']! + publishSessionState('rt-1', applySessionInfoStatePatch(state, sessionInfoStatePatch({ reasoning_effort: 'max' }))) + }) + expect(label()).toBe('Max') + }) + + it('falls back to the profile default once the runtime reports the session has no pin of its own', () => { + $defaultReasoningEffort.set('ultra') + $selectedStoredSessionId.set('stored-1') + + act(() => { + publishSessionState('rt-1', { ...createClientSessionState('stored-1'), reasoningEffortPending: true }) + $activeSessionId.set('rt-1') + }) + + render( + + + + ) + + act(() => { + const state = $sessionStates.get()['rt-1']! + publishSessionState('rt-1', applySessionInfoStatePatch(state, sessionInfoStatePatch({ reasoning_effort: '' }))) + }) + expect(screen.getByTestId('reasoning-pill').textContent).toBe('Ultra') + }) + it('hides when the catalog says the model has no reasoning control, but not while that is unknown', () => { const { unmount } = render( diff --git a/apps/desktop/src/app/chat/composer/reasoning-pill.tsx b/apps/desktop/src/app/chat/composer/reasoning-pill.tsx index 2e0818678a..a1626d0d71 100644 --- a/apps/desktop/src/app/chat/composer/reasoning-pill.tsx +++ b/apps/desktop/src/app/chat/composer/reasoning-pill.tsx @@ -6,6 +6,7 @@ import { useSessionView } from '@/app/chat/session-view' import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel' import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { releaseTypingFocus } from '@/components/ui/keyboard-first' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' @@ -35,6 +36,7 @@ export function ReasoningPill({ disabled, model }: { disabled: boolean; model: C const view = useSessionView() const reasoningEffort = useStore(view.$reasoningEffort) const reasoningEffortWire = useStore(view.$reasoningEffortWire) + const pending = useStore(view.$reasoningEffortPending) const defaultEffort = useStore($defaultReasoningEffort) const [open, setOpen] = useState(false) @@ -49,9 +51,13 @@ export function ReasoningPill({ disabled, model }: { disabled: boolean; model: C const clamp = reasoningEffortClamp(effort, reasoningEffortWire) const label = reasoningEffortLabel(effort, reasoningEffortWire) - const title = clamp - ? `${copy.effort}: ${copy[clamp.effort]} (${copy.sendsOnRoute(copy[clamp.wire])})` - : `${copy.effort}: ${label}` + // Until the session reports its own effort, the profile default is a guess + // about to be replaced (#79807). Show the model pill's quiet loader instead. + const title = pending + ? copy.effort + : clamp + ? `${copy.effort}: ${copy[clamp.effort]} (${copy.sendsOnRoute(copy[clamp.wire])})` + : `${copy.effort}: ${label}` // Closing the menu ends its claim on the keyboard: Radix restores focus to // this pill (a toolbar button), so without the release the Enter that @@ -76,7 +82,7 @@ export function ReasoningPill({ disabled, model }: { disabled: boolean; model: C type="button" variant="ghost" > - {label} + {pending ? : {label}} diff --git a/apps/desktop/src/app/chat/composer/rich-editor.test.ts b/apps/desktop/src/app/chat/composer/rich-editor.test.ts index 89cb3ba50e..7a506f40a9 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.test.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.test.ts @@ -1,10 +1,11 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { rememberDesktopCommandsCatalog } from '@/lib/desktop-slash-commands' import { insertInlineRefsIntoEditor } from './inline-refs' import { caretOffsetInEditor, + caretRevealScrollTop, composerPlainText, deleteSelectionInEditor, insertComposerContentsAtCaret, @@ -570,3 +571,69 @@ describe('normalizeComposerEditorDom — caret preservation', () => { editor.remove() }) }) + +describe('caretRevealScrollTop', () => { + const viewport = { top: 100, bottom: 200 } + + it('leaves a visible caret alone', () => { + expect(caretRevealScrollTop({ top: 120, bottom: 140 }, viewport, 50)).toBeNull() + }) + + it('scrolls down just far enough to show a caret below the viewport', () => { + expect(caretRevealScrollTop({ top: 400, bottom: 420 }, viewport, 50)).toBe(270) + }) + + it('scrolls up just far enough to show a caret above the viewport', () => { + expect(caretRevealScrollTop({ top: 60, bottom: 80 }, viewport, 50)).toBe(10) + }) +}) + +describe('caret reveal after programmatic inserts', () => { + // jsdom has no layout: give the editor a 100px viewport and put every other + // element (the caret probe) at `caretTop`, as a long insert would. + function overflowingEditor(caretTop: number) { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + document.body.append(editor) + + Object.defineProperty(editor, 'clientHeight', { configurable: true, value: 100 }) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + const [top, bottom] = this === editor ? [0, 100] : [caretTop, caretTop + 20] + + return { bottom, height: bottom - top, left: 0, right: 0, top, width: 0, x: 0, y: top, toJSON: () => ({}) } + }) + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { + callback(0) + + return 0 + }) + + return editor + } + + afterEach(() => { + vi.restoreAllMocks() + document.body.replaceChildren() + }) + + it('scrolls a long paste so the caret after it is visible', () => { + const editor = overflowingEditor(480) + placeCaretAtEnd(editor) + + insertComposerContentsAtCaret(editor, Array.from({ length: 30 }, (_, i) => `line ${i}`).join('\n')) + + expect(editor.scrollTop).toBe(400) + expect(editor.querySelectorAll('span').length).toBe(0) + expect(composerPlainText(editor)).toContain('line 29') + }) + + it('scrolls to the caret when a repaint parks it at the end (voice transcript)', () => { + const editor = overflowingEditor(300) + + renderComposerContents(editor, Array.from({ length: 30 }, (_, i) => `said ${i}`).join('\n')) + placeCaretEnd(editor) + + expect(editor.scrollTop).toBe(220) + expect(composerPlainText(editor)).toContain('said 29') + }) +}) diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index cff162d2c7..b4f96e4259 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -359,6 +359,8 @@ export function insertComposerContentsAtCaret(editor: HTMLElement, text: string, selection?.removeAllRanges() selection?.addRange(caret) } + + revealCaret(editor) } /** Range covering exactly `length` serialized characters immediately before a @@ -606,6 +608,93 @@ export function placeCaretEnd(element: HTMLElement) { range.collapse(false) selection?.removeAllRanges() selection?.addRange(range) + revealCaret(element) +} + +/** The editor `scrollTop` that brings a caret spanning `caret.top..bottom` + * (client px) inside the visible band `viewport`, or null when it's already + * visible. Moves the nearest edge only, like `block: 'nearest'`. */ +export function caretRevealScrollTop( + caret: { bottom: number; top: number }, + viewport: { bottom: number; top: number }, + scrollTop: number +): number | null { + if (caret.top < viewport.top) { + return scrollTop - (viewport.top - caret.top) + } + + if (caret.bottom > viewport.bottom) { + return scrollTop + (caret.bottom - viewport.bottom) + } + + return null +} + +function caretClientRect(range: Range): { bottom: number; top: number } | null { + const end = range.cloneRange() + end.collapse(false) + + const rects = typeof end.getClientRects === 'function' ? end.getClientRects() : null + + if (rects?.length) { + return rects[rects.length - 1] + } + + // Chromium gives a caret between elements (after a chip or
, or at the + // editor's end, which is where inserts leave it) no box. Measure a probe + // there instead. A text-node caret always has a box, so the probe never + // splits text. + if (end.startContainer.nodeType === Node.TEXT_NODE) { + return null + } + + const probe = document.createElement('span') + probe.textContent = '\u200b' + end.insertNode(probe) + + const rect = probe.getBoundingClientRect() + + probe.remove() + + return rect +} + +const pendingReveals = new WeakSet() + +/** Scroll the editor so its caret is visible, on the next frame (one per + * frame across a burst of inserts). Chromium only does this itself for native + * edit commands; programmatic inserts (paste, voice transcripts, repaints) + * leave the caret wherever the old scroll position puts it (#79806). Local to + * the editor: `scrollIntoView` would also move the transcript. */ +export function revealCaret(editor: HTMLElement) { + if (pendingReveals.has(editor)) { + return + } + + pendingReveals.add(editor) + window.requestAnimationFrame(() => { + pendingReveals.delete(editor) + + const selection = window.getSelection() + const range = selection?.rangeCount ? selection.getRangeAt(0) : null + + if (!editor.isConnected || !range || !editor.contains(range.endContainer)) { + return + } + + const caret = caretClientRect(range) + + if (!caret) { + return + } + + const top = editor.getBoundingClientRect().top + editor.clientTop + const next = caretRevealScrollTop(caret, { bottom: top + editor.clientHeight, top }, editor.scrollTop) + + if (next !== null) { + editor.scrollTop = next + } + }) } /** The caret's offset in `composerPlainText` coordinates, so it can be restored diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index 9dbd34c545..02de6f7ca3 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -73,7 +73,7 @@ import { startSessionDrag } from './session-drag' import { SessionStatusDot } from './session-status-dot' import { useSessionTileActions } from './session-tile-actions' import { tileOwnerRoute } from './session-tile-owner' -import { type SessionView, SessionViewProvider } from './session-view' +import { reasoningEffortPending, type SessionView, SessionViewProvider } from './session-view' import { SessionContextMenu } from './sidebar/session-actions-menu' import { lastVisibleMessageIsUser } from './thread-loading' @@ -149,6 +149,8 @@ function buildTileView(storedSessionId: string): SessionView { $model: computed($state, state => state?.model ?? ''), $provider: computed($state, state => state?.provider ?? ''), $reasoningEffort: computed($state, state => state?.reasoningEffort ?? ''), + // No slice yet means the tile's resume is still in flight. + $reasoningEffortPending: computed($state, state => (state ? reasoningEffortPending(state) : true)), $reasoningEffortWire: computed($state, state => state?.reasoningEffortWire ?? ''), $runtimeId, // Constant for the tile's lifetime — a plain atom, not a computed. diff --git a/apps/desktop/src/app/chat/session-view.tsx b/apps/desktop/src/app/chat/session-view.tsx index 4d9599b31c..6542401119 100644 --- a/apps/desktop/src/app/chat/session-view.tsx +++ b/apps/desktop/src/app/chat/session-view.tsx @@ -59,6 +59,10 @@ export interface SessionView { $provider: ReadableAtom $fast: ReadableAtom $reasoningEffort: ReadableAtom + /** The session's effort is not known yet (a resume in flight, or its agent + * still building), so an empty `$reasoningEffort` must not render as the + * profile default — that paints a level about to be replaced (#79807). */ + $reasoningEffortPending: ReadableAtom /** Gateway-reported level the route sends for `$reasoningEffort` ('' = unknown). */ $reasoningEffortWire: ReadableAtom } @@ -95,6 +99,21 @@ const $primaryBusy = computed([$primaryState, $busy, $selectedStoredSessionId], state ? state.busy : selected ? false : draftBusy ) +/** Whether a slice's effort is still unknown: marked pending by the resume and + * cleared by the first runtime report of `reasoning_effort` (even ''). */ +export const reasoningEffortPending = (state: ClientSessionState): boolean => + Boolean(state.reasoningEffortPending) && !state.reasoningEffort + +/** + * Same reasoning as busy: a selected stored session with no slice yet is a + * cold resume in flight, whose effort the backend has not reported. The + * draft's '' there would render as the profile default. A true new chat (no + * stored id) is the composer's own pick and is never pending. + */ +const $primaryReasoningEffortPending = computed([$primaryState, $selectedStoredSessionId], (state, selected) => + state ? reasoningEffortPending(state) : Boolean(selected) +) + export const PRIMARY_SESSION_VIEW: SessionView = { kind: 'primary', $awaitingResponse: primaryField(state => state.awaitingResponse, $awaitingResponse), @@ -107,6 +126,7 @@ export const PRIMARY_SESSION_VIEW: SessionView = { $model: primaryField(state => state.model, $currentModel), $provider: primaryField(state => state.provider, $currentProvider), $reasoningEffort: primaryField(state => state.reasoningEffort, $currentReasoningEffort), + $reasoningEffortPending: $primaryReasoningEffortPending, $reasoningEffortWire: primaryField(state => state.reasoningEffortWire ?? '', $currentReasoningEffortWire), $runtimeId: $activeSessionId, $storedId: $selectedStoredSessionId, diff --git a/apps/desktop/src/app/chat/sidebar/chat-sidebar.integration.test.tsx b/apps/desktop/src/app/chat/sidebar/chat-sidebar.integration.test.tsx index b3d23686cc..9778efe589 100644 --- a/apps/desktop/src/app/chat/sidebar/chat-sidebar.integration.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/chat-sidebar.integration.test.tsx @@ -7,7 +7,9 @@ import { group, split } from '@/components/pane-shell/tree/model' import { $layoutTree, noteActiveTreeGroup } from '@/components/pane-shell/tree/store' import { SidebarProvider } from '@/components/ui/sidebar' import { registry } from '@/contrib/registry' -import { $selectedStoredSessionId, $sessions } from '@/store/session' +import { setSidebarAgentsGrouped } from '@/store/layout' +import { $projectScope, $projectTree, ALL_PROJECTS } from '@/store/projects' +import { $currentCwd, $selectedStoredSessionId, $sessions, $workspaceCwdOwner } from '@/store/session' import { $removedSessionIds } from '@/store/session-removal' import { makeSessionInfo } from '@/test/session-info' @@ -162,3 +164,53 @@ describe('ChatSidebar navigation activity', () => { expectOnlySelectedSession(null) }) }) + +// Entering a project is a scope switch: the conversation main is showing keeps +// its workspace, so Files/Review and the composer's Git context can't drift to +// the project while the transcript stays on the old chat (#72772). +describe('ChatSidebar project entry', () => { + const project = { + id: '/repos/new-project', + label: 'new-project', + path: '/repos/new-project', + repos: [], + sessionCount: 0 + } + + beforeEach(() => { + setSidebarAgentsGrouped(true) + $projectTree.set([project]) + $currentCwd.set('/repos/old-project') + }) + + afterEach(() => { + cleanup() + $projectScope.set(ALL_PROJECTS) + $projectTree.set([]) + setSidebarAgentsGrouped(false) + $currentCwd.set('') + $selectedStoredSessionId.set(null) + $workspaceCwdOwner.set(null) + $sessions.set([]) + }) + + it("leaves a stored conversation's workspace alone", () => { + $sessions.set(sessionRows) + $selectedStoredSessionId.set('tile-one') + $workspaceCwdOwner.set('tile-one') + $projectScope.set(project.id) + + renderSidebar('/tile-one', 'chat') + + expect($currentCwd.get()).toBe('/repos/old-project') + expect($workspaceCwdOwner.get()).toBe('tile-one') + }) + + it('re-homes a fresh draft into the entered project', () => { + $projectScope.set(project.id) + + renderSidebar('/', 'chat') + + expect($currentCwd.get()).toBe(project.path) + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index ab11cfda7f..fc7e005c7c 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -96,6 +96,7 @@ import { ALL_PROJECTS, enterProject, exitProjectScope, + followEnteredProjectCwd, openProjectCreate, refreshProjects, refreshProjectTree, @@ -113,7 +114,6 @@ import { import { openRouteTile } from '@/store/route-tiles' import { $cronSessions, - $currentCwd, $gatewayState, $messagingPlatformTotals, $messagingSessions, @@ -123,8 +123,7 @@ import { $sessionsLoading, $unreadFinishedSessionIds, markAllSessionsRead, - sessionPinId, - setCurrentCwd + sessionPinId } from '@/store/session' import { $sessionDotStateById, sessionStatusBucket } from '@/store/session-dot-state' import { $unconfirmedPinWrites } from '@/store/session-pin-sync' @@ -522,7 +521,6 @@ export function ChatSidebar({ const reposScanning = useStore($reposScanning) const activeProjectId = useStore($activeProjectId) const projectScope = useStore($projectScope) - const currentCwd = useStore($currentCwd) const gatewayState = useStore($gatewayState) const dismissedAutoProjects = useStore($dismissedAutoProjectIds) const newSessionCombo = useStore($bindings)['session.new']?.[0] @@ -1166,16 +1164,13 @@ export function ChatSidebar({ const lastProjectCwdSyncRef = useRef(null) - const syncProjectCwd = useCallback( - (project: SidebarProjectTree) => { - const target = projectTreeCwd(project) + const syncProjectCwd = useCallback((project: SidebarProjectTree) => { + const target = projectTreeCwd(project) - if (target && target !== currentCwd) { - setCurrentCwd(target) - } - }, - [currentCwd] - ) + if (target) { + followEnteredProjectCwd(target) + } + }, []) // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { diff --git a/apps/desktop/src/app/chat/sidebar/session-row-slots.tsx b/apps/desktop/src/app/chat/sidebar/session-row-slots.tsx new file mode 100644 index 0000000000..e274643f89 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/session-row-slots.tsx @@ -0,0 +1,52 @@ +import type { FC } from 'react' +import { useMemo } from 'react' + +import { useContributions } from '@/contrib' +import { ContribBoundary, ContribRender } from '@/contrib/react/boundary' +import { type SessionRowSlotContribution } from '@/lib/session-row-slots' + +/** + * One row-decoration slot (leading / trailing) for `sessionId`. Mounts every + * registration and lets each decide — it renders its decoration, or nothing at + * all for rows it doesn't own. + * + * Mounting all of them (rather than first-wins) keeps ownership per session: + * a plugin that declines a row must not suppress the one that owns it purely + * on registration order. Two decorations on one row render both — a visible + * composition, not a silent drop. + */ +const SessionRowSlotEntry: FC<{ + id: string + render: SessionRowSlotContribution['render'] + sessionId: string +}> = ({ id, render, sessionId }) => { + // Stable component identity: ContribRender mounts this AS a component, so a + // fresh closure per render would remount the decoration on every tick. + const renderSlot = useMemo(() => () => render({ sessionId }), [render, sessionId]) + + return ( + + + + ) +} + +export const SessionRowSlot: FC<{ area: string; sessionId: string }> = ({ area, sessionId }) => { + const contributions = useContributions(area) + + if (contributions.length === 0) { + return null + } + + return ( + <> + {contributions.map(contribution => { + const render = (contribution.data as SessionRowSlotContribution | undefined)?.render + + return render ? ( + + ) : null + })} + + ) +} diff --git a/apps/desktop/src/app/chat/sidebar/session-row.test.tsx b/apps/desktop/src/app/chat/sidebar/session-row.test.tsx index 29770f6f6c..c8146ffe8b 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.test.tsx @@ -5,9 +5,11 @@ import { atom } from 'nanostores' import type * as React from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' +import { registry } from '@/contrib/registry' import type { SessionInfo } from '@/hermes' import { createClientSessionState } from '@/lib/chat-runtime' import type * as ChatRuntime from '@/lib/chat-runtime' +import { SESSION_ROW_AREAS, type SessionRowSlotProps } from '@/lib/session-row-slots' import type * as Time from '@/lib/time' import type * as ComposerStatusStore from '@/store/composer-status' import type * as SessionStore from '@/store/session' @@ -324,3 +326,61 @@ describe('SidebarSessionRow inside the sortable list', () => { expect(grabber.getAttribute('aria-pressed')).toBe('true') }) }) + +// Row-decoration slots: a plugin decorates rows through the registry with the +// row's stored session id handed to its render — the seam the session-list API +// pairs with (see #116305 item 3). +describe('SidebarSessionRow decoration slots', () => { + const disposers: Array<() => void> = [] + + afterEach(() => { + disposers.splice(0).forEach(dispose => dispose()) + }) + + const decorate = (area: string, id: string, testId: string) => + disposers.push( + registry.register({ + area, + data: { + render: ({ sessionId }: SessionRowSlotProps) => {sessionId} + }, + id, + source: 'disk' + }) + ) + + it('mounts leading and trailing decorations, each handed the DURABLE row id', () => { + act(() => { + decorate(SESSION_ROW_AREAS.leading, 'deco-lead', 'lead-deco') + decorate(SESSION_ROW_AREAS.trailing, 'deco-tail', 'tail-deco') + }) + + // Auto-compression rotates the live id. A plugin that remembered the live + // one decorates this row until the next compaction and then silently stops + // matching — so the slot hands the lineage root, the id core's own + // pin/reorder and `host.sessions.*` address. + renderRow(makeSession({ _lineage_root_id: 'root-9', id: 'live-9', title: 'Compressed' })) + + expect(screen.getByTestId('lead-deco').textContent).toBe('root-9') + expect(screen.getByTestId('tail-deco').textContent).toBe('root-9') + }) + + it('renders nothing for an area with no registrations and survives an unmount', () => { + const { container } = renderRow(makeSession({ id: 'row-7', title: 'Plain' })) + + expect(container.querySelector('[data-testid="lead-deco"]')).toBeNull() + + act(() => { + decorate(SESSION_ROW_AREAS.leading, 'deco-lead', 'lead-deco') + }) + + // Same row, contribution arriving late: the slot mounts it in place. + expect(screen.getByTestId('lead-deco').textContent).toBe('row-7') + + act(() => { + disposers.splice(0).forEach(dispose => dispose()) + }) + + expect(screen.queryByTestId('lead-deco')).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index 63919055e0..242b61a2a3 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -20,6 +20,7 @@ import { triggerHaptic } from '@/lib/haptics' import { middleClickHandlers } from '@/lib/middle-click' import { displayModelName } from '@/lib/model-status-label' import { sessionProjectLabel } from '@/lib/session-project-label' +import { SESSION_ROW_AREAS } from '@/lib/session-row-slots' import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source' import { coarseElapsed } from '@/lib/time' import { useStoreSelector } from '@/lib/use-session-slice' @@ -28,6 +29,7 @@ import { $sidebarRowMeta } from '@/store/layout' import { normalizeProfileKey } from '@/store/profile' import { $projects } from '@/store/projects' import { $pullRequestsByBranch, sessionPrKey } from '@/store/pull-requests' +import { sessionPinId } from '@/store/session' import { $sessionDotStateById, hasLiveTurn, showsRunningArc } from '@/store/session-dot-state' import { $sessionListDensity } from '@/store/session-list-density' import { $openStoredSessionIds } from '@/store/session-states' @@ -50,6 +52,7 @@ import { shellOwnsPress } from './reorderable-list' import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu' import { sessionRowDetails } from './session-row-details' import { resolveSessionRowClick } from './session-row-gesture' +import { SessionRowSlot } from './session-row-slots' import { useProfilePrewarm } from './use-profile-prewarm' interface SidebarSessionRowProps extends React.ComponentProps<'div'> { @@ -499,6 +502,7 @@ function SidebarSessionRowImpl({ return ( <> {leadNode} + {handoffBadge} @@ -534,6 +538,7 @@ function SidebarSessionRowImpl({ )} + ) } @@ -547,6 +552,7 @@ function SidebarSessionRowImpl({ entire width — nothing truncates against the kebab. */}
{leadNode} + {handoffBadge} + {actionsNode}
{/* Title + preview: ONE grouped cell with its own tight diff --git a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.ts b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.ts index 9d1eb2819f..a525b8c20f 100644 --- a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.ts +++ b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.ts @@ -1,7 +1,10 @@ import { renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { reasoningEffortPending } from '@/app/chat/session-view' +import type { ClientSessionState } from '@/app/types' import type * as HermesModule from '@/hermes' +import { createClientSessionState } from '@/lib/chat-runtime' import { setSessionOwnerHint, setSessions } from '@/store/session' import { $sessionTiles, sessionTileDelegate } from '@/store/session-states' import type { SessionInfo } from '@/types/hermes' @@ -441,9 +444,29 @@ describe('useSessionTileDelegate resumeTile', () => { expect(next.model).toBe('gpt-5') expect(next.provider).toBe('openai') expect(next.reasoningEffort).toBe('high') + expect(next.reasoningEffortPending).toBe(false) expect(next.fast).toBe(true) }) + it("keeps the tile's effort pending when the deferred-build resume has not reported it (#79807)", async () => { + setSessions([row({ id: 'stored-lazy', profile: 'default' })]) + + const updateSessionState = vi.fn() + + vi.mocked(requestGatewayForProfile).mockResolvedValueOnce({ + info: { lazy: true, model: 'gpt-5', running: false }, + session_id: 'runtime-lazy' + } as never) + + renderTile(vi.fn(), { updateSessionState }) + await sessionTileDelegate()!.resumeTile('stored-lazy') + + const updater = updateSessionState.mock.calls[0][1] as (state: ClientSessionState) => ClientSessionState + const next = updater(createClientSessionState('stored-lazy')) + + expect(reasoningEffortPending(next)).toBe(true) + }) + it('invalidateRuntimeBindings clears the stored→runtime map so tiles re-resume after reconnect', async () => { setSessions([row({ id: 'stored-c', profile: 'default' })]) diff --git a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts index 7aa16eb1ed..6177c2f464 100644 --- a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts +++ b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts @@ -8,6 +8,7 @@ import { } from '@/hermes' import { translateNow } from '@/i18n/runtime' import { type ChatMessage, chatMessageText, toChatMessages } from '@/lib/chat-messages' +import { markReasoningEffortPending } from '@/lib/chat-runtime' import { notify } from '@/store/notifications' import { isReadOnlyRuntimeId, @@ -401,13 +402,16 @@ export function useSessionTileDelegate({ updateSessionState( runtimeId, state => ({ - ...state, + // The deferred build reports the session's own effort later (#79807). + ...markReasoningEffortPending(state), busy: Boolean(info?.running), // Persist the session's own model/provider from resume so the tile // pill does not wait on a chrome-scoped catalog read (#93892). ...(typeof info?.model === 'string' ? { model: info.model } : {}), ...(typeof info?.provider === 'string' ? { provider: info.provider } : {}), - ...(typeof info?.reasoning_effort === 'string' ? { reasoningEffort: info.reasoning_effort } : {}), + ...(typeof info?.reasoning_effort === 'string' + ? { reasoningEffort: info.reasoning_effort, reasoningEffortPending: false } + : {}), ...(typeof info?.reasoning_effort_wire === 'string' ? { reasoningEffortWire: info.reasoning_effort_wire } : {}), diff --git a/apps/desktop/src/app/right-sidebar/files/remote-picker.test.tsx b/apps/desktop/src/app/right-sidebar/files/remote-picker.test.tsx new file mode 100644 index 0000000000..47b5f6776c --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/files/remote-picker.test.tsx @@ -0,0 +1,127 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { DesktopFsRemotePicker } from '@/lib/desktop-fs' + +import { RemoteFolderPicker } from './remote-picker' + +// A tiny backend filesystem: the picker reads it through readDesktopDir and +// grows it through createRemoteDir, the same two calls it makes for real. +const dirs = vi.hoisted(() => new Set()) +const picker = vi.hoisted(() => ({ current: null as DesktopFsRemotePicker | null })) + +const createRemoteDir = vi.hoisted(() => + vi.fn(async (path: string) => { + dirs.add(path) + + return path + }) +) + +vi.mock('@/lib/desktop-fs', () => ({ + createRemoteDir, + readDesktopDir: async (path: string) => { + const prefix = path === '/' ? '/' : `${path}/` + + const entries = [...dirs] + .filter(dir => dir.startsWith(prefix) && !dir.slice(prefix.length).includes('/') && dir !== path) + .map(dir => ({ isDirectory: true, name: dir.slice(prefix.length), path: dir })) + + return { entries } + }, + setDesktopFsRemotePicker: (next: DesktopFsRemotePicker | null) => { + picker.current = next + } +})) + +function openPicker(defaultPath: string) { + let result: Promise = Promise.resolve([]) + + act(() => { + result = picker.current!.selectPaths({ defaultPath, directories: true }) + }) + + return result +} + +beforeEach(() => { + dirs.clear() + dirs.add('/home') + dirs.add('/home/me') + dirs.add('/home/me/existing') + createRemoteDir.mockClear() +}) + +afterEach(cleanup) + +describe('RemoteFolderPicker', () => { + it('creates a folder on the backend under the current path and selects it', async () => { + render() + const selection = openPicker('/home/me') + + await screen.findByText('existing') + + fireEvent.click(screen.getByRole('button', { name: 'New folder' })) + const input = screen.getByRole('textbox', { name: 'Folder name' }) + fireEvent.change(input, { target: { value: 'fresh-project' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + await waitFor(() => expect(createRemoteDir).toHaveBeenCalledWith('/home/me/fresh-project')) + + // The picker lands inside the new folder, so Select picks it. + await waitFor(() => expect(screen.queryByRole('textbox', { name: 'Folder name' })).toBeNull()) + fireEvent.click(screen.getByRole('button', { name: 'Select folder' })) + + await expect(selection).resolves.toEqual(['/home/me/fresh-project']) + }) + + it('rejects names that would escape the current folder without calling the backend', async () => { + render() + void openPicker('/home/me') + + await screen.findByText('existing') + + fireEvent.click(screen.getByRole('button', { name: 'New folder' })) + const input = screen.getByRole('textbox', { name: 'Folder name' }) + + for (const name of ['..', 'a/b']) { + fireEvent.change(input, { target: { value: name } }) + fireEvent.keyDown(input, { key: 'Enter' }) + } + + expect(createRemoteDir).not.toHaveBeenCalled() + expect(screen.getByRole('textbox', { name: 'Folder name' })).toBeTruthy() + }) + + it('Escape abandons the new folder name without closing the picker', async () => { + render() + void openPicker('/home/me') + + await screen.findByText('existing') + + fireEvent.click(screen.getByRole('button', { name: 'New folder' })) + fireEvent.keyDown(screen.getByRole('textbox', { name: 'Folder name' }), { key: 'Escape' }) + + expect(screen.queryByRole('textbox', { name: 'Folder name' })).toBeNull() + expect(screen.getByRole('button', { name: 'Select folder' })).toBeTruthy() + expect(createRemoteDir).not.toHaveBeenCalled() + }) + + it('surfaces a backend failure inline and keeps the picker where it was', async () => { + createRemoteDir.mockRejectedValueOnce(new Error('Directory is not writable')) + render() + const selection = openPicker('/home/me') + + await screen.findByText('existing') + + fireEvent.click(screen.getByRole('button', { name: 'New folder' })) + const input = screen.getByRole('textbox', { name: 'Folder name' }) + fireEvent.change(input, { target: { value: 'locked' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + await screen.findByText(/Directory is not writable/) + fireEvent.click(screen.getByRole('button', { name: 'Select folder' })) + + await expect(selection).resolves.toEqual(['/home/me']) + }) +}) diff --git a/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx b/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx index 85fe59bd33..ef47083031 100644 --- a/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx +++ b/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx @@ -3,9 +3,11 @@ import { useEffect, useMemo, useState } from 'react' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' import { useI18n } from '@/i18n' -import { readDesktopDir, setDesktopFsRemotePicker } from '@/lib/desktop-fs' +import { createRemoteDir, readDesktopDir, setDesktopFsRemotePicker } from '@/lib/desktop-fs' import { displayPath, pathLeaf } from '@/lib/display-path' +import { isSubmitEnter } from '@/lib/ime' import { cn } from '@/lib/utils' function clean(path: string) { @@ -28,6 +30,17 @@ function pathName(path: string) { return pathLeaf(path) || path } +// One path segment only: the new folder goes directly under the current one. +function validFolderName(name: string) { + return Boolean(name) && name !== '.' && name !== '..' && !/[/\\]/.test(name) +} + +function childPath(parent: string, name: string) { + const base = clean(parent) + + return base === '/' ? `/${name}` : `${base}/${name}` +} + interface PendingSelection { defaultPath: string resolve: (paths: string[]) => void @@ -42,6 +55,10 @@ export function RemoteFolderPicker() { const [entries, setEntries] = useState>([]) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) + // null = not naming a new folder; a string is the name being typed. + const [newFolderName, setNewFolderName] = useState(null) + const [newFolderError, setNewFolderError] = useState(null) + const [creating, setCreating] = useState(false) useEffect(() => { setDesktopFsRemotePicker({ @@ -49,6 +66,8 @@ export function RemoteFolderPicker() { new Promise(resolve => { const defaultPath = clean(options?.defaultPath || '/') setCurrentPath(defaultPath) + setNewFolderName(null) + setNewFolderError(null) setPending({ defaultPath, resolve, title: options?.title || r.remotePickerTitle }) }) }) @@ -112,11 +131,48 @@ export function RemoteFolderPicker() { return out }, [currentPath]) + const cancelNewFolder = () => { + setNewFolderName(null) + setNewFolderError(null) + } + + const navigate = (path: string) => { + cancelNewFolder() + setCurrentPath(path) + } + const close = (paths: string[] = []) => { pending?.resolve(paths) setPending(null) setEntries([]) setError(null) + cancelNewFolder() + } + + const createFolder = async () => { + const name = (newFolderName ?? '').trim() + + if (creating) { + return + } + + if (!validFolderName(name)) { + setNewFolderError(r.remotePickerInvalidFolderName) + + return + } + + setCreating(true) + setNewFolderError(null) + + try { + const created = await createRemoteDir(childPath(currentPath, name)) + navigate(clean(created)) + } catch (err) { + setNewFolderError(r.remotePickerCreateFolderFailed(err instanceof Error ? err.message : String(err))) + } finally { + setCreating(false) + } } return ( @@ -124,6 +180,13 @@ export function RemoteFolderPicker() { { + // One cancel gesture does one thing: Escape abandons the name first. + if (newFolderName !== null) { + event.preventDefault() + cancelNewFolder() + } + }} >
{pending?.title || r.remotePickerTitle} @@ -139,7 +202,7 @@ export function RemoteFolderPicker() { index === crumbs.length - 1 && 'text-foreground' )} key={crumb.path} - onClick={() => setCurrentPath(crumb.path)} + onClick={() => navigate(crumb.path)} type="button" > {crumb.label} @@ -148,11 +211,43 @@ export function RemoteFolderPicker() {
- setCurrentPath(parentDir(currentPath))} - /> + navigate(parentDir(currentPath))} /> + {newFolderName !== null && ( +
+
+ + { + setNewFolderName(event.target.value) + setNewFolderError(null) + }} + onKeyDown={event => { + if (isSubmitEnter(event)) { + event.preventDefault() + void createFolder() + } + }} + placeholder={r.remotePickerFolderName} + size="sm" + value={newFolderName} + /> + +
+ {newFolderError &&
{newFolderError}
} +
+ )} {loading ? (
@@ -164,7 +259,7 @@ export function RemoteFolderPicker() {
{r.emptyBody}
) : ( entries.map(entry => ( - setCurrentPath(entry.path)} /> + navigate(entry.path)} /> )) )}
@@ -173,6 +268,15 @@ export function RemoteFolderPicker() {
{displayPath(currentPath)}
+ diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/utils.ts b/apps/desktop/src/app/session/hooks/use-message-stream/utils.ts index 7ff3859915..2cb7db5cfa 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/utils.ts @@ -14,6 +14,7 @@ type SessionRuntimeStatePatch = Partial< | 'personality' | 'provider' | 'reasoningEffort' + | 'reasoningEffortPending' | 'reasoningEffortWire' | 'serviceTier' | 'yolo' @@ -45,6 +46,7 @@ export function sessionInfoStatePatch(payload: GatewayEventPayload | undefined): if (typeof payload?.reasoning_effort === 'string') { patch.reasoningEffort = payload.reasoning_effort + patch.reasoningEffortPending = false } if (typeof payload?.reasoning_effort_wire === 'string') { @@ -85,6 +87,8 @@ export function applySessionInfoStatePatch( (patch.personality === undefined || patch.personality === state.personality) && (patch.provider === undefined || patch.provider === state.provider) && (patch.reasoningEffort === undefined || patch.reasoningEffort === state.reasoningEffort) && + (patch.reasoningEffortPending === undefined || + patch.reasoningEffortPending === Boolean(state.reasoningEffortPending)) && (patch.serviceTier === undefined || patch.serviceTier === state.serviceTier) && (patch.yolo === undefined || patch.yolo === state.yolo) ) { diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 131a94ab6f..4d96abdf7a 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -12,6 +12,7 @@ import { pathLabel } from '@/lib/chat-runtime' import { sanitizeComposerInput } from '@/lib/composer-input-sanitize' import { triggerHaptic } from '@/lib/haptics' import { setMutableRef } from '@/lib/mutable-ref' +import { isWindowsAbsolutePath } from '@/lib/path-compare' import { normalize } from '@/lib/text' import { transcribeAudioClientDirect } from '@/lib/voice-client-direct' import { clearClarifyRequest } from '@/store/clarify' @@ -89,7 +90,6 @@ interface HandoffResult { error?: string } -const WINDOWS_ABSOLUTE_PATH_RE = /^(?:[A-Za-z]:[\\/]|\\\\)/ const POSIX_ABSOLUTE_PATH_RE = /^\/(?!\/)/ // Terminal backends whose execution environment has its own filesystem @@ -109,7 +109,7 @@ function attachmentPathNeedsUpload(path: string, backendCwd?: null | string, ter return true } - return WINDOWS_ABSOLUTE_PATH_RE.test(path.trim()) && POSIX_ABSOLUTE_PATH_RE.test(backendCwd?.trim() || '') + return isWindowsAbsolutePath(path.trim()) && POSIX_ABSOLUTE_PATH_RE.test(backendCwd?.trim() || '') } /** diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 7d4cb2a3c8..56bfdeb356 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -5,6 +5,7 @@ import type { MutableRefObject } from 'react' import { useEffect, useRef } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { PRIMARY_SESSION_VIEW } from '@/app/chat/session-view' import { NO_PROJECT_ID } from '@/app/chat/sidebar/projects/workspace-groups' import { resolveSessionRpcOwner } from '@/app/contrib/wiring-routing' import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store' @@ -80,7 +81,9 @@ import { $removedSessionIds, $sessionMutationsInFlight } from '@/store/session-r import { requestForSessionProfile, type SessionProfileRoute } from '@/store/session-request-router' import { $sessionTiles, + dropSessionState, knownOwnerForSession, + publishSessionState, requestForOwnedSession, sessionTileOwnerRoute } from '@/store/session-states' @@ -92,6 +95,7 @@ import { deferred } from '../../../test/deferred' import { NEW_CHAT_ROUTE, sessionRoute } from '../../routes' import type { ClientSessionState } from '../../types' +import { applySessionInfoStatePatch, sessionInfoStatePatch } from './use-message-stream/utils' import { useSessionActions } from './use-session-actions' import { suppressTranscriptForView, transcriptRowContentKey } from './use-session-actions/transcript-provenance' import type { TranscriptViewCutoff } from './use-session-actions/transcript-provenance' @@ -1637,6 +1641,69 @@ describe('resumeSession failure recovery', () => { expect($resumeFailedSessionId.get()).toBeNull() }) + it("leaves the resumed session's effort pending until the built agent reports it (#79807)", async () => { + const sessionStateByRuntimeIdRef = { current: new Map() } + + // A deferred-build resume answers with the lazy shape: no reasoning_effort. + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'session.resume') { + return { + info: { lazy: true, model: 'qwen3.8-max' }, + messages: [], + resumed: params?.session_id, + session_id: 'runtime-1' + } as never + } + + return {} as never + }) + + vi.mocked(getLatestSessionMessages).mockResolvedValue({ messages: [] } as never) + + await runResume(requestGateway, { sessionStateByRuntimeIdRef }) + + const resumed = sessionStateByRuntimeIdRef.current.get('runtime-1')! + publishSessionState('runtime-1', resumed) + + expect($activeSessionId.get()).toBe('runtime-1') + expect(PRIMARY_SESSION_VIEW.$reasoningEffortPending.get()).toBe(true) + + publishSessionState( + 'runtime-1', + applySessionInfoStatePatch(resumed, sessionInfoStatePatch({ reasoning_effort: 'max' })) + ) + + expect(PRIMARY_SESSION_VIEW.$reasoningEffortPending.get()).toBe(false) + expect(PRIMARY_SESSION_VIEW.$reasoningEffort.get()).toBe('max') + dropSessionState('runtime-1') + }) + + it('does not mark a resumed effort pending when the resume reply already carries it', async () => { + const sessionStateByRuntimeIdRef = { current: new Map() } + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'session.resume') { + return { + info: { model: 'qwen3.8-max', reasoning_effort: '' }, + messages: [], + resumed: params?.session_id, + session_id: 'runtime-1' + } as never + } + + return {} as never + }) + + vi.mocked(getLatestSessionMessages).mockResolvedValue({ messages: [] } as never) + + await runResume(requestGateway, { sessionStateByRuntimeIdRef }) + + publishSessionState('runtime-1', sessionStateByRuntimeIdRef.current.get('runtime-1')!) + + expect(PRIMARY_SESSION_VIEW.$reasoningEffortPending.get()).toBe(false) + dropSessionState('runtime-1') + }) + it('resumes via the gateway default (deferred build) — not lazy, no eager opt-out', async () => { // The switch-latency fix lives backend-side: a normal cold resume gets the // gateway's default DEFERRED build (transcript returns immediately, agent diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index e67942d39d..18e1ba8e1c 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -23,6 +23,7 @@ import { stripPendingClarifyProjectionForCache, toChatMessages } from '@/lib/chat-messages' +import { markReasoningEffortPending } from '@/lib/chat-runtime' import { isMissingRpcMethod } from '@/lib/gateway-rpc' import { recoverInFlightTurnJournal } from '@/lib/inflight-turn-journal' import { setSessionYolo } from '@/lib/yolo-session' @@ -2054,8 +2055,8 @@ export function useSessionActions({ updateSessionState( resumed.session_id, state => ({ - ...state, - ...(runtimeInfo ?? {}), + // The deferred build reports the session's own effort later (#79807). + ...markReasoningEffortPending({ ...state, ...(runtimeInfo ?? {}) }), messages: visibleMessagesForView, transcriptProvenance, busy: resumedRunning, diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index 4014f3c05a..5df11ab5d8 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -1972,6 +1972,7 @@ type SessionRuntimeStatePatch = Partial< | 'personality' | 'provider' | 'reasoningEffort' + | 'reasoningEffortPending' | 'reasoningEffortWire' | 'serviceTier' | 'yolo' @@ -2096,6 +2097,7 @@ export function applyRuntimeInfo( if (typeof info.reasoning_effort === 'string') { sessionState.reasoningEffort = info.reasoning_effort + sessionState.reasoningEffortPending = false } if (typeof info.reasoning_effort_wire === 'string') { diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.reactivation.test.tsx b/apps/desktop/src/app/session/hooks/use-session-list-actions.reactivation.test.tsx new file mode 100644 index 0000000000..d026ab692c --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.reactivation.test.tsx @@ -0,0 +1,70 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' + +import type { SessionInfo, SidebarSessionsResponse } from '@/hermes' +import { ensureGatewayForAgent, setPrimaryGateway, setPrimaryGatewayConnection } from '@/store/gateway' +import { $sessions, setSessions } from '@/store/session' + +import { deferred } from '../../../test/deferred' + +import { useSessionListActions } from './use-session-list-actions' + +// Real gateway registry, stubbed transport: the activation epoch here is the +// one production bumps, not a test double. +const listSidebarSessions = vi.fn() + +vi.mock('@/hermes', async importOriginal => ({ + ...(await importOriginal>()), + getCronJobs: vi.fn(async () => []), + listSidebarSessions: (...args: unknown[]) => listSidebarSessions(...args) +})) + +const row = (id: string): SessionInfo => + ({ + id, + last_active: 1000, + message_count: 3, + profile: 'default', + source: 'desktop', + started_at: 900, + title: `Chat ${id}` + }) as SessionInfo + +const page = (sessions: SessionInfo[]): SidebarSessionsResponse => ({ + recents: { sessions }, + cron: { sessions: [] }, + messaging: { sessions: [] } +}) + +afterEach(() => setSessions([])) + +// #67600 / #88866: resuming a default-profile chat on the local source routes +// through ensureGatewayAgent('local', 'default'). That activation lands on the +// socket the window already shows, so no route atom moves and no effect asks +// for the list again; the sidebar page in flight at that moment is the only +// one the window gets. +it('keeps the default-profile sidebar when the active route is re-activated mid-refresh', async () => { + const primary = { connectionState: 'open', request: vi.fn(async () => ({})) } + setPrimaryGateway(primary as never, 'default') + setPrimaryGatewayConnection({ connectionId: 'local', mode: 'local' } as never) + + const pending = deferred() + const rows = [row('a'), row('b')] + listSidebarSessions.mockReturnValueOnce(pending.promise).mockResolvedValue(page(rows)) + + const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' })) + let refresh!: Promise + + act(() => { + refresh = result.current.refreshSessions() + }) + + await ensureGatewayForAgent('local', 'default') + + await act(async () => { + pending.resolve(page(rows)) + await refresh + }) + + expect($sessions.get().map(session => session.id)).toEqual(['a', 'b']) +}) diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx index 7c22c63b37..bec573ccd6 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx @@ -413,9 +413,11 @@ describe('refreshSessions identity + loading hygiene', () => { } }) - it('clears initial loading after a failed source activation advances the gateway epoch', async () => { + it('re-reads for the current route after a failed source activation advances the gateway epoch', async () => { const pending = deferred() - listSidebarSessions.mockReturnValue(pending.promise) + listSidebarSessions + .mockReturnValueOnce(pending.promise) + .mockResolvedValueOnce(sidebar({ sessions: [row('current')] })) const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' })) let refresh!: Promise @@ -427,8 +429,10 @@ describe('refreshSessions identity + loading hygiene', () => { expect($sessionsLoading.get()).toBe(true) // A source dial owns a new activation epoch even when it fails and leaves - // the previous source active. Its in-flight session response is stale, but - // it still owns the initial loading state and must release that state. + // the previous source active. Its in-flight response is stale and must not + // publish, but nothing else re-requests the list when the route atoms did + // not move, so the refresh re-reads under the new epoch and releases the + // initial loading state itself. gatewayScope.epoch += 1 await act(async () => { @@ -436,9 +440,56 @@ describe('refreshSessions identity + loading hygiene', () => { await refresh }) - expect($sessions.get()).toEqual([]) + expect(listSidebarSessions).toHaveBeenCalledTimes(2) + expect($sessions.get().map(session => session.id)).toEqual(['current']) expect($sessionsLoading.get()).toBe(false) }) + + // #67600 / #88866: re-activating the route the window is already on (a + // resume or rail click through ensureGatewayAgent('local', 'default')) + // advances the epoch without changing any route atom, so no effect fires a + // follow-up refresh. The in-flight 200-with-rows page used to be discarded + // and the sidebar stayed on "No sessions" until the user re-selected the + // profile. + it('fills the sidebar when a same-route re-activation lands mid-refresh', async () => { + const pending = deferred() + const rows = [row('a'), row('b')] + listSidebarSessions.mockReturnValueOnce(pending.promise).mockResolvedValueOnce(sidebar({ sessions: rows })) + const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' })) + + let refresh!: Promise + + act(() => { + refresh = result.current.refreshSessions() + }) + + gatewayScope.epoch += 1 + + await act(async () => { + pending.resolve(sidebar({ sessions: rows })) + await refresh + }) + + expect($sessions.get().map(session => session.id)).toEqual(['a', 'b']) + }) + + it('does not re-read a refresh a newer one already superseded', async () => { + const older = deferred() + listSidebarSessions.mockReturnValueOnce(older.promise).mockResolvedValueOnce(sidebar({ sessions: [row('newer')] })) + const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' })) + + const olderRefresh = result.current.refreshSessions() + gatewayScope.epoch += 1 + + await act(async () => { + await result.current.refreshSessions() + older.resolve(sidebar({ sessions: [row('older')] })) + await olderRefresh + }) + + expect(listSidebarSessions).toHaveBeenCalledTimes(2) + expect($sessions.get().map(session => session.id)).toEqual(['newer']) + }) }) describe('refreshSessions batches slices into one request', () => { @@ -812,4 +863,22 @@ describe('messaging profile scope', () => { expect(listAllProfileSessions).not.toHaveBeenCalled() expect($messagingPlatformTotals.get()).toEqual({ 'work:signal': 12 }) }) + + it('re-reads the messaging slice when a same-route re-activation lands mid-refresh', async () => { + const pending = deferred<{ sessions: SessionInfo[]; total: number }>() + const rows = [row('tg', { source: 'telegram' })] + listAllProfileSessions.mockReturnValueOnce(pending.promise).mockResolvedValueOnce({ sessions: rows, total: 1 }) + const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' })) + + const refresh = result.current.refreshMessagingSessions() + gatewayScope.epoch += 1 + + await act(async () => { + pending.resolve({ sessions: rows, total: 1 }) + await refresh + }) + + expect(listAllProfileSessions).toHaveBeenCalledTimes(2) + expect($messagingSessions.get().map(session => session.id)).toEqual(['tg']) + }) }) diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts index fb62d381cb..a29f6babea 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts @@ -126,7 +126,6 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg /** Refresh the active profile's messaging-platform sidebar slice. */ const refreshMessagingSessions = useCallback(async () => { const sessionProfile = sidebarProfileForScope(profileScope) - const activationEpoch = gatewayActivationEpoch() // A callback captured before a profile switch may still be queued by an // event subscription. Do not let it start a request against the old scope. @@ -137,16 +136,27 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg const requestId = refreshMessagingSessionsRequestRef.current + 1 refreshMessagingSessionsRequestRef.current = requestId - try { - const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', sessionProfile, { + const owns = () => + refreshMessagingSessionsRequestRef.current === requestId && + sidebarProfileForScope(profileScopeRef.current) === sessionProfile + + const fetchPage = () => + listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', sessionProfile, { excludeSources: MESSAGING_EXCLUDED_SOURCES }) - if ( - refreshMessagingSessionsRequestRef.current !== requestId || - sidebarProfileForScope(profileScopeRef.current) !== sessionProfile || - gatewayActivationEpoch() !== activationEpoch - ) { + try { + let activationEpoch = gatewayActivationEpoch() + let result = await fetchPage() + + // Same re-read as refreshSessions: a mid-request activation voids this + // page, and nothing else asks again when the route atoms did not move. + while (owns() && gatewayActivationEpoch() !== activationEpoch) { + activationEpoch = gatewayActivationEpoch() + result = await fetchPage() + } + + if (!owns()) { return } @@ -167,7 +177,6 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg const loadMoreMessagingForPlatform = useCallback( async (platform: string) => { const sessionProfile = sidebarProfileForScope(profileScope) - const activationEpoch = gatewayActivationEpoch() if (sidebarProfileForScope(profileScopeRef.current) !== sessionProfile) { return @@ -183,27 +192,31 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg const inPlatform = (s: SessionInfo) => normalizeSessionSource(s.source) === platform && inProfile(s) const loaded = $messagingSessions.get().filter(inPlatform).length + const owns = () => + loadMoreMessagingRequestRef.current[requestKey] === requestId && + sidebarProfileForScope(profileScopeRef.current) === sessionProfile + + const fetchPage = () => + listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', sessionProfile, { + source: platform + }) + let result try { - result = await listAllProfileSessions( - loaded + SIDEBAR_SESSIONS_PAGE_SIZE, - 1, - 'exclude', - 'recent', - sessionProfile, - { source: platform } - ) + let activationEpoch = gatewayActivationEpoch() + result = await fetchPage() + + while (owns() && gatewayActivationEpoch() !== activationEpoch) { + activationEpoch = gatewayActivationEpoch() + result = await fetchPage() + } } catch { // Non-fatal: leave the platform's loaded rows and total unchanged. return } - if ( - loadMoreMessagingRequestRef.current[requestKey] !== requestId || - sidebarProfileForScope(profileScopeRef.current) !== sessionProfile || - gatewayActivationEpoch() !== activationEpoch - ) { + if (!owns()) { return } @@ -244,7 +257,6 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg const refreshSessions = useCallback( async (shouldPublish: () => boolean = () => true) => { const sessionProfile = sidebarProfileForScope(profileScope) - const activationEpoch = gatewayActivationEpoch() if (!shouldPublish() || sidebarProfileForScope(profileScopeRef.current) !== sessionProfile) { return @@ -262,6 +274,11 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg setSessionsLoading(true) } + const owns = () => + shouldPublish() && + refreshSessionsRequestRef.current === requestId && + sidebarProfileForScope(profileScopeRef.current) === sessionProfile + try { const limit = $sessionsLimit.get() @@ -278,21 +295,33 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg // Batched: one request opens each profile DB once and returns all three // source-scoped slices, instead of three separate listAllProfileSessions // calls that each reopened + re-counted every profile DB per refresh. - const result = await listSidebarSessions({ - recentsProfile: sessionProfile, - recentsLimit: limit, - recentsExclude: SIDEBAR_EXCLUDED_SOURCES, - cronLimit: CRON_SECTION_LIMIT, - messagingLimit: MESSAGING_SECTION_LIMIT, - messagingExclude: MESSAGING_EXCLUDED_SOURCES - }) + const fetchPage = () => + listSidebarSessions({ + recentsProfile: sessionProfile, + recentsLimit: limit, + recentsExclude: SIDEBAR_EXCLUDED_SOURCES, + cronLimit: CRON_SECTION_LIMIT, + messagingLimit: MESSAGING_SECTION_LIMIT, + messagingExclude: MESSAGING_EXCLUDED_SOURCES + }) - if ( - shouldPublish() && - refreshSessionsRequestRef.current === requestId && - sidebarProfileForScope(profileScopeRef.current) === sessionProfile && - gatewayActivationEpoch() === activationEpoch - ) { + let activationEpoch = gatewayActivationEpoch() + let result = await fetchPage() + + // A gateway activation that landed mid-request voids this page: it may + // describe the source the window just left. But every activation bumps + // the epoch, including a re-activation of the route already in front + // (a resume or profile click through ensureGatewayAgent), and those + // move no route atom, so no effect asks for the list again. Dropping + // the page there left the sidebar on "No sessions" while the backend + // held the rows (#67600). Still the newest refresh for this scope, so + // re-read under the current epoch instead. + while (owns() && gatewayActivationEpoch() !== activationEpoch) { + activationEpoch = gatewayActivationEpoch() + result = await fetchPage() + } + + if (owns()) { const recents = result.recents setCorruptSessionStores(result.storage) diff --git a/apps/desktop/src/app/settings/connections-registry.test.tsx b/apps/desktop/src/app/settings/connections-registry.test.tsx index 2111ad0db1..3e9233eb56 100644 --- a/apps/desktop/src/app/settings/connections-registry.test.tsx +++ b/apps/desktop/src/app/settings/connections-registry.test.tsx @@ -192,6 +192,37 @@ describe('ConnectionsRegistrySection', () => { }) }) + it('signs a hand-registered Cloud connection in and saves it as oauth (#89529)', async () => { + const oauthLoginConnectionConfig = vi.fn().mockResolvedValue({ connected: true, ok: true }) + Object.assign(window.hermesDesktop!, { oauthLoginConnectionConfig }) + + render() + + await screen.findByText('Homelab') + fireEvent.click(screen.getByText('Add connection')) + fireEvent.click(screen.getByRole('button', { name: 'Hermes Cloud' })) + fireEvent.change(screen.getByPlaceholderText('Homelab'), { target: { value: 'Team cloud' } }) + fireEvent.change(screen.getByPlaceholderText('http://homelab.lan:9119'), { + target: { value: 'https://team.hermes.cloud' } + }) + + // Cloud never takes a pasted token: no token box, a sign-in button instead. + expect(screen.queryByPlaceholderText('Paste session token')).toBeNull() + fireEvent.click(await screen.findByRole('button', { name: /sign in/i })) + await waitFor(() => expect(oauthLoginConnectionConfig).toHaveBeenCalledWith('https://team.hermes.cloud')) + + fireEvent.click(screen.getByText('Save connection').closest('button')!) + + await waitFor(() => expect(save).toHaveBeenCalledTimes(1)) + expect(save.mock.calls[0][0]).toMatchObject({ + authMode: 'oauth', + kind: 'cloud', + label: 'Team cloud', + url: 'https://team.hermes.cloud' + }) + expect(save.mock.calls[0][0].token).toBeUndefined() + }) + it('saves a custom remote Hermes path for SSH connections', async () => { render() diff --git a/apps/desktop/src/app/settings/connections-registry.tsx b/apps/desktop/src/app/settings/connections-registry.tsx index d14644626c..d29d0c71d0 100644 --- a/apps/desktop/src/app/settings/connections-registry.tsx +++ b/apps/desktop/src/app/settings/connections-registry.tsx @@ -239,7 +239,7 @@ export function ConnectionsRegistrySection() { const remote = useRemoteSetup({ host: 'registry', - enabled: editor?.kind === 'remote', + enabled: editor?.kind === 'remote' || editor?.kind === 'cloud', onNotice: notify }) @@ -278,7 +278,7 @@ export function ConnectionsRegistrySection() { setDupeError(null) remote.reset({ url: saved?.url || '', - authMode: saved?.authMode || 'token', + authMode: next?.kind === 'cloud' ? 'oauth' : saved?.authMode || 'token', tokenSet: saved?.tokenSet ?? false, tokenPreview: saved?.tokenPreview ?? null }) @@ -323,9 +323,9 @@ export function ConnectionsRegistrySection() { if (editor.kind === 'remote' || editor.kind === 'cloud') { payload.url = remote.payload.remoteUrl - payload.authMode = remote.credentials.authMode + payload.authMode = editor.kind === 'cloud' ? 'oauth' : remote.credentials.authMode - if (remote.payload.remoteToken) { + if (editor.kind === 'remote' && remote.payload.remoteToken) { payload.token = remote.payload.remoteToken } @@ -678,6 +678,12 @@ export function ConnectionsRegistrySection() { key={kind} onClick={() => { setDupeError(null) + + // Cloud uses browser sign-in even after a token-auth remote edit. + if (kind === 'cloud') { + remote.setAuthMode('oauth') + } + setEditor({ ...editor, kind }) }} size="sm" @@ -714,6 +720,37 @@ export function ConnectionsRegistrySection() { /> )} + {editor.kind === 'cloud' && ( + {t.settings.gateway.signedIn} + ) : ( + + ) + } + description={ + remote.credentials.oauthConnected + ? remote.isPassword + ? t.settings.gateway.authSignedInPassword + : t.settings.gateway.authSignedInOauth + : remote.isPassword + ? t.settings.gateway.authNeedsPassword + : t.settings.gateway.authNeedsOauth(remote.providerLabel) + } + title={t.settings.gateway.authTitle} + /> + )} + {(editor.kind === 'remote' || editor.kind === 'cloud') && (
diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index 7014f8aa0b..859697e581 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -381,7 +381,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set active: activeView === 'sessions', icon: Archive, id: 'sessions', - label: t.settings.nav.archivedChats, + label: t.settings.nav.sessions, onSelect: () => setActiveView('sessions') }, { diff --git a/apps/desktop/src/app/settings/subpages.test.ts b/apps/desktop/src/app/settings/subpages.test.ts index 4bc1a9133b..7efbf89891 100644 --- a/apps/desktop/src/app/settings/subpages.test.ts +++ b/apps/desktop/src/app/settings/subpages.test.ts @@ -44,6 +44,20 @@ describe('settings subpage routing', () => { } }) + it('gives every settings group its own nav label in every locale', () => { + for (const locale of Object.values(TRANSLATIONS)) { + const nav: Record = locale.settings.nav + + for (const view of Object.keys(OTHER_SUBPAGES)) { + expect(nav[view]).toBeTruthy() + } + + // Sessions also holds the default project folder, so it can't wear the + // label of the palette row that lands on its archive page. + expect(nav.sessions).not.toBe(nav.archivedChats) + } + }) + it('routes every curated field and legacy target to its owning child before consuming the target', () => { for (const section of SECTIONS.filter(section => section.keys.length)) { for (const field of section.keys) { diff --git a/apps/desktop/src/app/settings/use-settings-search.ts b/apps/desktop/src/app/settings/use-settings-search.ts index 384ef0c8eb..c70f4fb677 100644 --- a/apps/desktop/src/app/settings/use-settings-search.ts +++ b/apps/desktop/src/app/settings/use-settings-search.ts @@ -125,10 +125,7 @@ export function useSettingsSearchCatalog(enabled: boolean) { { settings: Settings2, tools: Wrench } ) - const pageLabels: Record = { - ...t.settings.nav, - sessions: t.settings.nav.archivedChats - } + const pageLabels: Record = t.settings.nav // The pages that own rows: config sections and the standalone views. const parents = [ diff --git a/apps/desktop/src/app/shell/model-catalog-menu.test.tsx b/apps/desktop/src/app/shell/model-catalog-menu.test.tsx index 98f371454e..00727d5307 100644 --- a/apps/desktop/src/app/shell/model-catalog-menu.test.tsx +++ b/apps/desktop/src/app/shell/model-catalog-menu.test.tsx @@ -25,6 +25,7 @@ import { setModelVisibilityOpen, setVisibleModels } from '@/store/model-visibility' +import { $defaultReasoningEffort } from '@/store/session' import type { LocalRuntimeJob } from '@/types/hermes' import { ModelCatalogMenu, type ModelMenuController } from './model-catalog-menu' @@ -76,17 +77,36 @@ afterEach(() => { // The backend mock echoes this snapshot; retire fixture jobs before jsdom // disappears so an in-flight app-level poll cannot schedule another tick. queryClient.setQueryData(localModelsKey(localModelsOwner(), 'jobs'), []) + $defaultReasoningEffort.set('') vi.clearAllMocks() }) +describe('the current row effort', () => { + it('does not label the current model with the profile default before its session reports one (#79807)', async () => { + $defaultReasoningEffort.set('ultra') + renderMenu({ effortPending: true, model: 'gemini-2.5-flash', provider: 'google' }) + + const row = (await screen.findByText(/Gemini 2\.5 Flash/i)).closest('[role="menuitem"]')! + + expect(row.textContent).not.toContain('Ultra') + cleanup() + + renderMenu({ model: 'gemini-2.5-flash', provider: 'google' }) + + const settled = (await screen.findByText(/Gemini 2\.5 Flash/i)).closest('[role="menuitem"]')! + + expect(settled.textContent).toContain('Ultra') + }) +}) + // A minimal controller — these tests are about the CATALOG's own behaviour // (what it lists, what it offers), not about what any host does with a pick. -function renderMenu() { +function renderMenu(current: Partial = {}) { const select = vi.fn() const controller: ModelMenuController = { applyPreset: vi.fn(), - current: { effort: '', fast: false, model: '', provider: '' }, + current: { effort: '', fast: false, model: '', provider: '', ...current }, presetFor: () => ({}), select, setOptions: vi.fn() diff --git a/apps/desktop/src/app/shell/model-catalog-menu.tsx b/apps/desktop/src/app/shell/model-catalog-menu.tsx index 10e21bc303..8f664dfc50 100644 --- a/apps/desktop/src/app/shell/model-catalog-menu.tsx +++ b/apps/desktop/src/app/shell/model-catalog-menu.tsx @@ -72,6 +72,8 @@ export const ModelMenuCloseContext = createContext<() => void>(() => {}) * `effort` is '' for "inherit the default" and 'none' for thinking off. */ export interface ModelChoice { effort: string + /** `effort` is not reported yet, so '' is unknown rather than the default (#79807). */ + effortPending?: boolean /** Level the route actually sends for `effort` (`session.info.reasoning_effort_wire`); '' = unknown. */ effortWire?: string fast: boolean @@ -576,7 +578,7 @@ export function ModelCatalogMenu({ const meta = [ tag || null, fastControl.kind !== 'none' && fastControl.on ? copy.fast : null, - (caps?.reasoning ?? true) + (caps?.reasoning ?? true) && !(isCurrent && current.effortPending) ? reasoningEffortLabel(effEffort || defaultEffort, isCurrent ? current.effortWire : undefined) : null ] diff --git a/apps/desktop/src/app/shell/model-menu-panel.test.tsx b/apps/desktop/src/app/shell/model-menu-panel.test.tsx index 76ceec5108..870b51ec47 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.test.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.test.tsx @@ -268,6 +268,37 @@ describe('ModelMenuPanel search', () => { }) }) + it('hovering a model row leaves focus in the search field and arrows still drive the list (#53980)', async () => { + const { content, onSelectModel } = renderPanel() + + await content.findByText('DeepSeek') + + const input = screen.getByRole('textbox', { name: 'Search models' }) + input.focus() + fireEvent.change(input, { target: { value: 'gemini' } }) + + await vi.waitFor(() => { + expect(rowWithText(content, /Gemini 2\.5 Pro/i)).not.toBeNull() + }) + + // A real hand on the mouse: wake the rows, then move over one. + fireEvent.mouseMove(window) + fireEvent.pointerMove(rowWithText(content, /Gemini 2\.5 Pro/i)!, { pointerType: 'mouse' }) + + expect(input.ownerDocument.activeElement).toBe(input) + + fireEvent.keyDown(input, { key: 'ArrowDown' }) + fireEvent.keyDown(input, { key: 'Enter' }) + + await vi.waitFor(() => { + expect(onSelectModel).toHaveBeenCalledWith({ + model: 'gemini-2.5-flash', + provider: 'google', + sessionId: 'runtime-1' + }) + }) + }) + it('with no query the selection sits on the current model, so Enter closes without switching', async () => { $currentProvider.set('google') $currentModel.set('gemini-3.1-pro') diff --git a/apps/desktop/src/app/shell/use-model-menu-controller.ts b/apps/desktop/src/app/shell/use-model-menu-controller.ts index ced8d91f0c..916b45bbb1 100644 --- a/apps/desktop/src/app/shell/use-model-menu-controller.ts +++ b/apps/desktop/src/app/shell/use-model-menu-controller.ts @@ -59,6 +59,7 @@ export function useModelMenuController({ const currentProvider = useStore(view.$provider) const currentReasoningEffort = useStore(view.$reasoningEffort) const currentReasoningEffortWire = useStore(view.$reasoningEffortWire) + const currentReasoningEffortPending = useStore(view.$reasoningEffortPending) const modelPresets = useStore($modelPresets) const defaultEffort = useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT const touchesPrimary = view.kind === 'primary' @@ -89,6 +90,7 @@ export function useModelMenuController({ sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: next, + reasoningEffortPending: false, reasoningEffortWire: '' })) } @@ -164,6 +166,7 @@ export function useModelMenuController({ current: { effort: currentReasoningEffort, + effortPending: currentReasoningEffortPending, effortWire: currentReasoningEffortWire, fast: currentFastMode, model: optionsModel, diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index 02294c81ea..e4781ad0ac 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -159,6 +159,10 @@ export interface ClientSessionState { /** Gateway-reported wire level for `reasoningEffort`; '' until the backend * has stamped the current pick (so a clamp is never inferred client-side). */ reasoningEffortWire?: string + /** The runtime has not reported this session's effort yet, so '' above means + * "unknown", not "profile default". A cold resume answers before the agent + * builds, and only the built agent knows the session's own pin (#79807). */ + reasoningEffortPending?: boolean serviceTier: string fast: boolean yolo: boolean diff --git a/apps/desktop/src/components/assistant-ui/catalog-install-tool.test.tsx b/apps/desktop/src/components/assistant-ui/catalog-install-tool.test.tsx index 1c4563f196..c77ed2a801 100644 --- a/apps/desktop/src/components/assistant-ui/catalog-install-tool.test.tsx +++ b/apps/desktop/src/components/assistant-ui/catalog-install-tool.test.tsx @@ -89,6 +89,7 @@ function view(sessionId: string): SessionView { $model: atom(''), $provider: atom(''), $reasoningEffort: atom(''), + $reasoningEffortPending: atom(false), $reasoningEffortWire: atom(''), $runtimeId: atom(sessionId), $storedId: atom(sessionId), diff --git a/apps/desktop/src/components/assistant-ui/connector-tool.test.tsx b/apps/desktop/src/components/assistant-ui/connector-tool.test.tsx index e893e60c3a..086e50dcf4 100644 --- a/apps/desktop/src/components/assistant-ui/connector-tool.test.tsx +++ b/apps/desktop/src/components/assistant-ui/connector-tool.test.tsx @@ -76,6 +76,7 @@ function view(sessionId: string): SessionView { $model: atom(''), $provider: atom(''), $reasoningEffort: atom(''), + $reasoningEffortPending: atom(false), $reasoningEffortWire: atom(''), $runtimeId: atom(sessionId), $storedId: atom(sessionId), diff --git a/apps/desktop/src/components/assistant-ui/mcp-setup-tool.test.tsx b/apps/desktop/src/components/assistant-ui/mcp-setup-tool.test.tsx index 67e61c295e..e6d486cc10 100644 --- a/apps/desktop/src/components/assistant-ui/mcp-setup-tool.test.tsx +++ b/apps/desktop/src/components/assistant-ui/mcp-setup-tool.test.tsx @@ -78,6 +78,7 @@ function view(sessionId: string): SessionView { $model: atom(''), $provider: atom(''), $reasoningEffort: atom(''), + $reasoningEffortPending: atom(false), $reasoningEffortWire: atom(''), $runtimeId: atom(sessionId), $storedId: atom(sessionId), diff --git a/apps/desktop/src/components/assistant-ui/thread/message-parts-connectors.test.tsx b/apps/desktop/src/components/assistant-ui/thread/message-parts-connectors.test.tsx index 9f03825c82..a6997fd349 100644 --- a/apps/desktop/src/components/assistant-ui/thread/message-parts-connectors.test.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/message-parts-connectors.test.tsx @@ -84,6 +84,7 @@ function view(sessionId: string, storedId: string): SessionView { $model: atom(''), $provider: atom(''), $reasoningEffort: atom(''), + $reasoningEffortPending: atom(false), $reasoningEffortWire: atom(''), $runtimeId: atom(sessionId), $storedId: atom(storedId), diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx index 185e70abce..fcc665a7a8 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx @@ -16,6 +16,7 @@ import { import { ComposerDirectiveActions } from '@/app/chat/composer/directive-actions' import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from '@/app/chat/composer/drop-affordance' import { + ackComposerInsert, type ComposerInsertMode, focusComposerInput, markActiveComposer, @@ -188,11 +189,11 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess }, []) const appendExternalText = useCallback( - (text: string, mode: ComposerInsertMode) => { + (text: string, mode: ComposerInsertMode): boolean => { const value = text.trim() if (!value) { - return + return false } rememberInitialDraft() @@ -211,6 +212,8 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess } setFocusRequestId(id => id + 1) + + return true }, [aui, rememberInitialDraft] ) @@ -248,9 +251,11 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess } }) - const offInsert = onComposerInsertRequest(({ mode, target, text }) => { + const offInsert = onComposerInsertRequest(({ mode, target, text, token }) => { if (target === 'edit') { - appendExternalText(text, mode) + // Tokened inserts come from the plugin SDK: acknowledge whether the + // text landed instead of reporting success unconditionally. + ackComposerInsert(token, appendExternalText(text, mode)) } }) diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts index 3c04f0ed32..a7508cc7b0 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts @@ -7,9 +7,11 @@ import { clampForDisplay, countDiffLineStats, inlineDiffFromResult, + isPreviewableTarget, MAX_TOOL_RENDER_CHARS, prettyJson, - type ToolPart + type ToolPart, + toolPreviewOutcome } from './fallback-model' const part = (overrides: Partial): ToolPart => ({ @@ -464,6 +466,27 @@ describe('buildToolView title actions', () => { }) }) +// #85132: Windows agents write `C:\\...` / UNC paths; those must get the same +// artifact preview tag a POSIX `/Users/...` path gets. +describe('Windows absolute preview targets', () => { + it.each(['C:\\Users\\me\\report.html', 'D:/work/report.htm', '\\\\server\\share\\report.html'])( + 'tags a written %s as a previewable artifact', + path => { + const outcome = toolPreviewOutcome( + part({ args: { content: '

hi

', path }, result: { bytes_written: 11 }, toolName: 'write_file' }) + ) + + expect(outcome.previewTarget).toBe(path) + expect(isPreviewableTarget(outcome.previewTarget)).toBe(true) + } + ) + + it('keeps non-HTML Windows files out of the preview tag, like POSIX ones', () => { + expect(isPreviewableTarget('C:\\Users\\me\\notes.txt')).toBe(isPreviewableTarget('/Users/me/notes.txt')) + expect(isPreviewableTarget('C:\\Users\\me\\notes.txt')).toBe(false) + }) +}) + describe('clampForDisplay', () => { it('passes short payloads through untouched', () => { expect(clampForDisplay('hello')).toBe('hello') diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model/targets.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model/targets.ts index 01605f80b4..a6cf124a56 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback-model/targets.ts +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model/targets.ts @@ -1,3 +1,5 @@ +import { isWindowsAbsolutePath } from '@/lib/path-compare' + import type { ToolPart } from './types' export function looksLikeUrl(value: string): boolean { @@ -5,7 +7,7 @@ export function looksLikeUrl(value: string): boolean { } export function looksLikePath(value: string): boolean { - return /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value) + return /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value) || isWindowsAbsolutePath(value) } export function isPreviewableTarget(target: string): boolean { @@ -17,7 +19,7 @@ export function isPreviewableTarget(target: string): boolean { return Boolean( target && (/^file:\/\//i.test(target) || - /^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(target) || + (looksLikePath(target) && /\.html?$/i.test(target)) || /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(target)) ) } diff --git a/apps/desktop/src/components/ui/dropdown-menu.test.tsx b/apps/desktop/src/components/ui/dropdown-menu.test.tsx new file mode 100644 index 0000000000..561f22279c --- /dev/null +++ b/apps/desktop/src/components/ui/dropdown-menu.test.tsx @@ -0,0 +1,134 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSearch, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger +} from './dropdown-menu' + +// Radix menus use pointer capture and scrollIntoView; jsdom has neither. +beforeAll(() => { + Element.prototype.hasPointerCapture ??= () => false + Element.prototype.releasePointerCapture ??= () => undefined + Element.prototype.scrollIntoView ??= () => undefined +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +const hover = (element: Element) => fireEvent.pointerMove(element, { pointerType: 'mouse' }) +const unhover = (element: Element) => fireEvent.pointerLeave(element, { pointerType: 'mouse' }) + +function SearchableMenu({ withSearch = true }: { withSearch?: boolean }) { + return ( + + + {withSearch && } + Plain row + + Radio row + + + Sub row + + Sub option + + + + + ) +} + +describe('DropdownMenuSearch hover focus', () => { + it('keeps focus in the search field while the mouse moves over rows (#53980)', () => { + render() + + const search = screen.getByRole('textbox', { name: 'Search' }) + search.focus() + expect(search.ownerDocument.activeElement).toBe(search) + + for (const name of ['Plain row', 'Radio row', 'Sub row']) { + const row = screen.getByText(name).closest('[role^="menuitem"]')! + hover(row) + expect(search.ownerDocument.activeElement).toBe(search) + unhover(row) + expect(search.ownerDocument.activeElement).toBe(search) + } + }) + + it('still highlights the hovered row without taking focus', () => { + render() + + screen.getByRole('textbox', { name: 'Search' }).focus() + const row = screen.getByRole('menuitem', { name: 'Plain row' }) + + hover(row) + expect(row.hasAttribute('data-highlighted')).toBe(true) + + unhover(row) + expect(row.hasAttribute('data-highlighted')).toBe(false) + }) + + it('opens a hovered submenu and closes it when another row is hovered, without taking focus', () => { + vi.useFakeTimers() + render() + + const search = screen.getByRole('textbox', { name: 'Search' }) + search.focus() + + hover(screen.getByRole('menuitem', { name: 'Sub row' })) + act(() => vi.advanceTimersByTime(200)) + + expect(screen.queryByRole('menuitem', { name: 'Sub option' })).not.toBeNull() + expect(search.ownerDocument.activeElement).toBe(search) + + hover(screen.getByRole('menuitem', { name: 'Plain row' })) + act(() => vi.advanceTimersByTime(500)) + + expect(screen.queryByRole('menuitem', { name: 'Sub option' })).toBeNull() + expect(search.ownerDocument.activeElement).toBe(search) + }) + + it('drops a pending submenu hover-open once the user types, so the submenu cannot take the caret', () => { + vi.useFakeTimers() + render() + + const search = screen.getByRole('textbox', { name: 'Search' }) + search.focus() + + hover(screen.getByRole('menuitem', { name: 'Sub row' })) + fireEvent.keyDown(search, { key: 'g' }) + act(() => vi.advanceTimersByTime(200)) + + expect(screen.queryByRole('menuitem', { name: 'Sub option' })).toBeNull() + expect(search.ownerDocument.activeElement).toBe(search) + }) + + it('keeps Radix hover-to-focus once focus has left the search field', () => { + render() + + const row = screen.getByRole('menuitem', { name: 'Plain row' }) + screen.getByRole('menuitemradio', { name: 'Radio row' }).focus() + + hover(row) + expect(row.ownerDocument.activeElement).toBe(row) + }) + + it('leaves menus without a search field on Radix hover-to-focus', () => { + render() + + const row = screen.getByRole('menuitem', { name: 'Plain row' }) + + hover(row) + expect(row.ownerDocument.activeElement).toBe(row) + }) +}) diff --git a/apps/desktop/src/components/ui/dropdown-menu.tsx b/apps/desktop/src/components/ui/dropdown-menu.tsx index 5da802b690..5a63fdecc7 100644 --- a/apps/desktop/src/components/ui/dropdown-menu.tsx +++ b/apps/desktop/src/components/ui/dropdown-menu.tsx @@ -23,6 +23,148 @@ export const dropdownMenuSectionLabel = 'px-2.5 pt-1 pb-0.5 text-[0.625rem] font // is a filter keystroke and is stopped so the menu's typeahead doesn't hijack it. const DROPDOWN_NAV_KEYS = new Set(['ArrowDown', 'ArrowUp', 'Enter', 'Escape', 'Tab']) +// Radix highlights a row by FOCUSING it on hover (MenuItemImpl's pointermove +// calls item.focus()). In a menu with a DropdownMenuSearch, that pulls the +// caret out of the field mid-word (#53980). So while the field holds focus, +// rows cancel the pointermove (Radix skips its handler when the event is +// defaultPrevented) and set `data-highlighted` themselves. Once focus is +// elsewhere, e.g. arrowed onto a row, Radix hover applies as usual. +function searchHoldsFocus(row: HTMLElement): boolean { + const active = row.ownerDocument.activeElement + + return Boolean( + active?.closest('[data-slot="dropdown-menu-search"]') && row.closest('[role="menu"]')?.contains(active) + ) +} + +const searchHoverClass = 'data-[highlighted]:bg-(--ui-control-active-background) data-[highlighted]:text-foreground' + +type RowPointerHandlers = Pick, 'onPointerLeave' | 'onPointerMove'> + +function useSearchSafeHover( + { onPointerLeave, onPointerMove }: RowPointerHandlers, + { enter, leave }: { enter?: () => void; leave?: () => void } = {} +) { + const [hovered, setHovered] = React.useState(false) + + return { + ...(hovered ? { 'data-highlighted': '' } : {}), + onPointerLeave: (event: React.PointerEvent) => { + onPointerLeave?.(event) + + if (event.defaultPrevented || event.pointerType !== 'mouse') { + return + } + + setHovered(false) + + // Radix's leave handler focuses the menu surface: same theft. + if (searchHoldsFocus(event.currentTarget)) { + event.preventDefault() + leave?.() + } + }, + onPointerMove: (event: React.PointerEvent) => { + onPointerMove?.(event) + + if (event.defaultPrevented || event.pointerType !== 'mouse') { + return + } + + const held = searchHoldsFocus(event.currentTarget) + + setHovered(held) + + if (held) { + event.preventDefault() + enter?.() + } + } + } +} + +// Cancelling the pointermove also skips Radix's submenu hover-open, and Radix +// closes an open submenu only when focus moves to another row, which no longer +// happens. The menu re-creates both with Radix's delay as the travel grace: +// one hover-opened submenu at a time, closed when another row is hovered +// unless the pointer reaches the submenu first. +const SUBMENU_HOVER_DELAY_MS = 100 + +type SetSubOpen = (open: boolean) => void + +function createHoverSubmenus() { + let current: SetSubOpen | null = null + let pending: SetSubOpen | null = null + let openTimer = 0 + let closeTimer = 0 + + const cancelOpen = () => { + window.clearTimeout(openTimer) + pending = null + } + + const keep = () => { + window.clearTimeout(closeTimer) + closeTimer = 0 + } + + return { + cancelOpen, + dispose: () => { + cancelOpen() + keep() + }, + enterSub: (setOpen: SetSubOpen, open: boolean) => { + keep() + + if (open) { + cancelOpen() + current = setOpen + + return + } + + if (pending === setOpen) { + return + } + + cancelOpen() + pending = setOpen + openTimer = window.setTimeout(() => { + pending = null + + if (current !== setOpen) { + current?.(false) + } + + current = setOpen + setOpen(true) + }, SUBMENU_HOVER_DELAY_MS) + }, + enterRow: () => { + cancelOpen() + + if (current && !closeTimer) { + closeTimer = window.setTimeout(() => { + closeTimer = 0 + current?.(false) + current = null + }, SUBMENU_HOVER_DELAY_MS) + } + }, + keep + } +} + +const HoverSubmenusContext = React.createContext | null>(null) +const SubOpenContext = React.createContext<{ open: boolean; setOpen: SetSubOpen } | null>(null) + +function useRowSearchHover(props: RowPointerHandlers) { + const hoverSubmenus = React.useContext(HoverSubmenusContext) + + return useSearchSafeHover(props, { enter: () => hoverSubmenus?.enterRow() }) +} + function DropdownMenu({ ...props }: React.ComponentProps) { return } @@ -49,6 +191,8 @@ function DropdownMenuSearch({ }: Omit, 'type'> & { onValueChange?: (value: string) => void }) { + const hoverSubmenus = React.useContext(HoverSubmenusContext) + return (
hoverSubmenus.dispose, [hoverSubmenus]) return ( - + + + ) } @@ -126,11 +279,14 @@ function DropdownMenuItem({ inset?: boolean variant?: 'default' | 'destructive' }) { + const hoverProps = useRowSearchHover(props) + return ( ) } @@ -148,17 +305,21 @@ function DropdownMenuCheckboxItem({ checked, ...props }: React.ComponentProps) { + const hoverProps = useRowSearchHover(props) + return ( {children} @@ -177,16 +338,20 @@ function DropdownMenuRadioItem({ children, ...props }: React.ComponentProps) { + const hoverProps = useRowSearchHover(props) + return ( {children} @@ -233,8 +398,34 @@ function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'spa ) } -function DropdownMenuSub({ ...props }: React.ComponentProps) { - return +function DropdownMenuSub({ + defaultOpen = false, + onOpenChange, + open: openProp, + ...props +}: React.ComponentProps) { + // Owned here (still honouring a controlled `open`) so a sub trigger can + // hover-open it while a menu search keeps focus; see createHoverSubmenus. + const [openState, setOpenState] = React.useState(defaultOpen) + const open = openProp ?? openState + const onOpenChangeRef = React.useRef(onOpenChange) + + React.useEffect(() => { + onOpenChangeRef.current = onOpenChange + }) + + const setOpen = React.useCallback((next: boolean) => { + setOpenState(next) + onOpenChangeRef.current?.(next) + }, []) + + const sub = React.useMemo(() => ({ open, setOpen }), [open, setOpen]) + + return ( + + + + ) } function DropdownMenuSubTrigger({ @@ -248,15 +439,25 @@ function DropdownMenuSubTrigger({ /** Suppress the trailing caret — for triggers that own their right-side affordance. */ hideChevron?: boolean }) { + const sub = React.useContext(SubOpenContext) + const hoverSubmenus = React.useContext(HoverSubmenusContext) + + const hoverProps = useSearchSafeHover(props, { + enter: () => sub && hoverSubmenus?.enterSub(sub.setOpen, sub.open), + leave: () => hoverSubmenus?.cancelOpen() + }) + return ( {children} {!hideChevron && } @@ -267,8 +468,11 @@ function DropdownMenuSubTrigger({ function DropdownMenuSubContent({ className, collisionPadding = 8, + onPointerMove, ...props }: React.ComponentProps) { + const hoverSubmenus = React.useContext(HoverSubmenusContext) + return ( // Portal the submenu out of the parent Content so it escapes that Content's // `overflow` clip. Without this, a submenu opening from a scrollable menu @@ -290,6 +494,11 @@ function DropdownMenuSubContent({ // the submenu never gets clipped. collisionPadding={collisionPadding} data-slot="dropdown-menu-sub-content" + onPointerMove={event => { + // The pointer made it into the submenu: cancel a pending hover close. + hoverSubmenus?.keep() + onPointerMove?.(event) + }} {...props} /> diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index e7b13f6e7a..8803a0e6da 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -446,6 +446,7 @@ export const ar = defineLocale({ keysSettings: 'الإعدادات', mcp: 'MCP', archivedChats: 'المحادثات المؤرشفة', + sessions: 'الجلسات', about: 'حول', notifications: 'الإشعارات', keybinds: 'اختصارات لوحة المفاتيح', @@ -2881,6 +2882,11 @@ export const ar = defineLocale({ remotePickerTitle: 'اختر مجلدا بعيدا', remotePickerDescription: 'استعرض المجلدات على الخادم الخلفي المتصل.', remotePickerSelect: 'تحديد المجلد', + remotePickerNewFolder: 'مجلد جديد', + remotePickerFolderName: 'اسم المجلد', + remotePickerCreateFolder: 'إنشاء المجلد', + remotePickerInvalidFolderName: 'أدخل اسم مجلد واحد بدون شرطات مائلة.', + remotePickerCreateFolderFailed: error => `تعذر إنشاء المجلد: ${error}`, folderTip: cwd => cwd, openFolder: 'فتح مجلد', refreshTree: 'تحديث الشجرة', diff --git a/apps/desktop/src/i18n/de.ts b/apps/desktop/src/i18n/de.ts index 49d954efef..e7ee890835 100644 --- a/apps/desktop/src/i18n/de.ts +++ b/apps/desktop/src/i18n/de.ts @@ -830,6 +830,7 @@ export const deOverrides = { keysSettings: 'Einstellungen', mcp: 'MCP', archivedChats: 'Archivierte Chats', + sessions: 'Sessions', about: 'Über', billing: 'Abrechnung', notifications: 'Benachrichtigungen', @@ -5080,6 +5081,11 @@ export const deOverrides = { remotePickerTitle: 'Remote-Ordner wählen', remotePickerDescription: 'Ordner auf dem verbundenen Backend durchsuchen.', remotePickerSelect: 'Ordner auswählen', + remotePickerNewFolder: 'Neuer Ordner', + remotePickerFolderName: 'Ordnername', + remotePickerCreateFolder: 'Ordner erstellen', + remotePickerInvalidFolderName: 'Gib einen einzelnen Ordnernamen ohne Schrägstriche ein.', + remotePickerCreateFolderFailed: error => `Der Ordner konnte nicht erstellt werden (${error}).`, folderTip: cwd => cwd, openFolder: 'Ordner öffnen', refreshTree: 'Baum aktualisieren', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index cd42efd002..ad6a074e11 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -824,6 +824,7 @@ export const en: Translations = { keysSettings: 'Settings', mcp: 'MCP', archivedChats: 'Archived Chats', + sessions: 'Sessions', about: 'About', billing: 'Billing', notifications: 'Notifications', @@ -4624,6 +4625,11 @@ export const en: Translations = { remotePickerTitle: 'Choose remote folder', remotePickerDescription: 'Browse folders on the connected backend.', remotePickerSelect: 'Select folder', + remotePickerNewFolder: 'New folder', + remotePickerFolderName: 'Folder name', + remotePickerCreateFolder: 'Create folder', + remotePickerInvalidFolderName: 'Enter a single folder name, without slashes.', + remotePickerCreateFolderFailed: error => `Could not create the folder (${error}).`, folderTip: cwd => cwd, openFolder: 'Open folder', refreshTree: 'Refresh tree', diff --git a/apps/desktop/src/i18n/es.ts b/apps/desktop/src/i18n/es.ts index 72dfe5459d..bf0eddbcdd 100644 --- a/apps/desktop/src/i18n/es.ts +++ b/apps/desktop/src/i18n/es.ts @@ -830,6 +830,7 @@ export const esOverrides = { keysSettings: 'Configuración', mcp: 'MCP', archivedChats: 'Chats archivados', + sessions: 'Sesiones', about: 'Acerca de', billing: 'Facturación', notifications: 'Notificaciones', @@ -5071,6 +5072,11 @@ export const esOverrides = { remotePickerTitle: 'Elige una carpeta remota', remotePickerDescription: 'Explora carpetas en el backend conectado.', remotePickerSelect: 'Seleccionar carpeta', + remotePickerNewFolder: 'Nueva carpeta', + remotePickerFolderName: 'Nombre de la carpeta', + remotePickerCreateFolder: 'Crear carpeta', + remotePickerInvalidFolderName: 'Escribe un solo nombre de carpeta, sin barras.', + remotePickerCreateFolderFailed: error => `No se pudo crear la carpeta (${error}).`, folderTip: cwd => cwd, openFolder: 'Abrir carpeta', refreshTree: 'Actualizar árbol', diff --git a/apps/desktop/src/i18n/fr.ts b/apps/desktop/src/i18n/fr.ts index 3922eff080..8fd4904957 100644 --- a/apps/desktop/src/i18n/fr.ts +++ b/apps/desktop/src/i18n/fr.ts @@ -829,6 +829,7 @@ export const frOverrides = { keysSettings: 'Paramètres', mcp: 'MCP', archivedChats: 'Conversations archivées', + sessions: 'Sessions', about: 'À propos', billing: 'Facturation', notifications: 'Notifications', @@ -5091,6 +5092,11 @@ export const frOverrides = { remotePickerTitle: 'Choisir un dossier distant', remotePickerDescription: 'Parcourez les dossiers sur le backend connecté.', remotePickerSelect: 'Sélectionner le dossier', + remotePickerNewFolder: 'Nouveau dossier', + remotePickerFolderName: 'Nom du dossier', + remotePickerCreateFolder: 'Créer le dossier', + remotePickerInvalidFolderName: 'Saisissez un seul nom de dossier, sans barre oblique.', + remotePickerCreateFolderFailed: error => `Impossible de créer le dossier (${error}).`, folderTip: cwd => cwd, openFolder: 'Ouvrir le dossier', refreshTree: "Actualiser l'arbre", diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index b4cecdab41..203efb4aea 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -432,6 +432,7 @@ export const ja = defineLocale({ keysSettings: '設定', mcp: 'MCP', archivedChats: 'アーカイブ済みチャット', + sessions: 'セッション', about: '情報', billing: '請求', notifications: '通知', @@ -3372,6 +3373,11 @@ export const ja = defineLocale({ remotePickerTitle: 'リモートフォルダーを選択', remotePickerDescription: '接続中のバックエンド上のフォルダーを参照します。', remotePickerSelect: 'フォルダーを選択', + remotePickerNewFolder: '新しいフォルダー', + remotePickerFolderName: 'フォルダー名', + remotePickerCreateFolder: 'フォルダーを作成', + remotePickerInvalidFolderName: 'スラッシュを含まない 1 つのフォルダー名を入力してください。', + remotePickerCreateFolderFailed: error => `フォルダーを作成できませんでした (${error})。`, folderTip: cwd => cwd, openFolder: 'フォルダーを開く', refreshTree: 'ツリーを更新', diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts index 3595bf23ab..f28f769ebb 100644 --- a/apps/desktop/src/i18n/ru.ts +++ b/apps/desktop/src/i18n/ru.ts @@ -482,6 +482,7 @@ export const ru = defineLocale({ keysSettings: 'Настройки', mcp: 'MCP', archivedChats: 'Архив чатов', + sessions: 'Сеансы', about: 'О программе', billing: 'Оплата', notifications: 'Уведомления' @@ -3607,6 +3608,11 @@ export const ru = defineLocale({ remotePickerTitle: 'Выбрать удалённую папку', remotePickerDescription: 'Просмотрите папки на подключённом бэкенде.', remotePickerSelect: 'Выбрать папку', + remotePickerNewFolder: 'Новая папка', + remotePickerFolderName: 'Имя папки', + remotePickerCreateFolder: 'Создать папку', + remotePickerInvalidFolderName: 'Введите одно имя папки без косых черт.', + remotePickerCreateFolderFailed: error => `Не удалось создать папку (${error}).`, folderTip: cwd => cwd, openFolder: 'Открыть папку', refreshTree: 'Обновить дерево', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 1e63640160..f23ace5cf2 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -692,6 +692,7 @@ export interface Translations { keysSettings: string mcp: string archivedChats: string + sessions: string about: string billing: string notifications: string @@ -3899,6 +3900,11 @@ export interface Translations { remotePickerTitle: string remotePickerDescription: string remotePickerSelect: string + remotePickerNewFolder: string + remotePickerFolderName: string + remotePickerCreateFolder: string + remotePickerInvalidFolderName: string + remotePickerCreateFolderFailed: (error: string) => string folderTip: (cwd: string) => string openFolder: string refreshTree: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index ffac0b04ee..795022412a 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -389,6 +389,7 @@ export const zhHant = defineLocale({ keysSettings: '設定', mcp: 'MCP', archivedChats: '已封存聊天', + sessions: '工作階段', about: '關於', billing: '帳單', notifications: '通知', @@ -3569,6 +3570,11 @@ export const zhHant = defineLocale({ remotePickerTitle: '選擇遠端資料夾', remotePickerDescription: '瀏覽已連線後端上的資料夾。', remotePickerSelect: '選擇資料夾', + remotePickerNewFolder: '新增資料夾', + remotePickerFolderName: '資料夾名稱', + remotePickerCreateFolder: '建立資料夾', + remotePickerInvalidFolderName: '請輸入單一資料夾名稱,不要包含斜線。', + remotePickerCreateFolderFailed: error => `無法建立資料夾 (${error})。`, folderTip: cwd => cwd, openFolder: '開啟資料夾', refreshTree: '重新整理檔案樹', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 94fabba438..c4de71c5d7 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -533,6 +533,7 @@ export const zh = defineLocale({ keysSettings: '设置', mcp: 'MCP', archivedChats: '已归档对话', + sessions: '会话', about: '关于', billing: '账单', notifications: '通知', @@ -4360,6 +4361,11 @@ export const zh = defineLocale({ remotePickerTitle: '选择远程文件夹', remotePickerDescription: '浏览已连接后端上的文件夹。', remotePickerSelect: '选择文件夹', + remotePickerNewFolder: '新建文件夹', + remotePickerFolderName: '文件夹名称', + remotePickerCreateFolder: '创建文件夹', + remotePickerInvalidFolderName: '请输入单个文件夹名称,不要包含斜杠。', + remotePickerCreateFolderFailed: error => `无法创建文件夹 (${error})。`, folderTip: cwd => cwd, openFolder: '打开文件夹', refreshTree: '刷新文件树', diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index d5492db55d..d8b0c3140e 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -49,6 +49,17 @@ export function createClientSessionState( } } +/** + * Mark a freshly resumed slice's effort as not-yet-known. The deferred-build + * resume reply has no `reasoning_effort`, and falling through to the profile + * default would paint a level the built agent's `session.info` then replaces + * (#79807). A slice whose effort was already reported (a fast build can beat + * the resume reply) keeps it. + */ +export function markReasoningEffortPending(state: ClientSessionState): ClientSessionState { + return state.reasoningEffortPending === false ? state : { ...state, reasoningEffortPending: true } +} + export function sessionTitle(session: SessionInfo): string { return session.title?.trim() || session.preview?.trim() || 'Untitled session' } diff --git a/apps/desktop/src/lib/desktop-fs.test.ts b/apps/desktop/src/lib/desktop-fs.test.ts index 7422a3acfb..24e38a033b 100644 --- a/apps/desktop/src/lib/desktop-fs.test.ts +++ b/apps/desktop/src/lib/desktop-fs.test.ts @@ -4,6 +4,7 @@ import { setApiRequestConnection } from '@/api/client' import { $connection } from '@/store/session' import { + createRemoteDir, desktopDefaultCwd, desktopFileDiff, desktopFsCacheKey, @@ -43,6 +44,10 @@ const api = vi.fn(async ({ path }: { path: string }) => { return { cwd: '/backend/project', branch: 'main' } } + if (path === '/api/files/mkdir') { + return { ok: true, path: '/home/user/new folder' } + } + if (path.startsWith('/api/git/file-diff?')) { return { diff: 'remote diff' } } @@ -117,6 +122,19 @@ describe('desktop filesystem facade', () => { expect(gitRoot).not.toHaveBeenCalled() }) + it('creates remote folders through the backend mkdir route for the active profile', async () => { + $connection.set({ mode: 'remote', profile: 'team-remote' } as never) + + await expect(createRemoteDir('/home/user/new folder')).resolves.toBe('/home/user/new folder') + + expect(api).toHaveBeenCalledWith({ + body: { path: '/home/user/new folder' }, + method: 'POST', + path: '/api/files/mkdir', + profile: 'team-remote' + }) + }) + it('does not retry the same unreadable path through the local facade', async () => { const error = new Error('not readable') diff --git a/apps/desktop/src/lib/desktop-fs.ts b/apps/desktop/src/lib/desktop-fs.ts index 02d71aef87..166054d738 100644 --- a/apps/desktop/src/lib/desktop-fs.ts +++ b/apps/desktop/src/lib/desktop-fs.ts @@ -108,6 +108,14 @@ export async function writeDesktopFileText(path: string, content: string): Promi return { path: result.path || path } } +// Create a folder on the connected backend (POST /api/files/mkdir). Remote-only: +// in local mode the picker is the native dialog, which creates folders itself. +export async function createRemoteDir(path: string): Promise { + const result = await remoteFsApi<{ path?: string }>('/api/files/mkdir', { path }) + + return result.path || path +} + export async function readDesktopFileDataUrl(path: string): Promise { if (!isDesktopFsRemoteMode()) { return bridge().readFileDataUrl(path) diff --git a/apps/desktop/src/lib/local-preview.test.ts b/apps/desktop/src/lib/local-preview.test.ts index 9274ce9faa..1dcdc18096 100644 --- a/apps/desktop/src/lib/local-preview.test.ts +++ b/apps/desktop/src/lib/local-preview.test.ts @@ -148,6 +148,19 @@ describe('remote HTML previews', () => { expect(localPreviewTarget('/tmp/report\\draft.html')?.url).toBe('file:///tmp/report%5Cdraft.html') }) + // #85132: a Windows absolute path is absolute; joining it onto cwd made + // `/repo/C:\\Users\\...` which no filesystem has. + it.each(['C:\\Users\\me\\report.html', 'C:/Users/me/report.html', '\\\\server\\share\\report.html'])( + 'treats Windows absolute path %s as absolute instead of joining it onto cwd', + raw => { + expect(localPreviewTarget(raw, '/repo')).toMatchObject({ label: 'report.html', path: raw, previewKind: 'html' }) + } + ) + + it('still joins relative paths onto cwd', () => { + expect(localPreviewTarget('out/report.html', '/repo')?.path).toBe('/repo/out/report.html') + }) + it('preserves POSIX double-slash file paths', () => { expect(localPreviewTarget('//srv/share/report #1?.html')?.url).toBe('file:////srv/share/report%20%231%3F.html') }) diff --git a/apps/desktop/src/lib/local-preview.ts b/apps/desktop/src/lib/local-preview.ts index 15f0044216..9eb7ff8a26 100644 --- a/apps/desktop/src/lib/local-preview.ts +++ b/apps/desktop/src/lib/local-preview.ts @@ -1,6 +1,7 @@ import DOMPurify from 'dompurify' import { isDesktopFsRemoteMode, readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs' +import { isWindowsAbsolutePath } from '@/lib/path-compare' import type { PreviewTarget } from '@/store/preview' const HTML_EXTENSIONS = new Set(['.htm', '.html']) @@ -221,7 +222,7 @@ export function localPreviewTarget(rawTarget: string, cwd?: string | null): Prev } catch { path = raw.replace(/^file:\/\//i, '') } - } else if (!raw.startsWith('/') && cwd) { + } else if (!raw.startsWith('/') && !isWindowsAbsolutePath(raw) && cwd) { path = joinPath(cwd, raw) } diff --git a/apps/desktop/src/lib/path-compare.test.ts b/apps/desktop/src/lib/path-compare.test.ts index 30e3184f88..4ef6f1180d 100644 --- a/apps/desktop/src/lib/path-compare.test.ts +++ b/apps/desktop/src/lib/path-compare.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from 'vitest' -import { cleanPath, comparisonPath, isUnderPath } from './path-compare' +import { cleanPath, comparisonPath, isUnderPath, isWindowsAbsolutePath } from './path-compare' + +describe('isWindowsAbsolutePath', () => { + it.each(['C:\\Users\\me', 'd:/work', '\\\\server\\share'])('accepts %s', path => { + expect(isWindowsAbsolutePath(path)).toBe(true) + }) + + it.each(['/home/me', './out', 'out\\report.html', 'C:relative', 'https://x.com'])('rejects %s', path => { + expect(isWindowsAbsolutePath(path)).toBe(false) + }) +}) describe('cleanPath', () => { it('unifies separators and drops trailing slashes', () => { diff --git a/apps/desktop/src/lib/path-compare.ts b/apps/desktop/src/lib/path-compare.ts index e477a578cf..533893bd7a 100644 --- a/apps/desktop/src/lib/path-compare.ts +++ b/apps/desktop/src/lib/path-compare.ts @@ -5,6 +5,10 @@ * case. Compare through these rather than `===` / `startsWith`. */ +/** Windows drive (`C:\\`, `C:/`) or UNC (`\\\\server\\share`) absolute path. + * Such a path never gets joined onto a cwd. */ +export const isWindowsAbsolutePath = (path: string): boolean => /^(?:[A-Za-z]:[\\/]|\\\\)/.test(path) + /** POSIX-style spelling: one separator, no trailing slash. */ export const cleanPath = (path: string): string => path.trim().replace(/\\/g, '/').replace(/\/+$/, '') || '/' diff --git a/apps/desktop/src/lib/preview-targets.test.ts b/apps/desktop/src/lib/preview-targets.test.ts index 3a51c29ab0..ac738f254d 100644 --- a/apps/desktop/src/lib/preview-targets.test.ts +++ b/apps/desktop/src/lib/preview-targets.test.ts @@ -1,6 +1,24 @@ import { describe, expect, it } from 'vitest' -import { extractPreviewTargets, previewTargetFromMarkdownHref, stripPreviewTargets } from './preview-targets' +import { + extractPreviewTargets, + previewName, + previewTargetFromMarkdownHref, + stripPreviewTargets +} from './preview-targets' + +describe('previewName', () => { + // #85132: `new URL('C:\\...')` parses the drive letter as a scheme, which + // labelled the tag with the whole backslash path. + it.each([ + 'C:\\Users\\me\\report.html', + 'C:/Users/me/report.html', + '\\\\server\\share\\report.html', + '/Users/me/report.html' + ])('labels %s by its file name', target => { + expect(previewName(target)).toBe('report.html') + }) +}) describe('preview target detection', () => { it('does not infer preview targets from raw paths or URLs', () => { diff --git a/apps/desktop/src/lib/preview-targets.ts b/apps/desktop/src/lib/preview-targets.ts index 8e16b3cc24..8f472241ab 100644 --- a/apps/desktop/src/lib/preview-targets.ts +++ b/apps/desktop/src/lib/preview-targets.ts @@ -1,3 +1,5 @@ +import { isWindowsAbsolutePath } from '@/lib/path-compare' + const PREVIEW_MARKDOWN_RE = /\[Preview:[^\]]+\]\((?#preview[:/][^)]+)\)/gi export function stripPreviewTargets(text: string): string { @@ -37,6 +39,11 @@ export function previewTargetFromMarkdownHref(href?: string): string | null { } export function previewName(target: string): string { + // `new URL('C:\\...')` would read the drive letter as a URL scheme. + if (isWindowsAbsolutePath(target)) { + return target.split(/[\\/]/).filter(Boolean).pop() || target + } + try { const url = new URL(target) diff --git a/apps/desktop/src/lib/session-branch-tree.test.ts b/apps/desktop/src/lib/session-branch-tree.test.ts index 8de1803012..15cce5de23 100644 --- a/apps/desktop/src/lib/session-branch-tree.test.ts +++ b/apps/desktop/src/lib/session-branch-tree.test.ts @@ -32,6 +32,60 @@ describe('flattenSessionsWithBranches', () => { ]) }) + it('collapses a stale compression tip into its continuation instead of nesting it (#82290)', () => { + // Old tip (#3) and its continuation (#4) both survived in the store. They + // share one lineage root, so they are one conversation: a single row for + // the live tip, no └─ stem. + const oldTip = session('old-tip', { _lineage_root_id: 'root', last_active: 100, started_at: 50 }) + + const continuation = session('continuation', { + _lineage_root_id: 'root', + last_active: 100, + parent_session_id: 'old-tip', + started_at: 100 + }) + + expect(flattenSessionsWithBranches([oldTip, continuation])).toEqual([{ session: continuation }]) + expect(flattenSessionsWithBranches([continuation, oldTip], { preserveOrder: true })).toEqual([ + { session: continuation } + ]) + }) + + it('collapses the lineage root row once its continuation carries the root id', () => { + const root = session('root', { last_active: 40 }) + + const continuation = session('continuation', { + _lineage_root_id: 'root', + last_active: 60, + parent_session_id: 'root' + }) + + const branch = session('branch', { last_active: 50, parent_session_id: 'root' }) + + expect(flattenSessionsWithBranches([root, continuation, branch])).toEqual([ + { session: continuation }, + { branchStem: '└─ ', session: branch } + ]) + }) + + it('keeps same-lineage ids from different profiles apart', () => { + const work = session('tip', { _lineage_root_id: 'root', last_active: 20, profile: 'work' }) + const home = session('tip-home', { _lineage_root_id: 'root', last_active: 10, profile: 'home' }) + + expect(flattenSessionsWithBranches([work, home]).map(e => e.session.id)).toEqual(['tip', 'tip-home']) + }) + + it('still nests a real branch of a compressed conversation under the live tip', () => { + const oldTip = session('old-tip', { _lineage_root_id: 'root', last_active: 30 }) + const tip = session('tip', { _lineage_root_id: 'root', last_active: 90, parent_session_id: 'old-tip' }) + const branch = session('branch', { last_active: 70, parent_session_id: 'tip' }) + + expect(flattenSessionsWithBranches([oldTip, tip, branch])).toEqual([ + { session: tip }, + { branchStem: '└─ ', session: branch } + ]) + }) + it('keeps orphan branches at the top level when the parent is missing', () => { const branch = session('branch', { parent_session_id: 'missing' }) diff --git a/apps/desktop/src/lib/session-branch-tree.ts b/apps/desktop/src/lib/session-branch-tree.ts index 07ef3c9911..40bedf3396 100644 --- a/apps/desktop/src/lib/session-branch-tree.ts +++ b/apps/desktop/src/lib/session-branch-tree.ts @@ -17,11 +17,56 @@ export interface FlattenSessionsOptions { const recency = (session: SessionInfo): number => session.last_active || session.started_at || 0 +// Profile-qualified compression lineage, same key as mergeSessionPage (store/session.ts). The +// backend's `_lineage_root_id` follows compression edges only; /branch and /new children get +// their own root, so rows sharing a key are one conversation, never a fork. +const lineageKey = (session: SessionInfo): string => + `${(session.profile ?? '').trim() || 'default'}::${session._lineage_root_id?.trim() || session.id}` + +/** + * One row per compression lineage. A stale tip can outlive its rotation in the store (#82290); + * keep the row nothing in its lineage continues from, then the freshest by mergeSessionPage's + * recency rule (first in input on a tie). + */ +function collapseCompressionLineages(sessions: readonly SessionInfo[]): readonly SessionInfo[] { + const groups = new Map() + + for (const session of sessions) { + const key = lineageKey(session) + const group = groups.get(key) + + if (group) { + group.push(session) + } else { + groups.set(key, [session]) + } + } + + if (groups.size === sessions.length) { + return sessions + } + + const winners = new Set() + + for (const group of groups.values()) { + const continuedIds = new Set(group.map(session => session.parent_session_id?.trim())) + + const score = (session: SessionInfo) => + continuedIds.has(session.id) ? -Infinity : Math.max(session.last_active || 0, session.started_at || 0) + + winners.add(group.reduce((best, session) => (score(session) > score(best) ? session : best))) + } + + return sessions.filter(session => winners.has(session)) +} + /** Flat list with branch/fork sessions nested visually under their parent. */ export function flattenSessionsWithBranches( - sessions: readonly SessionInfo[], + input: readonly SessionInfo[], options: FlattenSessionsOptions = {} ): SidebarSessionEntry[] { + const sessions = collapseCompressionLineages(input) + if (sessions.length < 2) { return sessions.map(session => ({ session })) } @@ -49,7 +94,8 @@ export function flattenSessionsWithBranches( const parent = byVisibleId.get(parentId) - if (!parent || parent.id === session.id) { + // Compression ancestry is not a branch: never nest a row under its own lineage. + if (!parent || lineageKey(parent) === lineageKey(session)) { continue } @@ -90,7 +136,7 @@ export function flattenSessionsWithBranches( // Depth-first so a branch-of-a-branch still renders under its own parent. The // `seen` set guards against pathological parent cycles, and the trailing sweep - // emits anything the walk somehow missed — nothing in the input is ever dropped. + // emits anything the walk somehow missed — nothing past the lineage collapse is dropped. const out: SidebarSessionEntry[] = [] const seen = new Set() diff --git a/apps/desktop/src/lib/session-row-slots.ts b/apps/desktop/src/lib/session-row-slots.ts new file mode 100644 index 0000000000..7c1fd40173 --- /dev/null +++ b/apps/desktop/src/lib/session-row-slots.ts @@ -0,0 +1,40 @@ +import type { ReactNode } from 'react' + +/** + * Session-row decoration surface — the seams a plugin can decorate a sidebar + * session row through, with the SAME registry schema as every other surface + * (statusbar, composer, panes): + * + * render areas (`data`): sessionRow.leading — inline right after the + * status dot / drag handle + * sessionRow.trailing — inline before the row's + * hover actions cluster + * + * Core keeps ownership of the row's layout, gestures, and labels — these seams + * AUGMENT a row with a small decoration (a badge, a colour swatch, a tag), they + * never replace it. A contribution renders `null` for rows it doesn't own, so + * registering one costs nothing on every other row in the list. + */ + +export const SESSION_ROW_AREAS = { + leading: 'sessionRow.leading', + trailing: 'sessionRow.trailing' +} as const + +/** Props handed to a session-row decoration's `render`. */ +export interface SessionRowSlotProps { + /** The STORED (durable) id of the session the row renders — the lineage root, + * not the live id. Auto-compression rotates the live id, so a plugin that + * remembers `session.id` decorates the row until the next compaction and then + * silently stops matching; the durable id is the one `host.sessions.*` and + * core's own pin/reorder address (see `sessionPinId`). */ + sessionId: string +} + +/** Payload of a `sessionRow.*` contribution's `data`. */ +export interface SessionRowSlotContribution { + /** Renders the decoration, or `null` to leave the row untouched. Mounted as + * a component inside the contribution error boundary, so it can subscribe + * to its own stores; a throw degrades to an inline error, not a dead row. */ + render: (props: SessionRowSlotProps) => ReactNode +} diff --git a/apps/desktop/src/sdk/composer.ts b/apps/desktop/src/sdk/composer.ts new file mode 100644 index 0000000000..4ef571e095 --- /dev/null +++ b/apps/desktop/src/sdk/composer.ts @@ -0,0 +1,144 @@ +import { + type ComposerInsertMode, + type ComposerTarget, + requestComposerFocus, + requestComposerGetDraft, + requestComposerInsertAcked, + requestComposerSetDraft, + requestComposerSubmit +} from '@/app/chat/composer/focus' +import { NEW_SESSION_DRAFT_KEY, takeSessionDraft } from '@/store/composer' +import { $activeSessionId, $selectedStoredSessionId } from '@/store/session' +import { $sessionStates } from '@/store/session-states' + +/** + * A plugin's session address resolved for the two composer buses. `target` is + * the focus/insert/submit routing key (`null` = no mounted surface can own the + * address, so those verbs fail closed); `ids` are the session ids a mounted + * surface answers draft read/write requests for, and `stored` the durable id + * the persisted stash is keyed by. + * + * `null`/empty = the active composer (bus-resolved, like the internal helpers). + * `'new'` = the draft with no session yet — the primary composer while it shows + * no session (a tile always has one); it never falls through to `'active'`, + * which could be a tile holding another session. A stored id routes to that + * session's tile when one is open, else the primary composer (which renders + * that session); a runtime id is mapped to its stored id first. + */ +const resolveComposerAddress = ( + sessionId: null | string | undefined +): { ids: string[]; stored: string; target: 'active' | ComposerTarget | null } => { + const id = typeof sessionId === 'string' ? sessionId.trim() : '' + + if (!id) { + return { ids: [], stored: '', target: 'active' } + } + + if (id === 'new') { + const primaryIsNewDraft = !$activeSessionId.get() && !$selectedStoredSessionId.get() + + return { ids: [NEW_SESSION_DRAFT_KEY], stored: NEW_SESSION_DRAFT_KEY, target: primaryIsNewDraft ? 'main' : null } + } + + const stored = $sessionStates.get()[id]?.storedSessionId ?? id + const ids = stored === id ? [id] : [id, stored] + + // The primary composer answers only for the session it is showing; a tile + // answers for its own. Anything else stays `tile:` — an absent tile + // drops the request (fail-closed) rather than leaking it into whatever + // session the primary is displaying. + const shownInPrimary = ids.includes($selectedStoredSessionId.get() ?? '') + + return { ids, stored, target: shownInPrimary ? 'main' : (`tile:${stored}` as ComposerTarget) } +} + +/** THE composer draft surface (#116305 item 1): read, write, insert, and + * submit a session's input WITHOUT touching app DOM — mounted surfaces + * answer for their own sessions over the app's focus bus, so a plugin + * addressing one session can never reach another's composer. Addressing: + * `null` = the active composer (what the user last clicked into); a + * session id (stored or runtime) = that session's composer, whether it is + * the primary surface or a tile; the literal `'new'` = the fresh draft + * with no session id yet. Every verb fails closed: an address that + * resolves to no live surface returns `null`/`false` (or is dropped, for + * `focus`) — it never broadcasts and never throws. */ +export const composerHost = { + /** The live draft text of one composer, or null when nothing holds it. + * Mounted surfaces answer with their in-DOM text (current, incl. + * unsaved keystrokes); an unmounted session falls back to its debounced + * persisted stash; `null` address = the active composer (no fallback — + * nobody on screen is answering, which is reported as null). */ + getDraft: async (sessionId: null | string = null): Promise => { + const { ids, stored } = resolveComposerAddress(sessionId) + + if (!ids.length) { + const live = await requestComposerGetDraft([], { active: true }) + + return live ? live.text : null + } + + const live = await requestComposerGetDraft(ids) + + if (live) { + return live.text + } + + return takeSessionDraft(stored).text || null + }, + + /** Replace a composer's whole draft. `@`-ref / `/` tokens in the text + * hydrate into chips exactly like an official paste (the app owns the + * markup). Returns false when no mounted surface answers for the + * address — the draft of an unmounted session is never half-written. */ + setDraft: async (sessionId: null | string, text: string): Promise => { + if (typeof text !== 'string') { + return false + } + + const { ids } = resolveComposerAddress(sessionId) + + return requestComposerSetDraft(ids, text, ids.length ? undefined : { active: true }) + }, + + /** Append text to a composer's draft through the app's own insert modes + * ('block' = paragraph at end, 'inline' = same line, 'prefix' = start — + * the slash-command seat). Acknowledged like `setDraft`: resolves true + * when a mounted surface claimed and applied the text, false when the + * text trims to nothing or no surface answers for the address within the + * bus settle window — never a silent no-op. */ + insertText: (sessionId: null | string, text: string, opts?: { mode?: ComposerInsertMode }): Promise => { + const { target } = resolveComposerAddress(sessionId) + + if (target === null) { + return Promise.resolve(false) + } + + return requestComposerInsertAcked(text, { mode: opts?.mode ?? 'block', target }) + }, + + /** Send `text` as if the user typed it + pressed Enter, and return + * whether a visible surface claimed it. Same fail-closed contract as + * the internal bus: no exact visible composer for the address → false, + * never a broadcast into whichever pane happens to be mounted. */ + submit: (sessionId: null | string, text: string): boolean => { + const { target } = resolveComposerAddress(sessionId) + + return target !== null && requestComposerSubmit(text, { target }) + }, + + /** Put the caret in a composer — the app's own focus bus, same address + * resolution as the verbs above. `insertText`/`setDraft` already focus a + * VISIBLE surface they paint; this is the standalone verb for the other + * cases (return the caret after a plugin popover/dialog closes, a + * keybind that "goes to the input") that plugins used to reach with a + * hand-built `hermes:composer-focus` CustomEvent. Fail-closed like the + * rest: an absent tile drops the request instead of focusing whatever + * the primary happens to show. */ + focus: (sessionId: null | string = null): void => { + const { target } = resolveComposerAddress(sessionId) + + if (target !== null) { + requestComposerFocus(target) + } + } +} diff --git a/apps/desktop/src/sdk/index.test.ts b/apps/desktop/src/sdk/index.test.ts index 5ad9ca2924..796bb948e0 100644 --- a/apps/desktop/src/sdk/index.test.ts +++ b/apps/desktop/src/sdk/index.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ackComposerInsert } from '@/app/chat/composer/focus' import { createClientSessionState } from '@/lib/chat-runtime' import { host } from '@/sdk' -import { setActiveSessionId, setAwaitingResponse, setBusy } from '@/store/session' +import { $selectedStoredSessionId, setActiveSessionId, setAwaitingResponse, setBusy } from '@/store/session' import { clearAllSessionStates, publishSessionState } from '@/store/session-states' // The warm path must route through the guarded prewarm resolver, not dial the @@ -263,3 +264,188 @@ describe('host workspace scope', () => { expect($workspaceNewSessionTarget.get()).toEqual({ kind: 'route', route }) }) }) + +describe('host.composer draft facade', () => { + afterEach(() => { + setActiveSessionId(null) + $selectedStoredSessionId.set(null) + clearAllSessionStates() + }) + + it('routes insertText and focus by address: tile for a session, resolved-active for null', async () => { + const seen: string[] = [] + + const onInsert = (event: Event) => { + const { mode, target, token } = (event as CustomEvent).detail + + seen.push(`insert:${mode}:${target}`) + // A mounted surface acknowledges the insert like the real composer does. + ackComposerInsert(token, true) + } + + const onFocus = (event: Event) => seen.push(`focus:${(event as CustomEvent<{ target: string }>).detail.target}`) + + window.addEventListener('hermes:composer-insert', onInsert) + window.addEventListener('hermes:composer-focus', onFocus) + + const [tileOk, activeOk] = await Promise.all([ + host.composer.insertText('sess-1', ' snippet ', { mode: 'inline' }), + host.composer.insertText(null, 'to active') + ]) + + host.composer.focus('sess-1') + host.composer.focus(null) + // requestComposerFocus defers a plain focus request one macrotask. + await new Promise(resolve => window.setTimeout(resolve, 0)) + + window.removeEventListener('hermes:composer-insert', onInsert) + window.removeEventListener('hermes:composer-focus', onFocus) + + expect([tileOk, activeOk]).toEqual([true, true]) + // A session id never resolves to the primary unless the primary shows it — + // an absent tile drops the request rather than reaching the wrong pane. + expect(seen).toEqual(['insert:inline:tile:sess-1', 'insert:block:main', 'focus:tile:sess-1', 'focus:main']) + }) + + it("addresses 'new' to the session-less primary composer only, never to the active one", async () => { + const seen: string[] = [] + + const onInsert = (event: Event) => { + const { target, token } = (event as CustomEvent).detail + + seen.push(`insert:${target}`) + ackComposerInsert(token, true) + } + + const onFocus = (event: Event) => seen.push(`focus:${(event as CustomEvent<{ target: string }>).detail.target}`) + + window.addEventListener('hermes:composer-insert', onInsert) + window.addEventListener('hermes:composer-focus', onFocus) + + // The primary shows a session → nothing hosts the new draft; the verbs + // fail closed instead of landing in whatever composer the bus routes to. + setActiveSessionId('rt-1') + $selectedStoredSessionId.set('sess-1') + await expect(host.composer.insertText('new', 'x')).resolves.toBe(false) + expect(host.composer.submit('new', 'x')).toBe(false) + host.composer.focus('new') + await new Promise(resolve => window.setTimeout(resolve, 5)) + expect(seen).toEqual([]) + + // No session in the primary → it IS the new draft. + setActiveSessionId(null) + $selectedStoredSessionId.set(null) + await expect(host.composer.insertText('new', 'x')).resolves.toBe(true) + host.composer.focus('new') + await new Promise(resolve => window.setTimeout(resolve, 5)) + + window.removeEventListener('hermes:composer-insert', onInsert) + window.removeEventListener('hermes:composer-focus', onFocus) + + expect(seen).toEqual(['insert:main', 'focus:main']) + }) + + it('falls back to the persisted stash, keyed by the stored id, when no surface answers', async () => { + const { stashSessionDraft } = await import('@/store/composer') + + stashSessionDraft('sess-stash', 'stashed draft', []) + // The stash is keyed by the durable id; a plugin holding the runtime id + // must still reach it once the session states map runtime → stored. + publishSessionState('rt-stash', createClientSessionState('stored-stash')) + stashSessionDraft('stored-stash', 'runtime-addressed', []) + + await expect(host.composer.getDraft('sess-stash')).resolves.toBe('stashed draft') + await expect(host.composer.getDraft('rt-stash')).resolves.toBe('runtime-addressed') + }) +}) + +describe('host.sessions session-list mutations', () => { + beforeEach(async () => { + const layout = await import('@/store/layout') + const color = await import('@/store/session-color') + const session = await import('@/store/session') + + layout.$pinnedSessionIds.set([]) + layout.$sidebarSessionOrderIds.set([]) + layout.$sidebarSessionOrderManual.set(false) + color.$sessionColorOverrides.set({}) + session.$sessions.set([]) + }) + + it('pin/unpin write the pinned store the sidebar reads', async () => { + const { $pinnedSessionIds } = await import('@/store/layout') + + host.sessions.pin('row-1') + expect($pinnedSessionIds.get()).toEqual(['row-1']) + + host.sessions.pin('row-2') + expect($pinnedSessionIds.get()).toEqual(['row-1', 'row-2']) + + host.sessions.pin('row-1', false) + expect($pinnedSessionIds.get()).toEqual(['row-2']) + + // Drop-target pinning (drag-to-pin) slots the pin at an index instead of + // appending — the same `pinSession(id, index)` the sidebar's drop uses. + host.sessions.pin('row-0', true, 0) + expect($pinnedSessionIds.get()).toEqual(['row-0', 'row-2']) + }) + + it('resolves a live id to its durable lineage root before pinning', async () => { + const { $pinnedSessionIds } = await import('@/store/layout') + const { $sessions } = await import('@/store/session') + const { makeSessionInfo } = await import('@/test/session-info') + + $sessions.set([makeSessionInfo({ _lineage_root_id: 'root-9', id: 'tip-9' })]) + + host.sessions.pin('tip-9') + + expect($pinnedSessionIds.get()).toEqual(['root-9']) + }) + + it('reorder persists the manual order the drag path writes, in the LIVE id space', async () => { + const { $sidebarSessionOrderIds, $sidebarSessionOrderManual } = await import('@/store/layout') + const { $sessions } = await import('@/store/session') + const { makeSessionInfo } = await import('@/test/session-info') + + // `c` was compressed: the row slot hands a plugin its durable root `c`, + // but the order store (and the sidebar's reconcile effect) key rows by + // the live id `c-tip`. Feeding the durable id back verbatim would drop + // the row from the order and flip the manual flag off on the next render. + $sessions.set([makeSessionInfo({ _lineage_root_id: 'c', id: 'c-tip' }), makeSessionInfo({ id: 'a' })]) + + host.sessions.reorder(['c', 'a', 'b']) + + expect($sidebarSessionOrderManual.get()).toBe(true) + expect($sidebarSessionOrderIds.get()).toEqual(['c-tip', 'a', 'b']) + + // Empty list = clear the manual order, back to the default sort. + host.sessions.reorder([]) + + expect($sidebarSessionOrderManual.get()).toBe(false) + expect($sidebarSessionOrderIds.get()).toEqual([]) + }) + + it('reorderPinned permutes the Pinned section through the same setter the drag uses', async () => { + const { $pinnedSessionIds } = await import('@/store/layout') + const { $sessions } = await import('@/store/session') + const { makeSessionInfo } = await import('@/test/session-info') + + $sessions.set([makeSessionInfo({ _lineage_root_id: 'p1', id: 'p1-tip' })]) + $pinnedSessionIds.set(['p1', 'p2', 'unloaded']) + + // Durable ids (the slot's) and live ids both resolve; an unmentioned pin keeps its slot. + host.sessions.reorderPinned(['p2', 'p1-tip']) + + expect($pinnedSessionIds.get()).toEqual(['p2', 'p1', 'unloaded']) + }) + + it('setColor writes the durable-keyed colour override and clears with null', async () => { + const { $sessionColorOverrides } = await import('@/store/session-color') + + host.sessions.setColor('row-1', '#ff8800') + expect($sessionColorOverrides.get()).toEqual({ 'row-1': '#ff8800' }) + + host.sessions.setColor('row-1', null) + expect($sessionColorOverrides.get()).toEqual({}) + }) +}) diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 546ebf999a..29a05b2d92 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -106,7 +106,12 @@ import { import { runGatewayRestart } from '@/store/system-actions' import type { PaginatedSessions, UsageStats } from '@/types/hermes' +import { composerHost } from './composer' import { planPluginOpenSession } from './plugin-open-session-plan' +import { sessionsHost } from './sessions' +import { desktopSettings } from './settings' + +export type { DesktopSettingKey, DesktopSettingValues } from './settings' // -- state: readonly views over the app's live atoms ------------------------- @@ -692,6 +697,9 @@ export const host = { viewport: readonlyAtom($viewport) }, + /** Read, update, and observe the allowlisted Desktop appearance preferences. */ + settings: desktopSettings, + /** Toast into the app's notification stack. */ notify, notifyError, @@ -911,6 +919,9 @@ export const host = { ensureAgent: async (connectionId: null | string | undefined, profile: string): Promise => ensureGatewayAgent(connectionId ?? null, (profile ?? '').trim() || 'default'), + /** Session-list mutations (pin, reorder, colour) — see `./sessions`. */ + sessions: sessionsHost, + /** Open a stored session the way core surfaces do. A plugin/Bot Mode open * is navigation, not a workspace or chrome API-home switch — * keepAllProfilesScope defaults true so `$activeGatewayProfile` / @@ -1553,7 +1564,9 @@ export const host = { * components that take a `HermesGateway` prop directly (e.g. `ConnectorsTab`), * which need the instance, not just a JSON-RPC door. Re-read per use — the * active instance changes on a profile swap. */ - getGateway: (): HermesGateway | null => $gateway.get() + getGateway: (): HermesGateway | null => $gateway.get(), + + composer: composerHost } // -- react bridge ------------------------------------------------------------- @@ -1864,6 +1877,10 @@ export const TITLEBAR_AREAS = { center: 'titleBar.center', left: 'titleBar.left' * setup.runtime_check, reconciled) — pass `host.request`. Don't hand-roll * readiness from raw RPC shapes. */ export { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness' +/** Row-decoration slots: register a `data` contribution with a `render` for + * `SESSION_ROW_AREAS.leading` / `.trailing` to decorate sidebar session rows + * (the props carry the row's stored session id). */ +export { SESSION_ROW_AREAS, type SessionRowSlotContribution, type SessionRowSlotProps } from '@/lib/session-row-slots' /** A sibling WebSocket beside the route's `/api/ws` (voice PCM, Bot Screen RFB): * same origin, same auth resolution as chat. */ export { resolveSiblingWsUrl, type SiblingWsRoute } from '@/lib/sibling-ws-url' diff --git a/apps/desktop/src/sdk/sessions.ts b/apps/desktop/src/sdk/sessions.ts new file mode 100644 index 0000000000..4dd7562dc7 --- /dev/null +++ b/apps/desktop/src/sdk/sessions.ts @@ -0,0 +1,79 @@ +import { + pinSession, + setPinnedSessionOrder, + setSidebarSessionOrderIds, + setSidebarSessionOrderManual, + unpinSession +} from '@/store/layout' +import { $sessions, sessionMatchesStoredId, sessionPinId } from '@/store/session' +import { setSessionColorOverride } from '@/store/session-color' + +/** Pins and colours are keyed by the DURABLE (lineage-root) id so they survive + * compression's session-id rotation; a row's live id resolves through + * `$sessions` (the app's own lineage matcher), and an id that resolves to + * nothing is passed through as-is (the stores tolerate ids for rows this + * window hasn't loaded). */ +function durableSessionPinId(storedSessionId: string): string { + const session = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId)) + + return session ? sessionPinId(session) : storedSessionId +} + +/** The Recents order store is keyed by the LIVE id (`session.id`) — the drag + * path persists `reorderableRowIds` and the sidebar's reconcile effect keeps + * only ids present in `unpinnedAgentSessions.map(s => s.id)`. A plugin holds + * the durable id from the row slot, so map it back to the loaded row's live id + * before writing; a durable id written verbatim would be dropped by the next + * reconcile and, if nothing else survived, flip the manual flag off. */ +function liveSessionId(storedSessionId: string): string { + const session = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId)) + + return session ? session.id : storedSessionId +} + +/** Session-list mutations a plugin may perform on the user's behalf. Every + * method writes the SAME stores the app's own controls write, so a plugin + * action and a hand click can never disagree — the sidebar and the tab + * strip re-render from those stores immediately. Ids are stored (durable) + * session ids as a sidebar row slot carries them; a live id is resolved to + * the same row through the app's lineage matcher. */ +export const sessionsHost = { + /** Pin or unpin a session — the row's ⇧-click / context-menu action. A + * pinned session moves into the Pinned section on the next render. + * `index` slots the pin at that position in the Pinned list (a drop + * target between two pins); omitted = append, like the ⇧-click. */ + pin: (storedSessionId: string, pinned = true, index?: number): void => { + const id = durableSessionPinId(storedSessionId) + + if (pinned) { + pinSession(id, index) + } else { + unpinSession(id) + } + }, + + /** Replace the manual Recents order with `ids` (what a drag persists). + * Ids the window hasn't loaded reconcile on the next render, exactly + * like the app's own reorder. An EMPTY list clears the manual order and + * returns Recents to the default sort — the sidebar's own reconcile + * effect reaches that state one render after a drag empties the list; + * the verb states it directly so a plugin reset never depends on a + * mounted effect. */ + reorder: (ids: string[]): void => { + setSidebarSessionOrderManual(ids.length > 0) + setSidebarSessionOrderIds(ids.map(liveSessionId)) + }, + + /** Permute the Pinned section — the sidebar's own pinned-drag path. Only + * pins the list names move; a pin it omits (row not loaded) keeps its slot. */ + reorderPinned: (ids: string[]): void => { + setPinnedSessionOrder(ids.map(durableSessionPinId)) + }, + + /** Set a session's colour override (or clear it with `null`) — the same + * per-session colour the app's own picker writes, so a plugin swatch and + * a hand-picked colour are one value. */ + setColor: (storedSessionId: string, color: null | string): void => { + setSessionColorOverride(durableSessionPinId(storedSessionId), color) + } +} diff --git a/apps/desktop/src/sdk/settings.test.ts b/apps/desktop/src/sdk/settings.test.ts new file mode 100644 index 0000000000..bf094f2fb7 --- /dev/null +++ b/apps/desktop/src/sdk/settings.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { onPersistenceEvent } from '@/lib/storage' +import { host } from '@/sdk' +import { $backdrop, setBackdrop } from '@/store/backdrop' +import { $composerPopoutGesturesEnabled, setComposerPopoutGesturesEnabled } from '@/store/composer-popout' +import { $introSplash, setIntroSplash } from '@/store/intro-splash' +import { $reasoningCollapsedByDefault, setReasoningCollapsedByDefault } from '@/store/reasoning-disclosure' +import { $sessionListDensity, setSessionListDensity } from '@/store/session-list-density' +import { $tabStripDefault, setTabStripDefault } from '@/store/tabstrip-prefs' + +const resetSettings = () => { + setSessionListDensity('compact') + setTabStripDefault('auto') + setBackdrop(false) + setIntroSplash(true) + setReasoningCollapsedByDefault(false) + setComposerPopoutGesturesEnabled(true) +} + +describe('host.settings', () => { + beforeEach(() => { + resetSettings() + }) + + afterEach(resetSettings) + + it('reads and writes the allowlisted typed preferences through their owning stores', () => { + host.settings.set('sessionListDensity', 'detailed') + host.settings.set('tabStripDefault', 'always') + host.settings.set('backdrop.v1', true) + host.settings.set('intro-splash.v1', false) + host.settings.set('reasoning.collapsedByDefault', true) + host.settings.set('composerPopout.gesturesEnabled', false) + + expect(host.settings.get('sessionListDensity')).toBe('detailed') + expect(host.settings.get('tabStripDefault')).toBe('always') + expect(host.settings.get('backdrop.v1')).toBe(true) + expect(host.settings.get('intro-splash.v1')).toBe(false) + expect(host.settings.get('reasoning.collapsedByDefault')).toBe(true) + expect(host.settings.get('composerPopout.gesturesEnabled')).toBe(false) + + expect($sessionListDensity.get()).toBe('detailed') + expect($tabStripDefault.get()).toBe('always') + expect($backdrop.get()).toBe(true) + expect($introSplash.get()).toBe(false) + expect($reasoningCollapsedByDefault.get()).toBe(true) + expect($composerPopoutGesturesEnabled.get()).toBe(false) + }) + + it('preserves the stores existing persistence schema', () => { + const writes: Array<[string, null | string]> = [] + + const unsubscribe = onPersistenceEvent(event => { + if (event.op !== 'read') { + writes.push([event.key, event.value]) + } + }) + + host.settings.set('sessionListDensity', 'detailed') + host.settings.set('tabStripDefault', 'never') + host.settings.set('backdrop.v1', true) + host.settings.set('intro-splash.v1', false) + host.settings.set('reasoning.collapsedByDefault', true) + host.settings.set('composerPopout.gesturesEnabled', false) + + unsubscribe() + + expect(writes).toEqual( + expect.arrayContaining([ + ['hermes.desktop.sessionListDensity', 'detailed'], + ['hermes.desktop.tabStripDefault', 'never'], + ['hermes.desktop.backdrop.v1', 'true'], + ['hermes.desktop.intro-splash.v1', 'false'], + ['hermes.desktop.reasoning.collapsedByDefault', 'true'], + ['hermes.desktop.composerPopout.gesturesEnabled', 'false'] + ]) + ) + }) + + it('subscribes immediately and follows changes from the native settings surface', () => { + const listener = vi.fn() + const unsubscribe = host.settings.subscribe('backdrop.v1', listener) + + expect(listener).toHaveBeenLastCalledWith(false) + + setBackdrop(true) + + expect(listener).toHaveBeenLastCalledWith(true) + expect(listener).toHaveBeenCalledTimes(2) + + unsubscribe() + setBackdrop(false) + + expect(listener).toHaveBeenCalledTimes(2) + }) + + it('rejects keys and values outside the public allowlist', () => { + expect(() => (host.settings.get as (key: string) => unknown)('pluginDecisions.v2')).toThrow( + 'Unsupported desktop setting: pluginDecisions.v2' + ) + // Inherited keys are not settings: a plain-object lookup would hand back + // `Function.prototype.toString` and TypeError on `.get()`. + expect(() => (host.settings.get as (key: string) => unknown)('toString')).toThrow( + 'Unsupported desktop setting: toString' + ) + expect(() => (host.settings.set as (key: string, value: unknown) => void)('constructor', true)).toThrow( + 'Unsupported desktop setting: constructor' + ) + expect(() => (host.settings.set as (key: string, value: unknown) => void)('backdrop.v1', 'on')).toThrow( + 'Invalid value for desktop setting: backdrop.v1' + ) + + expect($backdrop.get()).toBe(false) + }) +}) diff --git a/apps/desktop/src/sdk/settings.ts b/apps/desktop/src/sdk/settings.ts new file mode 100644 index 0000000000..11061bd703 --- /dev/null +++ b/apps/desktop/src/sdk/settings.ts @@ -0,0 +1,90 @@ +import type { ReadableAtom } from 'nanostores' + +import { $backdrop, setBackdrop } from '@/store/backdrop' +import { $composerPopoutGesturesEnabled, setComposerPopoutGesturesEnabled } from '@/store/composer-popout' +import { $introSplash, setIntroSplash } from '@/store/intro-splash' +import { $reasoningCollapsedByDefault, setReasoningCollapsedByDefault } from '@/store/reasoning-disclosure' +import { $sessionListDensity, type SessionListDensity, setSessionListDensity } from '@/store/session-list-density' +import { $tabStripDefault, setTabStripDefault, type TabStripDefault } from '@/store/tabstrip-prefs' + +export interface DesktopSettingValues { + 'backdrop.v1': boolean + 'composerPopout.gesturesEnabled': boolean + 'intro-splash.v1': boolean + 'reasoning.collapsedByDefault': boolean + sessionListDensity: SessionListDensity + tabStripDefault: TabStripDefault +} + +export type DesktopSettingKey = keyof DesktopSettingValues + +interface SettingBinding { + accepts(value: unknown): value is T + get(): T + set(value: T): void + subscribe(listener: (value: T) => void): () => void +} + +const bindSetting = ( + $value: ReadableAtom, + set: (value: T) => void, + accepts: (value: unknown) => value is T +): SettingBinding => ({ + accepts, + get: () => $value.get(), + set, + subscribe: listener => $value.subscribe(value => listener(value)) +}) + +const isBoolean = (value: unknown): value is boolean => typeof value === 'boolean' + +const isSessionListDensity = (value: unknown): value is SessionListDensity => + value === 'compact' || value === 'comfortable' || value === 'detailed' + +const isTabStripDefault = (value: unknown): value is TabStripDefault => + value === 'auto' || value === 'always' || value === 'never' + +const settingBindings = { + 'backdrop.v1': bindSetting($backdrop, setBackdrop, isBoolean), + 'composerPopout.gesturesEnabled': bindSetting( + $composerPopoutGesturesEnabled, + setComposerPopoutGesturesEnabled, + isBoolean + ), + 'intro-splash.v1': bindSetting($introSplash, setIntroSplash, isBoolean), + 'reasoning.collapsedByDefault': bindSetting($reasoningCollapsedByDefault, setReasoningCollapsedByDefault, isBoolean), + sessionListDensity: bindSetting($sessionListDensity, setSessionListDensity, isSessionListDensity), + tabStripDefault: bindSetting($tabStripDefault, setTabStripDefault, isTabStripDefault) +} satisfies { [Key in DesktopSettingKey]: SettingBinding } + +const bindingsByKey = settingBindings as unknown as Record> + +const bindingFor = (key: string): SettingBinding => { + // Own keys only: `toString`/`constructor` would otherwise resolve to + // `Object.prototype` functions and TypeError instead of being refused. + if (!Object.hasOwn(settingBindings, key)) { + throw new Error(`Unsupported desktop setting: ${key}`) + } + + return bindingsByKey[key] +} + +export const desktopSettings = { + get(key: Key): DesktopSettingValues[Key] { + return bindingFor(key).get() as DesktopSettingValues[Key] + }, + + set(key: Key, value: DesktopSettingValues[Key]): void { + const binding = bindingFor(key) + + if (!binding.accepts(value)) { + throw new Error(`Invalid value for desktop setting: ${key}`) + } + + binding.set(value) + }, + + subscribe(key: Key, listener: (value: DesktopSettingValues[Key]) => void): () => void { + return bindingFor(key).subscribe(value => listener(value as DesktopSettingValues[Key])) + } +} diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts index 7a581b2272..2407a12e27 100644 --- a/apps/desktop/src/store/composer.ts +++ b/apps/desktop/src/store/composer.ts @@ -176,7 +176,7 @@ export const mainComposerScope = createComposerAttachmentScope($composerAttachme // localStorage; attachments are memory-only (blobs, upload state). export const SESSION_DRAFTS_STORAGE_KEY = 'hermes:composer-drafts:v3' -const NEW_SESSION_DRAFT_KEY = '__new__' +export const NEW_SESSION_DRAFT_KEY = '__new__' const MAX_PERSISTED_DRAFTS = 50 const EMPTY_SESSION_DRAFT: SessionDraft = { attachments: [], text: '' } diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index 62fda9a46d..3234abeb4b 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -27,9 +27,11 @@ import { requestFreshSession } from '@/store/profile' import { + $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId, + setCurrentCwd, setSessions, workspaceCwdForNewSession } from '@/store/session' @@ -175,6 +177,23 @@ export function resolveNewSessionCwd(): string { return workspaceCwdForNewSession() } +// Entering a project moves the live workspace only when main holds a fresh +// draft: the draft has no folder of its own yet, and the project root is where +// its first message should run. A stored conversation keeps its cwd — entering +// is a scope switch, and moving the workspace under the selected chat re-pointed +// Files/Review and the composer's Git context at the project while the +// transcript stayed on the old session (#72772). The next new chat still lands +// in the project through resolveNewSessionCwd. +export function followEnteredProjectCwd(cwd: string): void { + const target = cwd.trim() + + if (!target || $selectedStoredSessionId.get() || target === $currentCwd.get()) { + return + } + + setCurrentCwd(target) +} + // The project (explicit or auto) that owns `cwd`, by longest path match across // the live tree. Null when no project covers it (it'll surface as a fresh // auto-project on the next tree refresh). diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts index d6341c0dd5..5d76e6ff7c 100644 --- a/apps/desktop/src/store/session.test.ts +++ b/apps/desktop/src/store/session.test.ts @@ -777,6 +777,14 @@ describe('carryForwardFailedProfileSessions', () => { expect(carried.map(s => s.id)).toEqual(['idle']) }) + + it('keeps every profile when the unified (all) read failed', () => { + const previous = [session({ id: 'home', profile: 'default' }), session({ id: 'job', profile: 'work' })] + + expect( + carryForwardFailedProfileSessions(previous, [], [{ profile: 'all', error: 'timed out' }]).map(s => s.id) + ).toEqual(['home', 'job']) + }) }) describe('keepFailedProfileMeta', () => { @@ -795,6 +803,12 @@ describe('keepFailedProfileMeta', () => { work: { cost_usd: 2, tokens: 20 } }) }) + + it('keeps all previous meta when the unified (all) read failed', () => { + const previous = { default: true, work: false } + + expect(keepFailedProfileMeta(previous, {}, [{ profile: 'all' }])).toBe(previous) + }) }) describe('touchSessionActivity', () => { diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index f066d51406..de6b5ab27b 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -701,6 +701,10 @@ export function mergeSessionPage( return interleaved } +// Error scope for a failed unified read (Electron's primary fan-out): every +// profile in the aggregate went unread, not a profile literally named `all`. +const ALL_PROFILES_SCAN = 'all' + function sidebarProfileKey(session: Pick): string { return (session.profile ?? '').trim() || 'default' } @@ -735,7 +739,11 @@ export function carryForwardFailedProfileSessions( for (const session of previous) { // A hidden row (canonical Bot Chat) is LISTED-NEVER by design: the // failed-slice carry must not ride it back into the sidebar (#113273). - if (session.hidden || !failed.has(sidebarProfileKey(session)) || incomingIds.has(sessionListIdentity(session))) { + if ( + session.hidden || + !(failed.has(ALL_PROFILES_SCAN) || failed.has(sidebarProfileKey(session))) || + incomingIds.has(sessionListIdentity(session)) + ) { continue } @@ -767,6 +775,10 @@ export function keepFailedProfileMeta( return incoming } + if (errors.some(error => error.profile?.trim() === ALL_PROFILES_SCAN)) { + return previous + } + const next = { ...incoming } for (const error of errors) { diff --git a/cli.py b/cli.py index 3a85e72582..137c2a6dc4 100644 --- a/cli.py +++ b/cli.py @@ -92,6 +92,7 @@ from hermes_cli.cli_render import ( # noqa: F401,E402 _TRUE_RE, _WINDOWS_PATH_WITH_DOT_SEGMENT_RE, _accent_hex, + _add_suspect_rows, _append_blank_panel_line, _append_panel_line, _assistant_content_as_text, @@ -107,9 +108,15 @@ from hermes_cli.cli_render import ( # noqa: F401,E402 _heal_cooked_mode_drift, _hex_to_ansi, _install_skin_light_mode_hook, + _line_rows, _luminance_from_hex, _maybe_remap_for_light_mode, + _output_history_lines, _output_history_recording, + _output_history_rows, + _output_tail_fitting, + _painted_columns, + _PaintedLine, _panel_box_width, _post_stream_transform_output, _prepend_note_to_message, @@ -119,11 +126,14 @@ from hermes_cli.cli_render import ( # noqa: F401,E402 _query_osc11_background, _record_output_history, _record_output_history_entry, + _release_paints, _render_final_assistant_content, _rich_text_from_ansi, + _set_chrome_floor, _strip_markdown_syntax, _strip_reasoning_tags, _terminal_columns, + _terminal_reflows, _terminal_width_for_streaming, _tty_wrap, _wrap_panel_text, @@ -647,27 +657,44 @@ def _suspend_output_history(): _OUTPUT_HISTORY_SUPPRESSED = old_value -def _replay_output_history() -> None: - """Repaint recent output above the prompt after a full screen clear.""" +def _replay_output_history(fit=None, output=None) -> None: + """Repaint recent output above the prompt after a full screen clear. + + ``fit=(rows, columns, painted, top)`` replays only the newest lines whose wrapped height + fits ``rows`` (see ``_output_tail_fitting``) — the older ones are still in scrollback + (#95375) — from screen row ``top`` when known (``_set_chrome_floor``). ``output``: paint + now, straight to this prompt_toolkit output, where the caller just erased the viewport and + reset the renderer — ``run_in_terminal`` would first erase below the top row, which + scroll-on-clear terminals (tmux) take as a clear and copy the blank screen into scrollback. + """ global _OUTPUT_HISTORY_REPLAYING if not _OUTPUT_HISTORY_ENABLED or not _OUTPUT_HISTORY: return _OUTPUT_HISTORY_REPLAYING = True try: - rendered_lines = [] - for entry in tuple(_OUTPUT_HISTORY): - lines = [entry] - if callable(entry): - try: - lines = entry() - except Exception: - continue - if isinstance(lines, str): - lines = lines.splitlines() - rendered_lines.extend(str(line) for line in lines) + rendered_lines = _output_history_lines() + top = None + if fit is not None: + rows, columns, painted, top = fit + rendered_lines = _output_tail_fitting(rendered_lines, rows, columns, painted) if rendered_lines: # One payload: per-line pt prints each force a sync redraw (a waterfall of old output). - _pt_print(_PT_ANSI("\n".join(rendered_lines))) + if output is None: + _pt_print(_PT_ANSI("\n".join(rendered_lines))) + else: + from prompt_toolkit.renderer import print_formatted_text as _paint_formatted_text + from prompt_toolkit.styles import Style + _paint_formatted_text(output, _PT_ANSI("\n".join(rendered_lines) + "\n"), Style([])) + size = output.get_size() + if top is not None: # the chrome's top is now this many rows down + top += sum(_line_rows(line, columns) for line in rendered_lines) + _set_chrome_floor(max(0, size.rows - top)) + if size.columns != columns: + _add_suspect_rows(top + 1 - size.rows) + width = _painted_columns() if fit is None else columns + for line in rendered_lines: # repainted: they wrap at today's width from now on + if isinstance(line, _PaintedLine): + line.width = width except Exception: pass finally: @@ -1465,6 +1492,8 @@ class HermesCLI(CLIInitMixin, CLITuiRuntimeMixin, CLIProcessNotificationsMixin, else: raise finally: + # A resize right before exit leaves its recovery (and the paints it held) unrun. + _release_paints() self._tui_shutdown() # /update relaunch happens here, after prompt_toolkit restored terminal modes, on the diff --git a/gateway/run_notifications.py b/gateway/run_notifications.py index 6656c05fdc..90b46b3b36 100644 --- a/gateway/run_notifications.py +++ b/gateway/run_notifications.py @@ -1838,21 +1838,39 @@ class GatewayNotificationsMixin: """Re-queue undelivered async completions from every SECONDARY profile's ledger. The process registry restores only the launch profile's ``state.db`` at import; a secondary's rows would otherwise never be replayed after a restart.""" - from gateway.run import _profile_runtime_scope from tools.async_delegation import restore_undelivered_completions from tools.process_registry import process_registry as _pr + self._each_secondary_ledger(profile_homes, lambda: restore_undelivered_completions(_pr.completion_queue), + "Restored") + + def _sweep_orphaned_completion_ledgers(self) -> None: + """Offer completions whose owner process died while this gateway runs (#97202): the launch + ledger in the launch scope, each served secondary under its own. Startup replay only covers + owners that were already gone when the gateway started.""" + from tools.async_delegation import sweep_orphaned_completions + from tools.process_registry import process_registry as _pr + sweep = lambda: sweep_orphaned_completions(_pr.completion_queue) # noqa: E731 + with _log_suppressed(logging.DEBUG, "Orphaned async completion sweep failed: %s"): + if count := sweep(): + logger.info("Re-offered %d orphaned async completion(s)", count) + self._each_secondary_ledger((getattr(self, "_served_profile_homes", None) or {}).items(), sweep, + "Re-offered orphaned") + + def _each_secondary_ledger(self, profile_homes, fn, verb: str) -> None: + """Run ``fn`` (returns a completion count) once per SECONDARY profile, bound to that profile.""" + from gateway.run import _profile_runtime_scope primary = getattr(self, "_primary_profile_name", None) for profile_name, profile_home in profile_homes: if profile_name == primary: continue try: with _profile_runtime_scope(Path(profile_home), {}): - restored = restore_undelivered_completions(_pr.completion_queue) + count = fn() except Exception: - logger.warning("Could not restore async completions for profile %r", profile_name, exc_info=True) + logger.warning("Could not replay async completions for profile %r", profile_name, exc_info=True) continue - if restored: - logger.info("Restored %d undelivered async completion(s) for profile %r", restored, profile_name) + if count: + logger.info("%s %d undelivered async completion(s) for profile %r", verb, count, profile_name) async def _async_delegation_watcher(self, interval: float = 2.0) -> None: """Drain async completions and pattern notifications even while sessions are idle. @@ -1861,9 +1879,15 @@ class GatewayNotificationsMixin: consumer; both must progress without a later foreground turn. """ await asyncio.sleep(3) # let platforms finish connecting + from tools.async_delegation import ORPHAN_SWEEP_INTERVAL_S from tools.process_registry import process_registry as _pr + last_orphan_sweep = None while self._running: with _log_suppressed(logging.DEBUG, "Async delegation watcher error: %s"): + # Completions whose owner process died while this gateway runs (#97202). + if last_orphan_sweep is None or time.monotonic() - last_orphan_sweep >= ORPHAN_SWEEP_INTERVAL_S: + last_orphan_sweep = time.monotonic() + await asyncio.to_thread(self._sweep_orphaned_completion_ledgers) # Pattern events also need an idle consumer; foreground turns are optional. await self._drain_watch_notifications(_pr.completion_queue) # Process completions remain owned by their per-process watchers. diff --git a/hermes_cli/cli_chat_turn_mixin.py b/hermes_cli/cli_chat_turn_mixin.py index 7a3aad8ff1..a7da8013cf 100644 --- a/hermes_cli/cli_chat_turn_mixin.py +++ b/hermes_cli/cli_chat_turn_mixin.py @@ -93,7 +93,7 @@ class CLIChatTurnMixin: message = str(message) # UI metadata is on the staged row, never in model content. ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]") - print(flush=True) + _cprint("") from agent.notification_presentation import notification_config_snapshot, notification_policy_snapshot with notification_policy_snapshot(agent, "cli", notification_config_snapshot()): @@ -117,7 +117,7 @@ class CLIChatTurnMixin: self._chat_settle_turn(turn) return self._chat_render_turn(turn, agent_thread, interrupt_msg) except Exception as e: - print(f"Error: {e}") + _cprint(f"Error: {e}") return None finally: self._chat_release_turn_audio(turn) @@ -397,7 +397,7 @@ class CLIChatTurnMixin: def _chat_monitor_agent_thread(self, turn, agent_thread): """Poll the interrupt queue while the agent thread runs; returns the interrupting message (or None).""" - from cli import _hermes_home, logger + from cli import _cprint, _hermes_home, logger # Ambient "thinking" blips in voice mode; skipped per-blip while TTS speaks, the mic # records or a barge capture is live. voice.thinking_sound gates it (default on). if self._voice_mode: @@ -430,7 +430,7 @@ class CLIChatTurnMixin: pass interrupt_msg = None continue - print("\n⚡ New message detected, interrupting...") + _cprint("\n⚡ New message detected, interrupting...") if turn.stop_event is not None: turn.stop_event.set() self.agent.interrupt(interrupt_msg) @@ -594,16 +594,16 @@ class CLIChatTurnMixin: payload = (combined, image_parts) if image_parts else combined preview = combined[:50] + ("..." if len(combined) > 50 else "") if len(all_parts) > 1: - print(f"\n⚡ Sending {len(all_parts)} messages after interrupt: '{preview}'") + _cprint(f"\n⚡ Sending {len(all_parts)} messages after interrupt: '{preview}'") else: - print(f"\n⚡ Sending after interrupt: '{preview}'") + _cprint(f"\n⚡ Sending after interrupt: '{preview}'") self._pending_input.put(payload) # A /steer the agent finished before absorbing becomes the next user turn. _leftover_steer = turn.result.get("pending_steer") if turn.result else None if _leftover_steer: preview = _leftover_steer[:60] + ("..." if len(_leftover_steer) > 60 else "") - print(f"\n⏩ Delivering leftover /steer as next turn: '{preview}'") + _cprint(f"\n⏩ Delivering leftover /steer as next turn: '{preview}'") self._pending_input.put(_leftover_steer) return response diff --git a/hermes_cli/cli_render.py b/hermes_cli/cli_render.py index 3c97c0cec3..40400bed12 100644 --- a/hermes_cli/cli_render.py +++ b/hermes_cli/cli_render.py @@ -7,11 +7,13 @@ cli-level names through ``from cli import ...`` at call time so facade monkeypat from __future__ import annotations import functools +import itertools import os import re import shutil import sys import textwrap +import threading import time from contextlib import contextmanager, suppress from hermes_cli.banner import format_banner_version_label @@ -479,6 +481,7 @@ def _coerce_output_history_limit(value) -> int: def _clear_output_history() -> None: _cli()._OUTPUT_HISTORY.clear() + _set_chrome_floor(None) # the screen is cleared with it def _output_history_recording() -> bool: @@ -491,10 +494,155 @@ def _record_output_history_entry(entry) -> None: _cli()._OUTPUT_HISTORY.append(entry) -def _record_output_history(text: str) -> None: +class _PaintedLine(str): + """A recorded output line tagged with the terminal ``width`` it was painted at: a terminal + that does not reflow keeps the rows it wrapped into then, whatever the width is now.""" + width = None + + +def _painted_columns(): + """The width the terminal soft-wraps a print at right now, or ``None``.""" + from prompt_toolkit.application import get_app_or_none + app = get_app_or_none() + try: + if app is not None: + return app.output.get_size().columns + return os.get_terminal_size(sys.__stdout__.fileno()).columns + except (AttributeError, OSError, ValueError): + return None + + +def _record_output_history(text: str, *, force: bool = False) -> None: + """Record ``text`` as painted now. ``force`` skips the recording check when the caller + made it at print-request time (the print itself was deferred to the app loop).""" from cli import _output_history_recording - if _output_history_recording(): - _cli()._OUTPUT_HISTORY.extend(str(text).replace("\r", "").rstrip("\n").splitlines()) + if force or _output_history_recording(): + width = _painted_columns() + lines = [] + # One entry per printed line: ``_pt_print`` ends every text with a newline, so "" and a + # trailing "\n" are blank rows on screen and must count in the replay's row budget. + for line in str(text).replace("\r", "").split("\n"): + line = _PaintedLine(line) + line.width = width + lines.append(line) + _cli()._OUTPUT_HISTORY.extend(lines) + + +_ANSI_SEQUENCE_RE = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])") + + +def _ansi_drop_cells(line: str, cells: int) -> str: + """``line`` without its first ``cells`` visible cells; escape sequences are kept for their styling.""" + from prompt_toolkit.utils import get_cwidth + out, pos = [], 0 + for match in [*_ANSI_SEQUENCE_RE.finditer(line), None]: + end = match.start() if match else len(line) + for ch in line[pos:end]: + if cells > 0: + cells -= get_cwidth(ch) + else: + out.append(ch) + if match: + out.append(match.group()) + pos = match.end() + return "".join(out) + + +# TERM names the terminal the CLI talks to, a multiplexer included (tmux, GNU screen), and is set +# by that terminal for its children — decisive whenever it names one. Everything else is inherited +# from whatever started the shell (TMUX, KITTY_WINDOW_ID or TERM_PROGRAM=vscode leak into an st +# or urxvt launched from there), so it only speaks for a generic TERM such as xterm-256color; +# TMUX/STY first there: tmux with `default-terminal xterm-256color` (a common setup) hands its +# panes a generic TERM plus TMUX, while XTERM_VERSION is only inherited from the outer xterm. Then +# XTERM_VERSION: an xterm started from a VTE shell inherits VTE_VERSION but sets XTERM_VERSION. +_REFLOW_TERM_PREFIXES = ("tmux", "screen", "xterm-kitty", "alacritty", "foot", "xterm-ghostty", "wezterm", + "contour", "vte") +# TERM is all that survives ssh; these prefixes name terminals that truncate rows in place. +_NO_REFLOW_TERM_PREFIXES = ("linux", "st", "mosh", "vt", "cons", "rxvt") +_MULTIPLEXER_ENV = ("TMUX", "STY") +_NO_REFLOW_ENV = ("XTERM_VERSION",) +_REFLOW_ENV = ("VTE_VERSION", "KITTY_WINDOW_ID", "WT_SESSION", "KONSOLE_VERSION", + "ALACRITTY_WINDOW_ID", "WEZTERM_PANE", "GHOSTTY_RESOURCES_DIR") +_REFLOW_TERM_PROGRAMS = ("iTerm.app", "Apple_Terminal", "WezTerm", "vscode", "ghostty", "Tabby", "Hyper") + + +def _terminal_reflows() -> bool | None: + """Whether the terminal re-wraps the rows it shows when its width changes: ``True`` + (tmux, GNU screen, VTE, kitty, iTerm2, Terminal.app, WezTerm, Alacritty, Windows Terminal — + a shrink pushes the rows that grew into scrollback), ``False`` (xterm, st, urxvt, mosh, the + Linux console keep every row in place, truncated) or ``None`` when nothing says (xterm or + iTerm2 over ssh both look like ``TERM=xterm-256color``).""" + env = os.environ + term = env.get("TERM", "").lower() + if term.startswith(_REFLOW_TERM_PREFIXES): # before "vt": TERM=vte-256color + return True + if term.startswith(_NO_REFLOW_TERM_PREFIXES): + return False + if any(env.get(name) for name in _MULTIPLEXER_ENV): + return True + if any(env.get(name) for name in _NO_REFLOW_ENV): + return False + if any(env.get(name) for name in _REFLOW_ENV) or env.get("TERM_PROGRAM") in _REFLOW_TERM_PROGRAMS: + return True + if env.get("LC_TERMINAL") == "iTerm2": # iTerm2 sets it so that ssh forwards it (LC_*) + return True + return None + + +def _line_rows(line: str, columns: int) -> int: + """Rows ``line`` fills when the terminal soft-wraps it at ``columns``.""" + from prompt_toolkit.formatted_text import ANSI, fragment_list_width, to_formatted_text + width = fragment_list_width(to_formatted_text(ANSI(line))) + return max(1, -(-width // columns)) if columns and columns > 0 else 1 + + +def _output_tail_fitting(lines: list[str], max_rows: int, columns: int, painted: bool = True) -> list[str]: + """Newest ``lines`` filling ``max_rows`` rows, soft-wrapped at ``columns`` — or, with + ``painted``, at the width each was painted at, as a terminal that does not reflow still + shows it. The oldest one may only partly fit: its bottom rows are kept, the rows above + them are already in scrollback.""" + kept, used = [], 0 + for line in reversed(lines): + if used >= max_rows: + break + cols = (getattr(line, "width", None) if painted else None) or columns + height = _line_rows(line, cols) + if used + height > max_rows: + kept.append(_ansi_drop_cells(line, (height - (max_rows - used)) * cols)) + break + used += height + kept.append(line) + kept.reverse() + return kept + + +def _output_history_lines() -> list[str]: + """The recorded output as the lines a replay paints (callable entries render now).""" + rendered_lines = [] + for entry in tuple(_cli()._OUTPUT_HISTORY): + lines = [entry] + if callable(entry): + try: + lines = entry() + except Exception: + continue + if isinstance(lines, str): + lines = lines.splitlines() + rendered_lines.extend(line if isinstance(line, str) else str(line) for line in lines) + return rendered_lines + + +def _output_history_rows(limit: int, columns: int, painted: bool): + """Rows the whole recorded output fills (counted as ``_output_tail_fitting`` does), or + ``None`` when that is ``limit`` rows or more.""" + if not _cli()._OUTPUT_HISTORY_ENABLED: + return None + total = 0 + for line in reversed(_output_history_lines()): + total += _line_rows(line, (getattr(line, "width", None) if painted else None) or columns) + if total >= limit: + return None + return total def _pt_print_ansi(text: str) -> None: @@ -508,19 +656,115 @@ def _pt_print_ansi(text: str) -> None: print(text) +_HELD_PAINTS: list | None = None +_HELD_PAINTS_LOCK = threading.Lock() +_PAINT_SEQ = itertools.count() +# ``(app, gate)`` while the CLI's app runs: ``gate()`` is True when output must wait for a resize +# recovery (see ``CLITerminalMixin._output_waits_for_resize``). +_PAINT_GATE = None + + +def _set_paint_gate(app, gate) -> None: + global _PAINT_GATE + _PAINT_GATE = (app, gate) if gate is not None else None + + +# Rows from the top of the prompt chrome down to the bottom of the screen, once a refill left +# the chrome's top at a known row (``None``: unknown). prompt_toolkit learns that only through +# CPR, which the CLI leaves off; drawn at least this tall, the chrome keeps reaching the bottom +# row, where ``_transcript_room`` counts the transcript from, also when it shrinks (a narrower +# status bar, a closed modal) — rows left blank below it would be counted as transcript (#95375). +_CHROME_FLOOR = None + + +def _set_chrome_floor(rows) -> None: + global _CHROME_FLOOR + _CHROME_FLOOR = rows + + +def _chrome_floor(): + return _CHROME_FLOOR + + +# Rows output scrolled into scrollback while the terminal's width changed under it: a terminal +# that does not reflow may have truncated them first, if it resized before reading all of that +# output. The next refill repaints them too — twice rather than truncated for good (#95375). +_SUSPECT_ROWS = 0 + + +def _add_suspect_rows(rows: int) -> None: + global _SUSPECT_ROWS + _SUSPECT_ROWS += max(0, rows) + + +def _take_suspect_rows() -> int: + global _SUSPECT_ROWS + rows, _SUSPECT_ROWS = _SUSPECT_ROWS, 0 + return rows + + +def _hold_paints() -> None: + """Hold ``_cprint`` paints until ``_release_paints``, while a resize awaits its recovery. + + A paint erases the prompt chrome from prompt_toolkit's cursor, which is stale once the + terminal re-wrapped the chrome to its new width: rows of the old chrome would stay in the + transcript, where the replay cannot account for them (#95375). On a terminal that does not + reflow, a paint would scroll rows it truncated into scrollback before the refill restores them. + """ + global _HELD_PAINTS + with _HELD_PAINTS_LOCK: + if _HELD_PAINTS is None: + _HELD_PAINTS = [] + + +def _release_paints() -> None: + """Paint, in the order they were requested, what ``_hold_paints`` held.""" + global _HELD_PAINTS + with _HELD_PAINTS_LOCK: + held, _HELD_PAINTS = _HELD_PAINTS or [], None + for _seq, paint in sorted(held, key=lambda item: item[0]): + with suppress(Exception): + paint() + + +def _paint_held(seq: int, paint, app=None) -> bool: + """Queue ``paint`` (requested ``seq``-th) when paints are held — or must be, because the + terminal's width changed under ``app`` and its recovery has not run yet.""" + gate = _PAINT_GATE + if gate is not None and gate[0] is app and gate[1](): + _hold_paints() + with _HELD_PAINTS_LOCK: + if _HELD_PAINTS is None: + return False + _HELD_PAINTS.append((seq, paint)) + return True + + def _cprint(text: str): """Print ANSI text through prompt_toolkit's renderer (patch_stdout swallows raw ANSI). From a background thread while an Application runs, a direct print races the input - redraw and gets buried, so those go through ``run_in_terminal`` via ``call_soon_threadsafe``. + redraw and gets buried, so those are painted on the app's loop via ``call_soon_threadsafe``. """ - from cli import _PT_ANSI, _pt_print, _pt_print_ansi, _record_output_history - _record_output_history(text) + from cli import _PT_ANSI, _output_history_recording, _pt_print, _pt_print_ansi, _record_output_history + recording = _output_history_recording() + seq = next(_PAINT_SEQ) + + def _painted(paint): + # Recorded when painted, not when requested: a redraw replaying the history must + # neither print rows still queued for the loop nor size them at a stale width. + def _paint(): + if recording: + _record_output_history(text, force=True) + paint() + return _paint + paint_pt = _painted(lambda: _pt_print(_PT_ANSI(text))) + paint_fallback = _painted(lambda: _pt_print_ansi(text)) try: from prompt_toolkit.application import get_app_or_none, run_in_terminal except Exception: - _pt_print(_PT_ANSI(text)) + paint_pt() return try: @@ -529,7 +773,8 @@ def _cprint(text: str): app = None if app is None or not getattr(app, "_is_running", False): - _pt_print_ansi(text) + _release_paints() + paint_fallback() return import asyncio as _asyncio @@ -547,23 +792,56 @@ def _cprint(text: str): except Exception: current_loop = None if loop is None or (current_loop is loop and loop.is_running()): - _pt_print(_PT_ANSI(text)) + if not _paint_held(seq, paint_pt, app): + paint_pt() return + def _print_now(): + from prompt_toolkit.formatted_text import to_formatted_text + from prompt_toolkit.renderer import print_formatted_text as _paint_formatted_text + from prompt_toolkit.styles import Style + _paint_formatted_text(app.output, to_formatted_text(_PT_ANSI(text)) + [("", "\n")], Style([])) + paint_now = _painted(_print_now) + def _schedule(): - # run_in_terminal() returns an awaitable (pt >= 3.0) that must be scheduled or the - # output is dropped, or None (mocks / older pt) when it already ran synchronously. - # Never fall back to a bare print on error: the sync path already printed. + if not getattr(app, "_is_running", False): + paint_fallback() + return + if _paint_held(seq, _schedule, app): + return with suppress(Exception): - import inspect as _inspect - coro = run_in_terminal(lambda: _pt_print(_PT_ANSI(text))) - if coro is not None and (_inspect.isawaitable(coro) or _inspect.iscoroutine(coro)): - _asyncio.ensure_future(coro) + pending = getattr(app, "_running_in_terminal_f", None) + if getattr(app, "_running_in_terminal", False) or (pending is not None and not pending.done()): + # Another run_in_terminal body owns the terminal: paint after it. Never fall back + # to a bare print on error: run_in_terminal may already have painted. + import inspect as _inspect + coro = run_in_terminal(lambda: _paint_held(seq, _schedule, app) or paint_now()) + if coro is not None and (_inspect.isawaitable(coro) or _inspect.iscoroutine(coro)): + _asyncio.ensure_future(coro) + return + # What run_in_terminal does, but now: its erase and print would run a loop pass + # later, when a resize may have landed after the check above (#95375). + renderer = app.renderer + floor = _CHROME_FLOOR + if floor is not None: # the chrome, as tall as drawn, moves down by the rows printed + screen = renderer._last_screen + floor = max(floor, renderer._min_available_height, screen.height if screen else 0) + columns = app.output.get_size().columns + renderer.erase() + paint_now() + renderer.reset() + printed = sum(_line_rows(line, columns) for line in text.split("\n")) + if app.output.get_size().columns != columns: + _add_suspect_rows(printed) + if floor is not None: + _set_chrome_floor(max(0, floor - printed)) + app._request_absolute_cursor_position() + app._redraw() try: loop.call_soon_threadsafe(_schedule) except Exception: - _pt_print_ansi(text) + paint_fallback() def _prepend_note_to_message(message, note: str): diff --git a/hermes_cli/cli_stream_mixin.py b/hermes_cli/cli_stream_mixin.py index c4c2121e97..29db37b41c 100644 --- a/hermes_cli/cli_stream_mixin.py +++ b/hermes_cli/cli_stream_mixin.py @@ -563,13 +563,14 @@ class CLIStreamMixin: Most sync slash commands reserve the composer (their completion changes session state); manual compression is safe to draft through (queued input runs against compacted history). """ + from cli import _cprint previous_blocks_input = getattr(self, "_command_blocks_input", False) self._command_running = True self._command_blocks_input = blocks_input self._command_status = status self._invalidate(min_interval=0.0) try: - print(f"⏳ {status}") + _cprint(f"⏳ {status}") yield finally: self._command_running = False diff --git a/hermes_cli/cli_terminal_mixin.py b/hermes_cli/cli_terminal_mixin.py index 3115df2b16..3b3e5784d2 100644 --- a/hermes_cli/cli_terminal_mixin.py +++ b/hermes_cli/cli_terminal_mixin.py @@ -11,9 +11,31 @@ import sys import threading import time +from hermes_cli.cli_render import ( + _chrome_floor, + _hold_paints, + _output_history_rows, + _release_paints, + _set_chrome_floor, + _set_paint_gate, + _take_suspect_rows, +) from hermes_constants import get_hermes_home +# A replay ``fit`` with no room: nothing is replayed. +_NO_REPLAY = (0, 0, False, None) +# How long a resize drag holds output before its next width change runs a recovery anyway, +# that long after the change (seconds): output freezes about their sum plus one signal interval. +_RESIZE_HOLD_MAX = 0.7 +_RESIZE_SETTLE = 0.03 +# How long before a width change was first seen a chrome paint may still have reached the +# terminal after it narrowed (seconds; a paint the width moved under is always suspect): the +# signal lands a few ms after the multiplexer re-wraps, and it reads our output promptly — in +# tmux, paints that ended up to 4 ms before the signal were re-wrapped, never clipped (#95375). +_RESIZE_PAINT_MARGIN = 0.01 + + def _is_eio(exc: BaseException) -> bool: return getattr(exc, "errno", None) == errno.EIO @@ -99,11 +121,13 @@ class CLITerminalMixin: self._app_invalidate(self._app, "invalidate", swallow=False) def _paint_now(self) -> None: - """Immediate, unthrottled repaint for user-blocking modal prompts. + """Unthrottled repaint for user-blocking modal prompts. - Deliberately bypasses the ``_invalidate`` throttle and resize-recovery guard — - a modal the user is waiting on must never be dropped (#41098) — mirroring the - direct ``event.app.invalidate()`` the modal key-binding handlers use. + Bypasses the ``_invalidate`` throttle — a modal the user is waiting on must never be + dropped (#41098) — mirroring the direct ``event.app.invalidate()`` the modal key-binding + handlers use. While a width change awaits its recovery the app's redraw is gated + (``_install_resize_recovery``), so the paint waits for that recovery, which repaints the + whole app about every ``_RESIZE_HOLD_MAX`` seconds even while a drag keeps signalling. """ if getattr(self, "_terminal_io_broken", False): return @@ -128,10 +152,12 @@ class CLITerminalMixin: app = getattr(self, "_app", None) if not app: return - self._clear_prompt_toolkit_screen(app, rebuild_scrollback=self._redraw_rebuilds_scrollback()) + fit = self._clear_prompt_toolkit_screen(app, rebuild_scrollback=self._redraw_rebuilds_scrollback()) if getattr(self, "_terminal_io_broken", False): return - _replay_output_history() + # A viewport refill paints at once, before prompt_toolkit's next erase; after CSI 3J + # the whole history goes through the usual print (the screen was cleared anyway). + _replay_output_history(fit, None if fit is None else app.renderer.output) self._pet_queue_kitty_frame() self._app_invalidate(app, "force_full_redraw", swallow=True) @@ -186,74 +212,217 @@ class CLITerminalMixin: pass self._force_full_redraw() - def _clear_prompt_toolkit_screen(self, app, *, rebuild_scrollback: bool = False) -> None: - """Clear the terminal and reset prompt_toolkit renderer state.""" + def _clear_prompt_toolkit_screen(self, app, *, rebuild_scrollback: bool = False, keep_above: bool = False): + """Clear the terminal and reset prompt_toolkit renderer state. + + Returns the ``fit`` for the replay that follows (``_transcript_room`` plus where to + paint it). ``None`` means replay the whole history (scrollback wiped by CSI 3J). Without + 3J the older transcript stays in scrollback, so the replay may only repaint what the + viewport held (#95375); the viewport is erased row by row because CSI 2J makes + scroll-on-clear terminals (tmux, VTE) copy the whole screen into scrollback first, + stacking a duplicate. With ``keep_above`` (a resize), on a terminal that keeps rows in + place, and the whole history on screen, only its rows and the chrome are erased — what + sits above them (the startup banner) was never recorded and a replay could not restore + it. Counted as painted, the replay leaves the chrome's top at a known row: the chrome is + drawn from there to the bottom (``_set_chrome_floor``), where the next count assumes it. + A clear that fails replays nothing: the history is already on screen or in scrollback. + """ + from cli import _terminal_reflows if getattr(self, "_terminal_io_broken", False): - return + return _NO_REPLAY try: renderer = app.renderer out = renderer.output + size = out.get_size() + fit = None out.reset_attributes() - out.erase_screen() if rebuild_scrollback: - try: - out.write_raw("\x1b[3J") - except Exception: - pass - out.cursor_goto(0, 0) + out.erase_screen() + out.write_raw("\x1b[3J") + out.cursor_goto(0, 0) + _set_chrome_floor(None) + else: + room, columns, painted = self._transcript_room(app) + room += _take_suspect_rows() + # Only where rows stay in place: the erase below counts up from the cursor. + keep_above = keep_above and _terminal_reflows() is False + history_rows = _output_history_rows(room, columns, painted) if keep_above else None + if keep_above and history_rows is not None: + # Rows stay where prompt_toolkit's cursor has them: its oldest row is + # ``history_rows`` above the chrome's top — row ``room`` once that is known. + out.write_raw("\r") + out.cursor_up(renderer._cursor_pos.y + history_rows) + out.erase_down() + known = painted and _chrome_floor() is not None + fit = (room, columns, painted, room - history_rows if known else None) + else: + for row in range(1, size.rows + 1): # CUP rows count from 1 + out.cursor_goto(row, 0) + out.erase_end_of_line() + out.cursor_goto(0, 0) + fit = (room, columns, painted, 0 if painted else None) out.flush() # Drop cached screen + cursor state so the next _redraw() starts from a # known (0, 0) origin and re-renders every cell instead of diffing stale. renderer.reset(leave_alternate_screen=False) + return fit except OSError as exc: if _is_eio(exc): self._mark_terminal_io_broken("clear_screen") except Exception: pass + return _NO_REPLAY + + @staticmethod + def _transcript_room(app): + """``(rows, columns, painted)``: the viewport rows the transcript fills above the + prompt chrome, and how to count them — at the current ``columns``, or with ``painted`` + at the width each line was painted at, as a terminal that does not reflow keeps them. + Unless the terminal is known to reflow, count as painted: that replays at least what + the viewport held, never less. + + The chrome is the renderer's last paint: prompt_toolkit may draw the app taller than + its preferred height (it fills the rows below the cursor it measured, or down to the + bottom row once a refill left its top at a known row). + """ + from cli import _terminal_reflows + renderer = app.renderer + size = renderer.output.get_size() + screen = renderer._last_screen + if screen is None: + drawn = app.layout.container.preferred_height(size.columns, size.rows).preferred + else: + drawn = screen.height + rows = max(0, size.rows - max(renderer._min_available_height, drawn, _chrome_floor() or 0)) + return rows, size.columns, _terminal_reflows() is not True + + @staticmethod + def _painted_row_widths(renderer) -> list[int]: + """Cells each row of the renderer's last paint reaches: prompt_toolkit writes a row up to + its last cell that shows something (the blanks after it are erase-to-end-of-line).""" + screen = renderer._last_screen + if screen is None: + return [] + has_style = renderer._style_string_has_style + return [1 + max((x for x, ch in screen.data_buffer[y].items() if ch.char != " " or has_style[ch.style]), + default=0) for y in range(screen.height)] + + def _note_chrome_paint(self, app, full: bool) -> None: + """Remember when each row of the chrome prompt_toolkit just drew was written, and how + wide — ``full``: the whole chrome was written anew (for ``_aim_erase_at_reflowed_chrome``). + A row only changes height when its width does, so only those changes are kept.""" + renderer = app.renderer + size = renderer._last_size + try: # the width moved while it rendered: the terminal may have got the rows clipped + suspect = size is None or app.output.get_size().columns != size.columns + except Exception: + suspect = True + now = time.monotonic() + previous = [] if full else getattr(self, "_chrome_paints", []) + paints = [] + for y, width in enumerate(self._painted_row_widths(renderer)): + history = previous[y] if y < len(previous) else [] + if not history or history[-1][1] != width: + history = [*history[-3:], (now, width, suspect)] + paints.append(history) + self._chrome_paints = paints + self._chrome_paints_screen = renderer._last_screen + + def _aim_erase_at_reflowed_chrome(self, app, columns: int) -> None: + """Point prompt_toolkit's erase at the top of its last paint as a reflowing terminal + re-wrapped it to ``columns`` (#95375). + + ``renderer.erase()`` moves up ``_cursor_pos.y`` rows — right where rows stay in place, + short of the top on a reflowing terminal, whose re-wrapped chrome rows would stay + above the new paint. A row's width, re-wrapped, is its height now — if the terminal got + the row before it narrowed. prompt_toolkit writes rows with autowrap off, so one it got + after narrowing (painted just before the width change was seen, the signal still on its + way) was clipped to one row instead, and counting it as re-wrapped would erase + transcript rows above the chrome. Rows painted within ``_RESIZE_PAINT_MARGIN`` of the + width change, or unrecorded, count as one row: a stale chrome row left above the new + paint is benign, an erased transcript row is lost for good. + """ + renderer = app.renderer + screen, cursor = renderer._last_screen, renderer._cursor_pos + if screen is None or columns <= 0: + return + paints = getattr(self, "_chrome_paints", []) if getattr(self, "_chrome_paints_screen", None) is screen else [] + cutoff = (getattr(self, "_resize_seen_at", None) or time.monotonic()) - _RESIZE_PAINT_MARGIN + rows = cursor.x // columns + for y in range(cursor.y): + history = paints[y] if y < len(paints) else [] + settled = [i for i, (at, _width, suspect) in enumerate(history) if at < cutoff and not suspect] + if settled: # re-wrapped as it was then, or as a later, narrower paint reached it + rows += -(-min(width for _at, width, _suspect in history[settled[-1]:]) // columns) + else: + rows += 1 + renderer._cursor_pos = cursor._replace(y=rows) def _recover_after_resize(self, app, original_on_resize) -> None: """Recover a resized classic CLI without desynchronizing cursor state. Never clears scrollback (the startup banner lives there and replay cannot rebuild it) and never resets the renderer before prompt_toolkit's own ``_on_resize``, - which erases via the cached cursor position. The status bar / input rules are - suppressed while the reflow settles: on column shrink the terminal reflows + which erases the chrome from the cached cursor position. The status bar / input + rules are suppressed while the reflow settles: on column shrink the terminal reflows already-painted rows into scrollback first, so a fresh bar looks duplicated - (#19280, #22976). Suppression cannot erase the already-reflowed OLD bar - (``renderer.erase()`` uses ``_cursor_pos.y`` cached at the OLD width), so on an - OBSERVED width change we wipe the viewport (CSI 2J, banner-safe; 3J only via - ``display.cli_rebuild_scrollback_on_redraw``) and replay the transcript first. + (#19280, #22976). On a column shrink a reflowing terminal has re-wrapped the old + chrome into more rows than that cached cursor reaches, so the erase is aimed at the + re-wrapped top instead; the transcript above is left exactly as the terminal + re-wrapped it, never erased and replayed (a replay cannot know which rows the + terminal kept on screen and which it pushed into scrollback, #95375). A terminal that + does not reflow truncates every visible row at the new width instead and keeps it in + place, out of scrollback: the viewport is refilled as Ctrl+L does — also after a drag + that narrowed and widened back, which truncated rows all the same. When nothing says + whether the terminal reflows it is refilled too: a reflowing terminal had already + pushed some of those rows into scrollback and now shows them twice, which beats + truncating them for good. + With ``display.cli_rebuild_scrollback_on_redraw`` (3J + whole-history replay) every + width change rebuilds. Same-width SIGWINCH (tmux attach, GNOME tab bar, focus) and the first signal without a seeded baseline are left alone — 2J+replay against preserved scrollback duplicates ``_OUTPUT_HISTORY`` (#65293). tmux-attach's stale previous_screen is handled by ``_hermes_call_output_screen_diff`` (#83874). Suppression is cleared by a debounced timer so the bar returns during idle; next-submit stays a fast path. """ - # A debounced composer resize can arrive after the alternate screen opens. - # Its erase/replay belongs to the suspended composer, not the monitor. - if getattr(getattr(self, '_subagent_monitor', None), 'opening', False): - return - from cli import _replay_output_history - self._status_bar_suppressed_after_resize = True try: - new_width = self._get_tui_terminal_width() - except Exception: - new_width = None - prev_width = getattr(self, "_last_resize_width", None) - width_changed = new_width is not None and prev_width is not None and new_width != prev_width - if width_changed: + # A debounced composer resize can arrive after the alternate screen opens. + # Its erase/replay belongs to the suspended composer, not the monitor. + if getattr(getattr(self, '_subagent_monitor', None), 'opening', False): + return + from cli import _replay_output_history, _terminal_reflows + self._status_bar_suppressed_after_resize = True try: - self._clear_prompt_toolkit_screen( - app, rebuild_scrollback=self._redraw_rebuilds_scrollback()) - _replay_output_history() + new_width = self._get_tui_terminal_width() except Exception: - pass - if new_width is not None: - self._last_resize_width = new_width - if width_changed: - self._pet_queue_kitty_frame() - original_on_resize() - self._schedule_status_bar_unsuppress(app) + new_width = None + prev_width = getattr(self, "_last_resize_width", None) + narrowest = min(filter(None, (new_width, getattr(self, "_resize_narrowest", None))), default=None) + self._resize_narrowest = None + width_changed = new_width is not None and prev_width is not None and new_width != prev_width + narrowed = narrowest is not None and prev_width is not None and narrowest < prev_width + reflows = _terminal_reflows() + if width_changed and self._redraw_rebuilds_scrollback(): + _replay_output_history(self._clear_prompt_toolkit_screen(app, rebuild_scrollback=True)) + elif reflows and width_changed and new_width < prev_width: + self._aim_erase_at_reflowed_chrome(app, new_width) + elif not reflows and narrowed: + fit = self._clear_prompt_toolkit_screen(app, keep_above=True) + _replay_output_history(fit, app.renderer.output) + if new_width is not None: + self._last_resize_width = new_width + if width_changed: + self._pet_queue_kitty_frame() + # Its redraw is skipped, and the next recovery scheduled, if the width moved again + # since it was read above (``_output_waits_for_resize``). + original_on_resize() + self._schedule_status_bar_unsuppress(app) + finally: + # Output held since the signal paints now, against the settled screen — unless + # another width change is already waiting for its own recovery. + if not getattr(self, "_resize_recovery_pending", False): + self._resize_hold_since = None + _release_paints() def _restart_debounce_timer(self, attr: str, delay: float, fn) -> None: """Cancel the daemon Timer stored on ``self.`` (if any) and start a new one. @@ -289,13 +458,26 @@ class CLITerminalMixin: # Fail open: never leave the bar stuck hidden. self._status_bar_suppressed_after_resize = False - def _schedule_resize_recovery(self, app, original_on_resize, delay: float = 0.12) -> None: - """Debounce resize redraws so footer chrome is not stamped into scrollback.""" + def _schedule_resize_recovery(self, app, original_on_resize, delay: float = 0.3) -> None: + """Debounce resize redraws so footer chrome is not stamped into scrollback. + + The delay outwaits tmux, which re-wraps a pane on every resize but signals it at most + every 250 ms, so mid-drag the width a recovery reads is stale — also over ssh from a + tmux pane, where nothing says tmux is there (#95375). A width change also holds output + paints and redraws until its recovery; while a drag keeps signalling, a recovery still + runs ``_RESIZE_SETTLE`` after the first width change once output was held for + ``_RESIZE_HOLD_MAX`` seconds, so output never freezes for the whole drag. A signal that + leaves the width alone holds nothing. + """ try: lock = getattr(self, "_resize_recovery_lock", None) if lock is None: lock = threading.Lock() self._resize_recovery_lock = lock + try: + width = app.output.get_size().columns + except Exception: + width = None def _timer_fired(timer_ref): def _run_recovery(): @@ -307,7 +489,25 @@ class CLITerminalMixin: self._recover_after_resize(app, original_on_resize) _run_on_app_loop(app, _run_recovery) with lock: - self._resize_recovery_pending = True + now = time.monotonic() + pending = getattr(self, "_resize_recovery_pending", False) + if pending or width is None or width != getattr(self, "_last_resize_width", None): + if not pending: + self._resize_recovery_pending = True + self._resize_seen_at = now + # A hold its last recovery could not release keeps its start: the + # next recovery is due at once. + if getattr(self, "_resize_hold_since", None) is None: + self._resize_hold_since = now + self._resize_narrowest = None + _hold_paints() + if isinstance(width, int) and width > 0: # truncated rows at the narrowest + self._resize_narrowest = min(width, getattr(self, "_resize_narrowest", None) or width) + if now - self._resize_hold_since >= _RESIZE_HOLD_MAX: + # Shortly after a width change, not on a timer: between two of a drag, + # once the terminal (a multiplexer re-wrapping its rows) has settled; + # one landing mid-recovery would move rows under its erase and replay. + delay = _RESIZE_SETTLE self._restart_debounce_timer("_resize_recovery_timer", delay, _timer_fired) except Exception: self._resize_recovery_pending = False @@ -332,7 +532,43 @@ class CLITerminalMixin: break self._last_resize_width = width original_on_resize = app._on_resize + self._resize_original_on_resize = original_on_resize app._on_resize = lambda: self._schedule_resize_recovery(app, original_on_resize) + # A render before the recovery lands at the new width from the old geometry and strands + # chrome rows; the recovery repaints everything anyway (#95375). The final render at + # exit is never held. + original_redraw = app._redraw + + def _redraw_unless_resizing(render_as_done: bool = False) -> None: + if render_as_done or not self._output_waits_for_resize(app): + renderer = app.renderer + floor = _chrome_floor() + if floor: # what CPR would tell prompt_toolkit: the rows down to the bottom + renderer._min_available_height = max(renderer._min_available_height, floor) + before = renderer._last_screen, renderer._last_size + original_redraw(render_as_done=render_as_done) + # prompt_toolkit repaints every row when it has no previous paint or a new size + self._note_chrome_paint(app, before[0] is None or renderer._last_size != before[1]) + + app._redraw = _redraw_unless_resizing + _set_paint_gate(app, lambda: self._output_waits_for_resize(app)) + + def _output_waits_for_resize(self, app) -> bool: + """Whether output must wait for a resize recovery: one is pending, or the terminal's + width no longer matches the one the app last settled at — its SIGWINCH is not handled + yet, and a paint or chrome render now would land in geometry that no recovery accounts + for: on a terminal that does not reflow it scrolls rows the resize truncated into + scrollback before any refill (#95375). Noticing that schedules the recovery.""" + if getattr(self, "_resize_recovery_pending", False): + return True + try: + width = app.output.get_size().columns + except Exception: + return False + if width == getattr(self, "_last_resize_width", None): + return False + self._schedule_resize_recovery(app, self._resize_original_on_resize) + return bool(getattr(self, "_resize_recovery_pending", False)) def _try_attach_clipboard_image(self) -> bool: """Save a clipboard image to ~/.hermes/images/ and attach it; True if attached.""" diff --git a/hermes_cli/cli_tui_runtime_mixin.py b/hermes_cli/cli_tui_runtime_mixin.py index 0b3f7f2044..9eeb62a631 100644 --- a/hermes_cli/cli_tui_runtime_mixin.py +++ b/hermes_cli/cli_tui_runtime_mixin.py @@ -108,7 +108,7 @@ class CLITuiRuntimeMixin: if isinstance(user_input, str) and _PASTE_REF_RE.search(user_input): user_input = self._expand_paste_references(user_input) - print() + _cprint("") self._print_user_message_preview(notification_preview or user_input) if submit_images: diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 490865e77a..6a22cf6a78 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -1052,6 +1052,10 @@ DEFAULT_CONFIG = { # "edge" (free) | "elevenlabs" (premium) | "openai" | "xai" | "minimax" | "mistral" | # "gemini" | "deepinfra" | "neutts" (local) | "kittentts" (local) | "piper" (local) "provider": "edge", + # Seconds a local engine (Piper, KittenTTS) stays loaded after the last speech toggle + # turns off, so a quick re-activation (wake word, voice-chat restart) skips the reload. + # 0 unloads immediately. + "keep_warm_seconds": 60, "streaming": { # Shortest first sentence (chars) spoken on its own by streaming TTS; shorter openers # ride with the next sentence. 20 suits English; CJK voice setups use ~6. diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index da6876632c..0b01e9847c 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -866,7 +866,7 @@ def _complete_source_update(request: dict | None) -> None: def _reconcile_diverged_checkout(git_cmd, branch: str, pre_pull_sha, *, target_ref=None) -> None: """Fast-forward failed: merge on a custom branch (local commits survive) or reset --hard on the - same branch (rescue ref first when histories share no ancestor). ``sys.exit(1)`` on failure.""" + same branch after parking the old HEAD behind a rescue ref. ``sys.exit(1)`` on failure.""" # A custom branch (local commits atop origin/) also can't ff, and reset --hard # would discard that work: merge instead, stop on conflict. merge_ref = target_ref if target_ref is not None else f"origin/{branch}" @@ -884,22 +884,35 @@ def _reconcile_diverged_checkout(git_cmd, branch: str, pre_pull_sha, *, target_r print(" Then re-run the update. Local work is untouched.") sys.exit(1) return - # Same branch: a true upstream force-push/rebase; local changes are stashed, so reset. - # Orphan divergence (no common ancestor: corrupted HEAD, re-init) would lose the whole - # local graph, so park pre_pull_sha behind a rescue ref first. + # Same branch: the reset below is right either way, but the two causes of divergence here + # are indistinguishable from the checkout alone. An upstream force-push/rebase loses + # nothing; local commits on this branch lose everything, and the reflog is the only way + # back — an expiring log the user has to know to reach for, in a directory Hermes updates + # unattended. So park pre_pull_sha behind a rescue ref for BOTH, orphan divergence (no + # common ancestor: corrupted HEAD, re-init) included. merge_base_result = _git_run(git_cmd, ["merge-base", "HEAD", merge_ref]) - has_common_ancestor = merge_base_result.returncode == 0 and merge_base_result.stdout.strip() - if not has_common_ancestor and pre_pull_sha: + has_common_ancestor = bool( + merge_base_result.returncode == 0 and merge_base_result.stdout.strip()) + if pre_pull_sha: from datetime import datetime as _dt, timezone # SHA suffix so two updates in the same second get distinct refs. + kind = "diverged" if has_common_ancestor else "orphan" rescue_ref = ( - f"refs/hermes-update-backups/orphan-{branch}-" + f"refs/hermes-update-backups/{kind}-{branch}-" f"{_dt.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{pre_pull_sha[:12]}") - head = f" ⚠ Local history shares no common ancestor with origin/{branch} (orphan divergence) — " + head = ( + f" ⚠ Local history has diverged from origin/{branch} — " + if has_common_ancestor else + f" ⚠ Local history shares no common ancestor with origin/{branch} (orphan divergence) — ") if _git_run(git_cmd, ["update-ref", rescue_ref, pre_pull_sha]).returncode == 0: print( f"{head}backed up current HEAD to {rescue_ref} before resetting. " f"This backup expires after {_ORPHAN_RESCUE_REF_MAX_AGE_DAYS} days.") + if has_common_ancestor: + dropped = (_git_run( + git_cmd, ["rev-list", "--count", f"origin/{branch}..{pre_pull_sha}"]).stdout or "").strip() + print(f" {dropped or 'Some'} commit(s) not on origin/{branch} leave the branch; " + f"list them with: git log origin/{branch}..{rescue_ref}") else: # update-ref failure is intentionally non-fatal, but never claim a backup exists. print( @@ -951,7 +964,7 @@ def _pull_updates( keep_stash, target_ref=None, pre_sync_sha=None, sync_upstream=False, assume_yes=False, in_place_update=False, _windows_gateway_resume=None): """Fast-forward onto ``origin/`` and settle the autostash. Divergence by shape: - custom branch -> merge, same branch -> reset, orphan history -> rescue ref first; a + custom branch -> merge, same branch -> rescue ref then reset; a post-pull syntax error in a critical file rolls back. Exits on failure; returns pre-pull SHA.""" update_succeeded = False # Rescue refs must retain the immediate pre-pull tip, even when syntax diff --git a/hermes_cli/update_cmd_git.py b/hermes_cli/update_cmd_git.py index 7cb9fcb4d3..84cece0a1f 100644 --- a/hermes_cli/update_cmd_git.py +++ b/hermes_cli/update_cmd_git.py @@ -56,7 +56,11 @@ def _git_stdout(git_cmd, args, cwd, **kw) -> Optional[str]: def _prune_orphan_rescue_refs( git_cmd, cwd, branch, keep=_ORPHAN_RESCUE_REFS_TO_KEEP, max_age_days=_ORPHAN_RESCUE_REF_MAX_AGE_DAYS ) -> None: - """Expire old orphan rescue refs (``refs/hermes-update-backups/orphan---``). + """Expire old rescue refs (``refs/hermes-update-backups/---``). + + ```` is ``orphan`` (no common ancestor) or ``diverged`` (local commits on the target + branch). Both are written before the same ``reset --hard`` and both pin objects, so both + expire on the same terms; each kind keeps its own ``keep`` newest. Each ref pins a possibly multi-GB snapshot against ``git gc``, so a repeatedly corrupted install would grow ``.git`` unbounded. Keep the ``keep`` newest AND drop any older than ``max_age_days`` by the @@ -69,18 +73,22 @@ def _prune_orphan_rescue_refs( """ from hermes_cli.update_cmd_git import _git_run with suppress(OSError): - prefix = f"refs/hermes-update-backups/orphan-{branch}-" - list_result = _git_run(git_cmd, ["for-each-ref", "--format=%(refname)", "--sort=refname", f"{prefix}*"], cwd) - if list_result.returncode != 0: - return - refs = [line.strip() for line in list_result.stdout.splitlines() if line.strip()] - stale = set(refs[:-keep] if keep > 0 else refs) - if max_age_days > 0: - cutoff = datetime.now(timezone.utc) - timedelta(days=max_age_days) - for ref in refs: - with suppress(ValueError): - if datetime.strptime(ref[len(prefix):][:15], "%Y%m%d-%H%M%S").replace(tzinfo=timezone.utc) < cutoff: - stale.add(ref) + stale: set[str] = set() + for kind in ("orphan", "diverged"): + prefix = f"refs/hermes-update-backups/{kind}-{branch}-" + list_result = _git_run( + git_cmd, ["for-each-ref", "--format=%(refname)", "--sort=refname", f"{prefix}*"], cwd) + if list_result.returncode != 0: + continue + refs = [line.strip() for line in list_result.stdout.splitlines() if line.strip()] + stale |= set(refs[:-keep] if keep > 0 else refs) + if max_age_days > 0: + cutoff = datetime.now(timezone.utc) - timedelta(days=max_age_days) + for ref in refs: + with suppress(ValueError): + stamp = datetime.strptime(ref[len(prefix):][:15], "%Y%m%d-%H%M%S") + if stamp.replace(tzinfo=timezone.utc) < cutoff: + stale.add(ref) for ref in sorted(stale): _git_run(git_cmd, ["update-ref", "-d", ref], cwd) diff --git a/scripts/desktop-update/posix.sh b/scripts/desktop-update/posix.sh index a69fb61927..7c3b4560dc 100755 --- a/scripts/desktop-update/posix.sh +++ b/scripts/desktop-update/posix.sh @@ -26,7 +26,8 @@ # polls /progress for the current stage or a terminal event and reacts. The # stages come from the gates below, never from child output. It owns nothing -- # relaunch, result file, marker hygiene all happen here, identically, when -# no renderer exists. No chromium-family browser found = no UI, fine. +# no renderer exists. No chromium-family browser found = no UI, fine; macOS +# never opens one (see find_browser). # # ORDERING (the durable-truth rule): swap and relaunch are DECIDED AND # EXECUTED before the result file is written, the marker is removed, or a @@ -195,16 +196,16 @@ find_browser() { # (#88682). The throwaway --user-data-dir below cannot block either; the # remaining Chromium-family browsers carry no first-run chrome of their # own into a fresh profile. - if [ "$(uname)" = "Darwin" ]; then - for c in "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ - "/Applications/Chromium.app/Contents/MacOS/Chromium"; do - [ -x "$c" ] && { echo "$c"; return; } - done - else - for c in google-chrome google-chrome-stable chromium chromium-browser; do - command -v "$c" 2>/dev/null && return - done - fi + # + # No browser at all on macOS. A second --user-data-dir is a second instance + # of the same bundle, and the Dock records every one as a new recent-app + # tile it never merges with the pinned browser: one more duplicate Chrome + # icon per update (#96374). A stable profile would not help (still a second + # instance). notify_fallback + the next-boot result dialog carry the outcome. + [ "$(uname)" = "Darwin" ] && return + for c in google-chrome google-chrome-stable chromium chromium-browser; do + command -v "$c" 2>/dev/null && return + done } # The shim is decoration; launching a browser the user does NOT use is not. @@ -215,28 +216,9 @@ find_browser() { # the durable result file carry the outcome. Best-effort on purpose: any # detection failure keeps today's behavior (0 = allowed). default_browser_is_chromium() { - local py="$1" handler="" - if [ "$(uname)" = "Darwin" ]; then - local plist="$HOME/Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist" - # No explicit https handler registered = the OS default (Safari). - [ -f "$plist" ] || return 1 - handler="$("$py" -c ' -import plistlib, sys -with open(sys.argv[1], "rb") as f: - data = plistlib.load(f) -for entry in data.get("LSHandlers", []): - if entry.get("LSHandlerURLScheme") == "https": - print(entry.get("LSHandlerRoleAll", "")) - break -' "$plist" 2>/dev/null)" || return 0 - # Parsed but empty = no https override = Safari default. - [ -n "$handler" ] || return 1 - case "$handler" in - com.google.[Cc]hrome*|org.chromium.[Cc]hromium*) return 0 ;; - *) return 1 ;; - esac - fi - # Linux: xdg-settings is the authority; missing tool = permissive. + local handler="" + # Linux only (find_browser never picks one on macOS). xdg-settings is the + # authority; missing tool = permissive. command -v xdg-settings >/dev/null 2>&1 || return 0 handler="$(xdg-settings get default-web-browser 2>/dev/null)" || return 0 [ -n "$handler" ] || return 0 @@ -267,7 +249,7 @@ start_ui() { py="${INSTALL_ROOT:+$INSTALL_ROOT/venv/bin/python3}" [ -x "${py:-/nonexistent}" ] || py="$(command -v python3 2>/dev/null)" browser="$(find_browser)" - if [ -n "$browser" ] && [ -n "$py" ] && ! default_browser_is_chromium "$py"; then + if [ -n "$browser" ] && ! default_browser_is_chromium; then log "shim: default browser is not Chromium-family; skipping UI window" browser="" fi diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 488d417eb9..802a2abbf7 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -605,12 +605,22 @@ function Stage-Repository { # files only the new tree has (pm/), so an install left on the old # tree cannot finish -- match the remote the way `hermes update` # does, after parking the old tip. Mirrors scripts/install.sh. - $prior = (Invoke-Native { git -C $InstallDir rev-parse --short HEAD 2>$null }) - if (-not $prior) { $prior = 'unknown' } - $rescue = "refs/hermes-install-backup/$stamp-$prior" - Invoke-Native { git -C $InstallDir update-ref $rescue HEAD 2>$null } - if ($LASTEXITCODE) { Log "could not back up the previous HEAD" } - else { Log "previous HEAD backed up to $rescue" } + # Keep commits absent from origin in the updater's rescue namespace. + $droppedText = (Invoke-Native { git -C $InstallDir rev-list --count "origin/$Branch..HEAD" 2>$null }) + if ($LASTEXITCODE) { Fail "cannot count commits before reset" } + [long]$dropped = 0 + if (-not [long]::TryParse("$droppedText".Trim(), [ref]$dropped)) { Fail "cannot count commits before reset" } + if ($dropped -gt 0) { + Invoke-Native { git -C $InstallDir merge-base HEAD "origin/$Branch" 2>$null } | Out-Null + $rescueKind = if ($LASTEXITCODE -eq 0) { 'diverged' } else { 'orphan' } + $prior = (Invoke-Native { git -C $InstallDir rev-parse --short=12 HEAD 2>$null }) + if ($LASTEXITCODE -or -not $prior) { Fail "cannot identify commits before reset" } + $rescue = "refs/hermes-update-backups/$rescueKind-$Branch-$stamp-$prior" + Invoke-Native { git -C $InstallDir update-ref $rescue HEAD 2>$null } + if ($LASTEXITCODE) { Fail "cannot back up $dropped local commit(s); refusing to reset" } + Log "$dropped commit(s) not on origin/$Branch backed up to $rescue" + Log "List them with: git -C `"$InstallDir`" log origin/$Branch..$rescue" + } Invoke-Native { git -C $InstallDir reset --hard "origin/$Branch" }; if ($LASTEXITCODE) { Fail "git reset failed" } Log "not fast-forwardable; reset to origin/$Branch" } diff --git a/scripts/install.sh b/scripts/install.sh index 24992f824d..4d9540f8bf 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -334,7 +334,7 @@ stage_repository() { git -C "$INSTALL_DIR" remote set-url origin "$REPO_URL" || fail "cannot point origin at $REPO_URL" fi git -C "$INSTALL_DIR" fetch origin "$BRANCH" || fail "git fetch failed" - local stamp prior rescue + local stamp stamp="$(date -u +%Y%m%d-%H%M%S)" # Park local work BEFORE switching branches: checkout refuses a dirty # tree that conflicts, and the reset below would discard it. Work @@ -359,11 +359,23 @@ stage_repository() { # files only the new tree has (pm/), so an install left on the old # tree cannot finish -- match the remote the way `hermes update` # does, after parking the old tip. - prior="$(git -C "$INSTALL_DIR" rev-parse --short HEAD 2>/dev/null || echo unknown)" - rescue="refs/hermes-install-backup/$stamp-$prior" - git -C "$INSTALL_DIR" update-ref "$rescue" HEAD 2>/dev/null \ - && log "previous HEAD backed up to $rescue" \ - || log "could not back up the previous HEAD" + # Only commits absent from origin need a rescue ref. Keep the same + # namespace as `hermes update` so its pruning and recovery work. + local dropped rescue_kind rescue_ref prior + dropped="$(git -C "$INSTALL_DIR" rev-list --count "origin/$BRANCH..HEAD")" \ + || fail "cannot count commits before reset" + if [ "$dropped" -gt 0 ]; then + rescue_kind="diverged" + git -C "$INSTALL_DIR" merge-base HEAD "origin/$BRANCH" >/dev/null 2>&1 \ + || rescue_kind="orphan" + prior="$(git -C "$INSTALL_DIR" rev-parse --short=12 HEAD)" \ + || fail "cannot identify commits before reset" + rescue_ref="refs/hermes-update-backups/$rescue_kind-$BRANCH-$stamp-$prior" + git -C "$INSTALL_DIR" update-ref "$rescue_ref" HEAD \ + || fail "cannot back up $dropped local commit(s); refusing to reset" + log "$dropped commit(s) not on origin/$BRANCH backed up to $rescue_ref" + log "List them with: git -C \"$INSTALL_DIR\" log origin/$BRANCH..$rescue_ref" + fi git -C "$INSTALL_DIR" reset --hard "origin/$BRANCH" || fail "git reset failed" log "not fast-forwardable; reset to origin/$BRANCH" fi diff --git a/tests/agent/test_compression_attempt_ownership.py b/tests/agent/test_compression_attempt_ownership.py index 419adeeab9..fe518ad671 100644 --- a/tests/agent/test_compression_attempt_ownership.py +++ b/tests/agent/test_compression_attempt_ownership.py @@ -23,11 +23,15 @@ end-to-end checks use event handshakes rather than wall-clock timing. from types import SimpleNamespace +import pytest + from agent.conversation_compression import ( + _COMPRESSOR_ATTEMPT_GENERATION, _claim_compressor_attempt, _clear_compression_cancelled_check_if_owner, _compressor_attempt_is_current, _install_compression_cancelled_check, + _raise_if_stale_attempt, _restore_compressor_attempt_state, _snapshot_compressor_attempt_state, ) @@ -45,6 +49,26 @@ def _compressor(**overrides): return SimpleNamespace(**base) +class TestCallerAttemptOwnership: + """The caller generation only fences a compressor that has been claimed.""" + + def test_newer_entry_generation_without_marker_remains_stale(self): + """Control for the unclaimed-compressor carve-out: a CLAIMED compressor whose newer claim + never published a working marker still cancels the older caller.""" + from agent.auxiliary_client import AuxiliaryExplicitCancellation + + compressor = _compressor() + caller_gen = _claim_compressor_attempt(compressor) + _claim_compressor_attempt(compressor) + + token = _COMPRESSOR_ATTEMPT_GENERATION.set(caller_gen) + try: + with pytest.raises(AuxiliaryExplicitCancellation): + _raise_if_stale_attempt(compressor) + finally: + _COMPRESSOR_ATTEMPT_GENERATION.reset(token) + + class TestLatePrimaryRestoreAfterFallbackCommit: """Claim 1: a stale attempt's snapshot restore must no-op.""" @@ -278,6 +302,35 @@ class TestStaleAttemptEndToEnd: resp.choices[0].message.content = content return resp + def test_outer_attempt_can_use_never_claimed_inner_compressor(self): + from unittest.mock import patch + + from agent.conversation_compression import _run_summary_dispatch + + inner = self._compressor() + + class DelegatingEngine: + def compress(self, messages, **kwargs): + return inner.compress(messages, **kwargs) + + engine = DelegatingEngine() + agent = SimpleNamespace(context_compressor=engine, session_id="s1") + messages = self._messages() + generation = _claim_compressor_attempt(engine) + + with patch( + "agent.context_compressor.call_llm", + return_value=self._llm_response("## Goal\ndelegated summary"), + ): + compressed = _run_summary_dispatch( + agent, messages, engine.compress, + {"current_tokens": 999999, "force": True}, + commit_fence=None, attempt_generation=generation, hard_cancel_event=None, + ) + + assert compressed != messages + assert inner._previous_summary and "delegated summary" in inner._previous_summary + def test_detached_primary_late_success_cannot_write_after_fallback(self): import threading from unittest.mock import patch diff --git a/tests/e2e/core/cli_tmux/__init__.py b/tests/e2e/core/cli_tmux/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/e2e/core/cli_tmux/test_resize_scrollback.py b/tests/e2e/core/cli_tmux/test_resize_scrollback.py new file mode 100644 index 0000000000..bed57e44c7 --- /dev/null +++ b/tests/e2e/core/cli_tmux/test_resize_scrollback.py @@ -0,0 +1,115 @@ +"""#95375 on a reflowing terminal: resizing the classic CLI leaves every transcript line in +tmux's scrollback + screen exactly once, with no stray blank or prompt rows. + +A real ``hermes chat --cli`` runs in a private tmux server against the scripted fake provider. +One session goes through a resize storm while a reply streams, a shrink while idle, and a +two-step shrink while the next reply streams; ``capture-pane -J`` then joins tmux's re-wrapped +rows back into lines. Reply lines are 146 columns, so each one wraps at every width here, and +the two-step shrink lands as a line is committed — when the chrome may reach tmux before or +after it narrows. Mocked-renderer unit tests cannot see this: what lands in scrollback is +decided by how the terminal re-wraps the rows prompt_toolkit already wrote. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import sys +import time +import uuid +from collections import Counter +from pathlib import Path + +import pytest + +from tests.fakes.fake_llm_provider import FakeLLMServer, Text, write_hermes_home + +pytestmark = pytest.mark.skipif(shutil.which("tmux") is None, reason="needs tmux") + +REPO_ROOT = Path(__file__).resolve().parents[4] +WORDS = {1: 60, 2: 260, 3: 130} + + +def _reply(turn: int) -> str: + words = [f"t{turn}w{i:03d}" for i in range(WORDS[turn])] + return "\n".join(" ".join(words[j:j + 21]) for j in range(0, len(words), 21)) + + +def test_resizes_keep_each_transcript_line_once_in_tmux_scrollback(tmp_path: Path) -> None: + sock = f"hermes-e2e-{uuid.uuid4().hex[:8]}" + home = tmp_path / "home" + (tmp_path / "work").mkdir() + + def tmux(*args: str) -> str: + return subprocess.run(["tmux", "-L", sock, *args], capture_output=True, text=True, timeout=30).stdout + + def transcript() -> str: + return tmux("capture-pane", "-p", "-J", "-t", "p", "-S", "-", "-E", "-") + + def wait_for(needle: str, timeout: float = 60.0) -> None: + end = time.monotonic() + timeout + while needle not in transcript(): + assert time.monotonic() < end, f"{needle!r} never appeared:\n{transcript()[-3000:]}" + time.sleep(0.1) + + def resize(cols: int) -> None: + tmux("resize-window", "-t", "p", "-x", str(cols), "-y", "24") + + def ask(turn: int) -> None: + tmux("send-keys", "-t", "p", "-l", f"question zq{turn}q please") + time.sleep(0.5) # typed text + Enter in one write is a paste, not a submit + tmux("send-keys", "-t", "p", "Enter") + + def reply_done(turn: int) -> None: + wait_for(f"t{turn}w{WORDS[turn] - 1:03d}") + time.sleep(2.0) + + script = [Text(_reply(t), chunk_chars=10, delay_per_chunk=0.03) for t in WORDS] + with FakeLLMServer(script, aux=lambda _r: Text("Scripted session title")) as llm: + write_hermes_home(home / ".hermes", llm.base_url) + env = {k: v for k, v in os.environ.items() if not k.startswith(("HERMES_", "TMUX"))} + env.update(HOME=str(home), HERMES_HOME=str(home / ".hermes"), PYTHONPATH=str(REPO_ROOT), + TERM="xterm-256color") + argv = [sys.executable, "-m", "hermes_cli.main", "chat", "--cli", "--yolo"] + subprocess.run(["tmux", "-L", sock, "-f", os.devnull, "new-session", "-d", "-s", "p", "-x", "120", + "-y", "24", "-c", str(tmp_path / "work"), *argv], env=env, check=True, timeout=30) + try: + tmux("set", "-g", "window-size", "manual") + wait_for("Welcome to Hermes", timeout=120) + time.sleep(2.0) + + ask(1) + reply_done(1) + ask(2) + wait_for("t2w040") + for cols in (110, 95, 80, 70, 90, 85, 100): # a drag: 7 resizes in 0.35 s + resize(cols) + time.sleep(0.05) + reply_done(2) + resize(80) # idle shrink + time.sleep(1.5) + ask(3) + wait_for("t3w020") # the end of the first line: its commit repaints the chrome + resize(70) # two-step shrink mid-stream + time.sleep(0.8) + resize(60) + reply_done(3) + final = transcript() + finally: + tmux("kill-server") + + # A streaming-preview row painted as tmux narrowed may have been re-wrapped or clipped: it is + # left rather than erased (a stale chrome row is benign, an erased transcript row is lost) — + # at most one per mid-stream recovery: the drag's and each step's (#95375). + lines = final.split("\n") + stale_preview = [line for line in lines if line.lstrip().startswith("\u2026")] + assert len(stale_preview) <= 3, final + words = Counter(re.findall(r"\bt\dw\d{3}\b", "\n".join(ln for ln in lines if ln not in stale_preview))) + expected = {f"t{t}w{i:03d}" for t, n in WORDS.items() for i in range(n)} + assert sorted(w for w in expected if words[w] != 1) == [], final + assert [final.count(f"zq{t}q") for t in WORDS] == [1, 1, 1], final + assert sum(1 for line in final.split("\n") if line.lstrip().startswith("❯")) == 1, final + blank_runs = [len(run) for run in re.findall(r"(?:^[ \t]*\n)+", final, flags=re.M)] + assert max(blank_runs, default=0) <= 2, final # the reply panel's own spacing, nothing more diff --git a/tests/e2e/core/terminal/test_terminal_transcript_pty.py b/tests/e2e/core/terminal/test_terminal_transcript_pty.py index 80e0f5669e..9605514460 100644 --- a/tests/e2e/core/terminal/test_terminal_transcript_pty.py +++ b/tests/e2e/core/terminal/test_terminal_transcript_pty.py @@ -18,14 +18,12 @@ Invariants, checked on the settled final frame of every scenario: The ``resize`` scenarios change the terminal width while a long reply is streaming (SIGWINCH via the PTY), the recurring "history re-appended on resize" shape. ``resize_scrollback`` replays it on -a normal 24-row classic-CLI terminal, where earlier turns already sit in scrollback: while the live -bug #95375 (fix PR #120321) duplicates them, that cell XFAILs on exactly that duplication and on -nothing else, and passes as a plain test once the fix lands. +a normal 24-row classic-CLI terminal, where earlier turns already sit in scrollback and a redraw +must not print them again (#95375). """ from __future__ import annotations -import contextlib import os import shutil import sys @@ -34,7 +32,6 @@ from pathlib import Path import pytest -from tests.e2e.core._pending_fixes import known_failure from tests.e2e.core.terminal._pty import REPO_ROOT, PtyHermes, canon from tests.fakes.fake_llm_provider import FakeLLMServer, Text, ToolCall @@ -134,12 +131,6 @@ MATRIX = [ ("tui", "resize"), ] -# Merge-order safe (see _pending_fixes.known_failure): only a turn rendered MORE than once excuses the cell. -KNOWN = {("cli", "resize_scrollback"): ( - r"(?:rendered|echoed) (?:[2-9]|\d{2,})x", - "LIVE BUG #95375 (fix PR #120321): a width-changing resize clears only the visible screen (CSI 2J) and " - "replays the last 200 output lines, so turns already in scrollback are printed again")} - @pytest.mark.parametrize(("surface", "scenario"), MATRIX) def test_terminal_transcript_integrity(surface: str, scenario: str, tmp_path: Path) -> None: @@ -173,26 +164,23 @@ def test_terminal_transcript_integrity(surface: str, scenario: str, tmp_path: Pa dump = "\n".join(final[-120:]) positions = [] - known = KNOWN.get((surface, scenario)) - gate = known_failure(*known, raises=DuplicateRender) if known else contextlib.nullcontext() - with gate: - for turn in spec.turns: - n_reply = text.count(canon(turn.reply)) - if n_reply != 1: - raise DuplicateRender( - f"[{surface}/{scenario}] assistant reply rendered {n_reply}x (want exactly 1, verbatim): " - f"{turn.reply[:60]!r}\n{dump}") - n_prompt = text.count(canon(turn.prompt)) - if n_prompt != 1: - raise DuplicateRender( - f"[{surface}/{scenario}] user prompt echoed {n_prompt}x (want 1): {turn.prompt!r}\n{dump}") - positions += [text.index(canon(turn.prompt)), text.index(canon(turn.reply))] - if turn.tool_command: - n_cmd = text.count(canon(turn.tool_command)) - assert n_cmd == 1, f"[{surface}/{scenario}] tool call rendered {n_cmd}x\n{dump}" - if turn.reasoning: - n_reason = text.count(canon(turn.reasoning)) - assert n_reason <= 1, f"[{surface}/{scenario}] reasoning rendered {n_reason}x\n{dump}" + for turn in spec.turns: + n_reply = text.count(canon(turn.reply)) + if n_reply != 1: + raise DuplicateRender( + f"[{surface}/{scenario}] assistant reply rendered {n_reply}x (want exactly 1, verbatim): " + f"{turn.reply[:60]!r}\n{dump}") + n_prompt = text.count(canon(turn.prompt)) + if n_prompt != 1: + raise DuplicateRender( + f"[{surface}/{scenario}] user prompt echoed {n_prompt}x (want 1): {turn.prompt!r}\n{dump}") + positions += [text.index(canon(turn.prompt)), text.index(canon(turn.reply))] + if turn.tool_command: + n_cmd = text.count(canon(turn.tool_command)) + assert n_cmd == 1, f"[{surface}/{scenario}] tool call rendered {n_cmd}x\n{dump}" + if turn.reasoning: + n_reason = text.count(canon(turn.reasoning)) + assert n_reason <= 1, f"[{surface}/{scenario}] reasoning rendered {n_reason}x\n{dump}" assert positions == sorted(positions), ( f"[{surface}/{scenario}] transcript out of conversation order\n{dump}") diff --git a/tests/hermes_cli/test_cli_force_redraw.py b/tests/hermes_cli/test_cli_force_redraw.py index 344fa4621c..a4ef427825 100644 --- a/tests/hermes_cli/test_cli_force_redraw.py +++ b/tests/hermes_cli/test_cli_force_redraw.py @@ -24,6 +24,33 @@ def bare_cli(): return cli +def _fake_app(*, rows, columns, chrome, painted=None, cursor_y=0): + """MagicMock app whose output reports a real size and whose layout is ``chrome`` rows tall; + ``painted`` lists the row widths of the renderer's last paint (``None``: nothing yet), its + cursor on row ``cursor_y``.""" + from collections import defaultdict + + from prompt_toolkit.data_structures import Point, Size + from prompt_toolkit.layout.screen import Char, Screen + + app = MagicMock() + app.renderer.output.get_size.return_value = Size(rows=rows, columns=columns) + app.output = app.renderer.output + screen = None + if painted is not None: + screen = Screen() + for y, width in enumerate(painted): + for x in range(width): + screen.data_buffer[y][x] = Char("─") + screen.height = len(painted) + app.renderer._last_screen = screen + app.renderer._cursor_pos = Point(x=0, y=cursor_y) + app.renderer._style_string_has_style = defaultdict(bool) + app.renderer._min_available_height = 0 + app.layout.container.preferred_height.return_value.preferred = chrome + return app + + class TestForceFullRedraw: def test_no_app_is_safe(self, bare_cli): # _force_full_redraw must be a no-op when the TUI isn't running. @@ -33,46 +60,74 @@ class TestForceFullRedraw: - def test_resize_recovery_clears_viewport_on_width_change(self, bare_cli, monkeypatch): - """A WIDTH change must wipe the visible viewport (CSI 2J) and replay. + @pytest.mark.parametrize("reflows,new_width,history_lines,paint", [ + (True, 90, 40, "settled"), (True, 90, 40, "late"), (True, 90, 40, "clipped"), (False, 90, 40, "settled"), + (None, 90, 40, "settled"), (None, 250, 40, "settled"), (False, 90, 2, "settled")]) + def test_resize_never_loses_or_reprints_a_row(self, bare_cli, monkeypatch, reflows, new_width, history_lines, + paint): + """#95375: on a shrink a reflowing terminal has pushed the rows that grew into + scrollback, so nothing is erased and replayed there — prompt_toolkit's erase is only + aimed at the chrome's re-wrapped top. A chrome paint the terminal may have got after + it narrowed (painted right before the width change was seen, or while the width moved) + was clipped, not re-wrapped: its rows count as one each, so the erase never reaches the + transcript above. A terminal that does not reflow (or may not) + keeps every visible row in place, truncated: the viewport is erased row by row (no + CSI 2J, no 3J) and refilled from its top row with what it held, counted at the width it + was painted at, before prompt_toolkit repaints. When the viewport holds the whole + history, only its rows and the chrome are erased: rows above it (the startup banner) + were never recorded. A widen truncates nothing and is left alone.""" + import time + from collections import deque - On column shrink the terminal reflows the old full-width chrome into - extra rows that prompt_toolkit's stale-cursor erase cannot reach, - leaving a duplicated status bar (#19280/#5474 class). We route through - the same recovery as Ctrl+L: erase_screen (2J) + replay transcript. - It must be banner-safe — CSI 3J (write_raw) must NOT fire. - """ - app = MagicMock() + from prompt_toolkit.layout.screen import Char + app = _fake_app(rows=30, columns=new_width, chrome=5, painted=[0, 200, 50, 200, 30], cursor_y=4) + for x in range(200): # blanks without a colour are never written: one row, not three + app.renderer._last_screen.data_buffer[0][x] = Char(" ") + out = app.renderer.output events = [] - app.renderer.output.erase_screen.side_effect = lambda: events.append("erase") - app.renderer.output.write_raw.side_effect = lambda *_: events.append("scrollback_wipe") - original_on_resize = lambda: events.append("original_resize") + out.erase_end_of_line.side_effect = lambda: events.append("erase_row") + out.erase_screen.side_effect = lambda: events.append("clear") + out.erase_down.side_effect = lambda: events.append("erase_down") + out.cursor_up.side_effect = lambda n: events.append(("up", n)) + out.write_raw.side_effect = lambda raw: events.append(("raw", raw)) bare_cli._status_bar_suppressed_after_resize = False bare_cli._last_resize_width = 200 - monkeypatch.setattr(bare_cli, "_get_tui_terminal_width", lambda: 90) + monkeypatch.setattr(bare_cli, "_get_tui_terminal_width", lambda: new_width) monkeypatch.setattr(bare_cli, "_schedule_status_bar_unsuppress", lambda *_: None) - monkeypatch.setattr(cli_mod, "_replay_output_history", lambda: events.append("replay")) - monkeypatch.setattr( - cli_mod, - "CLI_CONFIG", - {"display": {"cli_rebuild_scrollback_on_redraw": False}}, - ) + monkeypatch.setattr(cli_mod, "_OUTPUT_HISTORY", deque("x" * 180 for _ in range(history_lines))) + monkeypatch.setattr(cli_mod, "_replay_output_history", lambda *a: events.append(("replay", *a))) + monkeypatch.setattr(cli_mod, "_terminal_reflows", lambda: reflows) + monkeypatch.setattr(cli_mod, "CLI_CONFIG", {"display": {"cli_rebuild_scrollback_on_redraw": False}}) + cli_mod._set_chrome_floor(None) + from prompt_toolkit.data_structures import Size + app.renderer._last_size = Size(rows=30, columns=200) # the paint: at the old width + out.get_size.return_value = Size(rows=30, columns=new_width if paint == "clipped" else 200) + bare_cli._note_chrome_paint(app, True) + from hermes_cli.cli_terminal_mixin import _RESIZE_PAINT_MARGIN + bare_cli._resize_seen_at = time.monotonic() + (_RESIZE_PAINT_MARGIN / 2 if paint == "late" else 1.0) + out.get_size.return_value = Size(rows=30, columns=new_width) - bare_cli._recover_after_resize(app, original_on_resize) + bare_cli._recover_after_resize( + app, lambda: events.append(("original_resize", app.renderer._cursor_pos.y))) - # Viewport cleared and transcript replayed BEFORE prompt_toolkit's resize. - assert "erase" in events - assert "replay" in events - assert events.index("erase") < events.index("original_resize") - # Banner-safe: scrollback (CSI 3J) must never be wiped on a resize. - assert "scrollback_wipe" not in events - # New width recorded for the next comparison. - assert bare_cli._last_resize_width == 90 + if new_width > 200: + assert events == [("original_resize", 4)] + elif reflows and paint == "settled": + assert events == [("original_resize", 1 + 3 + 1 + 3)] + elif reflows: + assert events == [("original_resize", 4)] + elif history_lines == 2: # 2 lines x 2 rows, right above the chrome + assert events[:4] == [("raw", "\r"), ("up", 4 + 4), "erase_down", ("replay", (25, 90, True, None), out)] + assert events[4][0] == "original_resize" and len(events) == 5 + else: + assert events[:31] == ["erase_row"] * 30 + [("replay", (25, 90, True, 0), out)] + assert events[31][0] == "original_resize" and len(events) == 32 + assert bare_cli._last_resize_width == new_width assert bare_cli._status_bar_suppressed_after_resize is True - def test_force_redraw_uses_full_screen_clear_without_scrollback_clear(self, bare_cli, monkeypatch): - app = MagicMock() + def test_force_redraw_erases_the_viewport_without_scrollback_clear(self, bare_cli, monkeypatch): + app = _fake_app(rows=30, columns=100, chrome=6) bare_cli._app = app monkeypatch.setattr( cli_mod, @@ -82,8 +137,10 @@ class TestForceFullRedraw: bare_cli._force_full_redraw() - app.renderer.output.erase_screen.assert_called_once() - app.renderer.output.cursor_goto.assert_called_once_with(0, 0) + # Row by row, not CSI 2J: scroll-on-clear terminals (tmux, VTE) copy a 2J'd screen + # into scrollback, stacking a duplicate of the transcript (#95375). + app.renderer.output.erase_screen.assert_not_called() + assert app.renderer.output.erase_end_of_line.call_count == 30 app.renderer.output.write_raw.assert_not_called() def test_force_redraw_can_clear_scrollback_when_configured(self, bare_cli, monkeypatch): @@ -111,7 +168,7 @@ class TestForceFullRedraw: bare_cli._last_resize_width = 200 monkeypatch.setattr(bare_cli, "_get_tui_terminal_width", lambda: 90) monkeypatch.setattr(bare_cli, "_schedule_status_bar_unsuppress", lambda *_: None) - monkeypatch.setattr(cli_mod, "_replay_output_history", lambda: events.append("replay")) + monkeypatch.setattr(cli_mod, "_replay_output_history", lambda *_: events.append("replay")) monkeypatch.setattr( cli_mod, "CLI_CONFIG", @@ -313,18 +370,20 @@ class TestFirstSigwinchBaseline: def test_real_width_change_after_baseline_still_replays( self, bare_cli, monkeypatch ): - """The #49120 recovery (2J + replay) must still fire on a real change.""" - app = MagicMock() + """The #49120 recovery (viewport erase + replay) must still fire on a real change.""" + app = _fake_app(rows=30, columns=90, chrome=3, painted=[120, 120, 30], cursor_y=2) events = [] - app.renderer.output.erase_screen.side_effect = lambda: events.append("erase") + app.renderer.output.erase_end_of_line.side_effect = lambda: events.append("erase") + app.renderer.output.erase_down.side_effect = lambda: events.append("erase") original_on_resize = lambda: events.append("original_resize") bare_cli._status_bar_suppressed_after_resize = False bare_cli._last_resize_width = 120 monkeypatch.setattr(bare_cli, "_get_tui_terminal_width", lambda: 90) monkeypatch.setattr(bare_cli, "_schedule_status_bar_unsuppress", lambda *_: None) + monkeypatch.setattr(cli_mod, "_terminal_reflows", lambda: None) monkeypatch.setattr( - cli_mod, "_replay_output_history", lambda: events.append("replay") + cli_mod, "_replay_output_history", lambda *_: events.append("replay") ) bare_cli._recover_after_resize(app, original_on_resize) diff --git a/tests/hermes_cli/test_cli_pet_pane.py b/tests/hermes_cli/test_cli_pet_pane.py index 9f42508b0d..d257205434 100644 --- a/tests/hermes_cli/test_cli_pet_pane.py +++ b/tests/hermes_cli/test_cli_pet_pane.py @@ -258,7 +258,7 @@ def test_force_full_redraw_requeues_kitty_frame(boba_like, monkeypatch): self.invalidated = True cli_obj._app = App() - monkeypatch.setattr("cli._replay_output_history", lambda: None) + monkeypatch.setattr("cli._replay_output_history", lambda *_: None) cli_obj._force_full_redraw() diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index 19f35fe22f..ef927ee6b6 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -41,7 +41,7 @@ def test_update_preserves_local_work_and_rescues_orphan_before_reset( refs = original(['git', 'for-each-ref', '--format=%(objectname)', 'refs/hermes-update-backups/'], cwd=t.clone, check=True, capture_output=True, text=True).stdout.split() - assert refs == ([before] if history == 'orphan' and failure not in {'ref', 'head'} else []) + assert refs == ([before] if failure not in {'ref', 'head'} else []) resets.append(command) if ((failure == 'ref' and 'update-ref' in command and '-d' not in command) or (failure == 'reset' and 'reset' in command and '--hard' in command)): @@ -73,8 +73,12 @@ def test_update_preserves_local_work_and_rescues_orphan_before_reset( assert 'backup write failed' in output and 'backed up current HEAD' not in output if failure == 'reset': assert 'preserved in stash' in output - if history == 'orphan' and failure not in {'ref', 'head'}: + if failure not in {'ref', 'head'}: assert f'expires after {update_cmd._ORPHAN_RESCUE_REF_MAX_AGE_DAYS} days' in output + kind = 'orphan' if history == 'orphan' else 'diverged' + assert f'refs/hermes-update-backups/{kind}-main-' in output + if kind == 'diverged': + assert 'commit(s) not on origin/main leave the branch' in output @pytest.mark.parametrize('mode', ['count', 'age', 'unparseable']) diff --git a/tests/hermes_cli/test_update_diverged_rescue_ref.py b/tests/hermes_cli/test_update_diverged_rescue_ref.py new file mode 100644 index 0000000000..27ffb40625 --- /dev/null +++ b/tests/hermes_cli/test_update_diverged_rescue_ref.py @@ -0,0 +1,83 @@ +"""`hermes update` must not drop local commits without leaving a named way back. + +When the checkout sits on the update's target branch and its history has +diverged, the update resets hard to ``origin/``. Divergence there has +two indistinguishable causes: an upstream force-push (nothing local is lost) +and local commits on that branch (everything is). The update must park the old +HEAD under ``refs/hermes-update-backups/`` first and tell the user the ref name. +The installer update paths are covered in +``tests/scripts/install/test_install_diverged_rescue_ref.py``. +""" + +from __future__ import annotations + +import subprocess + +import pytest + +from hermes_cli import update_cmd + + +GIT = ["git"] + + +def _git(repo, *args, check=True): + return subprocess.run( + GIT + list(args), cwd=repo, capture_output=True, text=True, check=check) + + +def _commit(repo, name, text): + (repo / name).write_text(text, encoding="utf-8") + _git(repo, "add", name) + _git(repo, "-c", "user.name=t", "-c", "user.email=t@example.invalid", + "commit", "-q", "-m", f"add {name}") + return _git(repo, "rev-parse", "HEAD").stdout.strip() + + +@pytest.fixture() +def diverged_checkout(tmp_path): + """A checkout on ``main`` carrying a local commit its ``origin/main`` does not have.""" + upstream = tmp_path / "upstream" + upstream.mkdir() + _git(upstream, "init", "-q", "-b", "main") + _commit(upstream, "shared.txt", "shared\n") + _commit(upstream, "upstream-only.txt", "upstream\n") + + checkout = tmp_path / "checkout" + _git(tmp_path, "clone", "-q", str(upstream), str(checkout)) + _git(checkout, "reset", "-q", "--hard", "HEAD~1") # back to the shared commit + local_sha = _commit(checkout, "local-fix.txt", "local\n") # diverges from origin/main + return checkout, local_sha + + +def _rescue_refs(checkout): + out = _git(checkout, "for-each-ref", "--format=%(refname) %(objectname)", + "refs/hermes-update-backups/").stdout + return dict(line.split() for line in out.splitlines() if line.strip()) + + +def _assert_reset_kept_local_commit(checkout, local_sha, output): + head = _git(checkout, "rev-parse", "HEAD").stdout.strip() + origin = _git(checkout, "rev-parse", "origin/main").stdout.strip() + assert head == origin, "the reset itself must still happen" + refs = [ref for ref, sha in _rescue_refs(checkout).items() if sha == local_sha] + assert len(refs) == 1, f"the dropped commit needs exactly one rescue ref: {_rescue_refs(checkout)}" + assert refs[0].startswith("refs/hermes-update-backups/diverged-main-") + assert refs[0] in output, "the user must be told where the commits went" + dropped = _git(checkout, "log", "--format=%H", f"origin/main..{refs[0]}").stdout.split() + assert dropped == [local_sha] + + +def test_hermes_update_keeps_local_commit_behind_a_rescue_ref( + diverged_checkout, monkeypatch, capsys): + """The real apply path: ff-only fails, the reconcile resets, the local commit stays reachable.""" + checkout, local_sha = diverged_checkout + monkeypatch.setattr(update_cmd._m(), "PROJECT_ROOT", checkout) + + update_cmd._pull_updates( + GIT, "main", None, prompt_for_restore=False, gw_input_fn=None, + discard_local_changes=False, keep_stash=False) + + out = capsys.readouterr().out + _assert_reset_kept_local_commit(checkout, local_sha, out) + assert "1 commit(s) not on origin/main" in out diff --git a/tests/hermes_cli/test_web_server_files.py b/tests/hermes_cli/test_web_server_files.py index fd9ed4d078..247d2c52f5 100644 --- a/tests/hermes_cli/test_web_server_files.py +++ b/tests/hermes_cli/test_web_server_files.py @@ -92,6 +92,25 @@ def _seed_file(client, root, name="out/hello.txt"): +@pytest.mark.parametrize("client_fixture", ["local_files_client", "forced_files_client"]) +def test_mkdir_creates_a_folder_the_picker_can_list_and_enter(client_fixture, request): + """The desktop remote folder picker's New folder: mkdir an absolute child of + the folder it is browsing, then list the parent and navigate into the result.""" + client, root = request.getfixturevalue(client_fixture) + root.mkdir(exist_ok=True) + listed = client.get("/api/fs/list", params={"path": str(root)}).json() + assert "error" not in listed + + created = client.post("/api/files/mkdir", json={"path": str(root / "fresh project")}) + + assert created.status_code == 200 + new_dir = created.json()["path"] + assert (root / "fresh project").is_dir() + after = client.get("/api/fs/list", params={"path": str(root)}).json()["entries"] + assert {"name": "fresh project", "path": new_dir, "isDirectory": True} in after + assert client.get("/api/fs/list", params={"path": new_dir}).json() == {"entries": []} + + def test_download_authenticates_via_query_token(forced_files_client): client, root = forced_files_client file_path = _seed_file(client, root, name="out/demo.mp4") diff --git a/tests/hermes_cli/test_web_server_tts_lease.py b/tests/hermes_cli/test_web_server_tts_lease.py index 7bc86da1bb..f7135d23ed 100644 --- a/tests/hermes_cli/test_web_server_tts_lease.py +++ b/tests/hermes_cli/test_web_server_tts_lease.py @@ -80,9 +80,16 @@ def test_active_acquires_and_warms(client, monkeypatch): assert tts_tool_lifecycle.tts_lease_holders() == ["desktop:read-aloud"] -def test_inactive_releases_and_unloads_when_last(client, monkeypatch): +def _write_tts_config(home, tts): + import yaml + + (home / "config.yaml").write_text(yaml.safe_dump({"tts": tts}), encoding="utf-8") + + +def test_inactive_releases_and_unloads_when_last(client, monkeypatch, isolated_profiles): from tools import tts_tool_lifecycle, tts_tool_local + _write_tts_config(isolated_profiles["default"], {"keep_warm_seconds": 0}) monkeypatch.setattr(tts_tool_lifecycle, "warm_tts_provider", lambda cfg=None, provider=None: {"action": "noop", "warmed": False, "provider": "piper"}) client.post("/api/audio/tts-lease", json={"lease": "desktop:read-aloud", "active": True}) client.post("/api/audio/tts-lease", json={"lease": "desktop:conversation:abc", "active": True}) @@ -99,6 +106,36 @@ def test_inactive_releases_and_unloads_when_last(client, monkeypatch): assert tts_tool_local._piper_voice_cache == {} +def test_last_release_keeps_model_warm_by_default(client, monkeypatch): + """#118037: with the shipped config the last release parks the unload instead of evicting now.""" + from tools import tts_tool_lifecycle, tts_tool_local + + monkeypatch.setattr(tts_tool_lifecycle, "warm_tts_provider", lambda cfg=None, provider=None: {"action": "loaded", "warmed": True, "provider": "piper"}) + client.post("/api/audio/tts-lease", json={"lease": "desktop:conversation:abc", "active": True}) + tts_tool_local._piper_voice_cache["voice"] = object() + + last = client.post("/api/audio/tts-lease", json={"lease": "desktop:conversation:abc", "active": False}).json() + assert last["leases"] == 0 + assert last["released"] == 0 + assert len(tts_tool_local._piper_voice_cache) == 1 + + +def test_keep_warm_window_from_config_unloads_after_it_elapses(client, monkeypatch, isolated_profiles): + import time + from tools import tts_tool_lifecycle, tts_tool_local + + _write_tts_config(isolated_profiles["default"], {"keep_warm_seconds": 0.1}) + monkeypatch.setattr(tts_tool_lifecycle, "warm_tts_provider", lambda cfg=None, provider=None: {"action": "loaded", "warmed": True, "provider": "piper"}) + client.post("/api/audio/tts-lease", json={"lease": "desktop:conversation:abc", "active": True}) + tts_tool_local._piper_voice_cache["voice"] = object() + client.post("/api/audio/tts-lease", json={"lease": "desktop:conversation:abc", "active": False}) + + deadline = time.monotonic() + 5 + while tts_tool_local._piper_voice_cache and time.monotonic() < deadline: + time.sleep(0.02) + assert tts_tool_local._piper_voice_cache == {} + + def test_warm_failure_is_reported_not_an_http_error(client, monkeypatch): from tools import tts_tool_lifecycle diff --git a/tests/scripts/desktop_update/test_desktop_update_macos_no_browser.py b/tests/scripts/desktop_update/test_desktop_update_macos_no_browser.py new file mode 100644 index 0000000000..49e52cf8fa --- /dev/null +++ b/tests/scripts/desktop_update/test_desktop_update_macos_no_browser.py @@ -0,0 +1,80 @@ +"""The macOS hand-off never launches a second browser instance (#96374). + +The progress shim used to start the user's Google Chrome/Chromium binary +directly with its own `--user-data-dir`. That is a second instance of the +same bundle, and the Dock records each one as another recent-app tile that it +never merges with the pinned browser, so every update added one more +duplicate icon. On macOS the outcome goes through the notification + +next-boot result dialog instead. This drives the real `posix.sh` +`--self-test-ui` path with Chrome set as the default browser and watches the +process table for a browser pointed at the shim. +""" + +from __future__ import annotations + +import os +import plistlib +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +SHIM = REPO_ROOT / "scripts" / "desktop-update" / "posix.sh" + + +def _shim_browser_processes(tmp_path: Path) -> list[str]: + ps = subprocess.run( + ["ps", "-axww", "-o", "command="], capture_output=True, text=True, check=False + ) + return [ + line + for line in ps.stdout.splitlines() + if "--app=" in line and f"--user-data-dir={tmp_path}" in line + ] + + +@pytest.mark.macos_only +def test_macos_handoff_opens_no_browser_window_when_chrome_is_default(tmp_path): + # Chrome is the system default https handler: the case the + # default-browser gate let through. + prefs = tmp_path / "Library" / "Preferences" / "com.apple.LaunchServices" + prefs.mkdir(parents=True) + with open(prefs / "com.apple.launchservices.secure.plist", "wb") as f: + plistlib.dump( + {"LSHandlers": [{"LSHandlerURLScheme": "https", "LSHandlerRoleAll": "com.google.chrome"}]}, + f, + ) + install = tmp_path / "hermes-agent" + install.mkdir() + env = { + **os.environ, + "HOME": str(tmp_path), + "TMPDIR": str(tmp_path), + "PATH": f"{Path(sys.executable).parent}:/usr/bin:/bin", + "HERMES_SELFTEST_HOLD_SECONDS": "3", + } + env.pop("HERMES_SELFTEST_FAIL", None) + + proc = subprocess.Popen( + ["bash", str(SHIM), "--install-root", str(install), "--self-test-ui"], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + seen: list[str] = [] + try: + while proc.poll() is None: + seen += _shim_browser_processes(tmp_path) + time.sleep(0.2) + stdout, stderr = proc.communicate(timeout=30) + finally: + if proc.poll() is None: + proc.kill() + proc.wait(timeout=5) + + assert proc.returncode == 0, stdout + stderr + assert seen == [] diff --git a/tests/scripts/install/test_install_diverged_rescue_ref.py b/tests/scripts/install/test_install_diverged_rescue_ref.py new file mode 100644 index 0000000000..f96265efdd --- /dev/null +++ b/tests/scripts/install/test_install_diverged_rescue_ref.py @@ -0,0 +1,107 @@ +"""Regression: the installer's update reset must not drop local commits unanchored. + +Re-running ``install.sh`` / ``install.ps1`` over an existing checkout (desktop +bootstrap and its update retry do this) falls back to +``reset --hard origin/`` when a fast-forward fails. Commits made on that +branch must survive behind a ``refs/hermes-update-backups/`` rescue ref, the same +namespace ``hermes update`` uses, and the installer must print the ref. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent +INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" +INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1" +POWERSHELL = next( + (candidate for candidate in ("pwsh", "powershell") if shutil.which(candidate)), + None, +) + + +def _git(cwd: Path, *args: str) -> str: + return subprocess.run( + ["git", "-c", "user.email=t@t", "-c", "user.name=t", *args], + cwd=cwd, check=True, capture_output=True, text=True, + ).stdout.strip() + + +def _commit(repo: Path, name: str) -> str: + (repo / name).write_text(f"{name}\n", encoding="utf-8") + _git(repo, "add", name) + _git(repo, "commit", "-q", "-m", f"add {name}") + return _git(repo, "rev-parse", "HEAD") + + +def _diverged_managed_checkout(tmp_path: Path) -> tuple[Path, str]: + """A managed checkout on ``main`` with one local commit, while its origin moved on.""" + upstream = tmp_path / "upstream" + upstream.mkdir() + _git(upstream, "init", "-q", "-b", "main") + _commit(upstream, "shared.txt") + managed = tmp_path / "hermes-agent" + _git(tmp_path, "clone", "-q", str(upstream), str(managed)) + local_sha = _commit(managed, "local-fix.txt") + _commit(upstream, "upstream-only.txt") + return managed, local_sha + + +def _assert_local_commit_parked(repo: Path, local_sha: str, output: str) -> None: + assert _git(repo, "rev-parse", "HEAD") == _git(repo, "rev-parse", "origin/main") + refs = dict( + line.split()[::-1] for line in _git( + repo, "for-each-ref", "--format=%(refname) %(objectname)", + "refs/hermes-update-backups/").splitlines()) + ref = refs.get(local_sha) + assert ref and ref.startswith("refs/hermes-update-backups/diverged-main-"), refs + assert ref in output, "the installer must print where the commits went" + assert _git(repo, "log", "--format=%H", f"origin/main..{ref}").split() == [local_sha] + + +@pytest.mark.live_system_guard_bypass +@pytest.mark.skipif( + shutil.which("git") is None or shutil.which("bash") is None, + reason="needs git and bash", +) +def test_install_sh_repository_stage_parks_local_commits_before_reset(tmp_path: Path) -> None: + managed, local_sha = _diverged_managed_checkout(tmp_path) + env = os.environ | { + "HERMES_HOME": str(tmp_path / "hermes-home"), + "HERMES_INSTALL_DIR": str(managed), + } + + result = subprocess.run( + ["bash", str(INSTALL_SH), "--stage", "repository", "--non-interactive"], + cwd=tmp_path, env=env, capture_output=True, text=True, + ) + + assert result.returncode == 0, result.stderr + _assert_local_commit_parked(managed, local_sha, result.stdout + result.stderr) + + +@pytest.mark.live_system_guard_bypass +@pytest.mark.skipif( + shutil.which("git") is None or POWERSHELL is None, + reason="needs git and PowerShell", +) +def test_install_ps1_repository_stage_parks_local_commits_before_reset(tmp_path: Path) -> None: + managed, local_sha = _diverged_managed_checkout(tmp_path) + + result = subprocess.run( + [ + POWERSHELL, "-NoProfile", "-File", str(INSTALL_PS1), + "-Stage", "repository", "-NonInteractive", + "-InstallDir", str(managed), + "-HermesHome", str(tmp_path / "hermes-home"), + ], + cwd=tmp_path, capture_output=True, text=True, + ) + + assert result.returncode == 0, result.stderr + _assert_local_commit_parked(managed, local_sha, result.stdout + result.stderr) diff --git a/tests/tools/test_async_delegation_orphan_sweep.py b/tests/tools/test_async_delegation_orphan_sweep.py new file mode 100644 index 0000000000..db6af99647 --- /dev/null +++ b/tests/tools/test_async_delegation_orphan_sweep.py @@ -0,0 +1,316 @@ +"""A completion whose owner process died is re-offered by processes that are ALREADY running (#97202). + +Startup replay (``restore_undelivered_completions``) runs once per process, so before this a result +persisted by a process that then died (a desktop reload) waited for the next process start. These +tests produce the orphan with a real owner process against a real temp ``state.db``; time is injected +(``now=``) or written into the row, never raced against the wall clock. +""" + +import os +import queue +import sqlite3 +import subprocess +import sys +import threading +import time +from pathlib import Path + +import pytest + +from hermes_constants import get_hermes_home, reset_hermes_home_override, set_hermes_home_override +from tools import async_delegation as ad +from tools.process_registry import process_registry + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# The owner dispatches, its child fails (the reload-interrupted child of the report lands as +# state='error'), the completion is persisted, and the process dies before anything delivers it. +_OWNER = r''' +import os, time +from tools import async_delegation as ad +def child(): + raise RuntimeError("interrupted: waiting for model response") +r = ad.dispatch_async_delegation( + goal="adversarial review", context=None, toolsets=None, role="leaf", model="m", + session_key="bot-chat", parent_session_id="bot-parent", runner=child) +deadline = time.time() + 10 +while ad.active_count() and time.time() < deadline: + time.sleep(.01) +print(r["delegation_id"], flush=True) +os._exit(0) +''' + + +@pytest.fixture(autouse=True) +def _clean_state(): + ad._reset_for_tests() + yield + ad._reset_for_tests() + + +def _orphan(home: Path) -> str: + """Run a real owner process under ``home`` and return the id of the completion it left pending.""" + home.mkdir(parents=True, exist_ok=True) + env = {**os.environ, "HERMES_HOME": str(home), "PYTHONPATH": REPO} + out = subprocess.run([sys.executable, "-c", _OWNER], cwd=REPO, env=env, text=True, + capture_output=True, timeout=60, check=True) + return out.stdout.strip().splitlines()[-1] + + +def _row(home: Path, delegation_id: str) -> dict: + conn = sqlite3.connect(home / "state.db") + try: + conn.row_factory = sqlite3.Row + return dict(conn.execute("SELECT * FROM async_delegations WHERE delegation_id=?", (delegation_id,)).fetchone()) + finally: + conn.close() + + +def _set(home: Path, delegation_id: str, **cols) -> None: + conn = sqlite3.connect(home / "state.db") + try: + conn.execute(f"UPDATE async_delegations SET {', '.join(f'{k}=?' for k in cols)} WHERE delegation_id=?", + (*cols.values(), delegation_id)) + conn.commit() + finally: + conn.close() + + +class _Home: + """Bind the profile home for code outside a turn, the way a delivery loop does.""" + + def __init__(self, home: Path): + self.home = home + + def __enter__(self): + self._token = set_hermes_home_override(str(self.home)) + return self + + def __exit__(self, *exc): + reset_hermes_home_override(self._token) + + +def _drain(q) -> list: + out = [] + while not q.empty(): + out.append(q.get_nowait()) + return out + + +def test_orphaned_terminal_completion_is_offered_once_while_the_process_runs(tmp_path): + home = tmp_path / "home" + delegation_id = _orphan(home) + row = _row(home, delegation_id) + assert (row["state"], row["delivery_state"]) == ("error", "pending") and row["event_json"] + q = queue.Queue() + with _Home(home): + # Freshly written: a live consumer may still be on it, so the sweep leaves it alone. + assert ad.sweep_orphaned_completions(q, now=row["updated_at"] + 1) == 0 + later = row["updated_at"] + ad._ORPHAN_STALE_S + 1 + assert ad.sweep_orphaned_completions(q, now=later) == 1 + (evt,) = _drain(q) + assert evt["delegation_id"] == delegation_id and evt["status"] == "error" + assert evt["session_key"] == "bot-chat" and evt["restored"] is True + # Offered once per process: the next sweep does not flood the queue with the same row. + assert ad.sweep_orphaned_completions(q, now=later + 60) == 0 + assert q.empty() + # Delivery still goes through the atomic claim: a second consumer (another process that + # also offered the row) cannot claim it, and the winner's ack settles the row. + claim = ad.claim_event_delivery(evt, "first") + assert claim + assert ad.claim_event_delivery(evt, "second") is None + assert ad.complete_completion_delivery(delegation_id, claim) + assert _row(home, delegation_id)["delivery_state"] == "delivered" + + +def test_row_owned_by_a_live_process_is_never_swept(tmp_path): + home = tmp_path / "home" + home.mkdir() + q = queue.Queue() + with _Home(home): + handle = ad.dispatch_async_delegation( + goal="mine", context=None, toolsets=None, role="leaf", model="m", session_key="live", + runner=lambda: {"status": "completed", "summary": "done"}) + deadline = time.monotonic() + 10 + while ad.active_count() and time.monotonic() < deadline: + time.sleep(0.01) + row = _row(home, handle["delegation_id"]) + assert row["owner_pid"] == os.getpid() and row["delivery_state"] == "pending" + assert ad.sweep_orphaned_completions(q, now=row["updated_at"] + 3600) == 0 + assert q.empty() + + +def test_sweep_is_bound_to_the_profile_home_it_runs_under(tmp_path): + """A→B→A: each profile's sweep reads only its own ledger and never adopts the other's row.""" + home_a, home_b = tmp_path / "a", tmp_path / "b" + id_a, id_b = _orphan(home_a), _orphan(home_b) + later = max(_row(home_a, id_a)["updated_at"], _row(home_b, id_b)["updated_at"]) + ad._ORPHAN_STALE_S + 1 + q = queue.Queue() + with _Home(home_a): + ad.sweep_orphaned_completions(q, now=later) + assert [e["delegation_id"] for e in _drain(q)] == [id_a] + with _Home(home_b): + ad.sweep_orphaned_completions(q, now=later) + assert [e["delegation_id"] for e in _drain(q)] == [id_b] + with _Home(home_a): + assert ad.sweep_orphaned_completions(q, now=later + 60) == 0 + assert q.empty() + assert _row(home_b, id_b)["delivery_state"] == "pending" + + +def test_restart_replay_and_sweep_never_offer_the_same_row_twice(tmp_path): + home = tmp_path / "home" + delegation_id = _orphan(home) + later = _row(home, delegation_id)["updated_at"] + ad._ORPHAN_STALE_S + 1 + q = queue.Queue() + with _Home(home): + assert ad.restore_undelivered_completions(q) == 1 + assert ad.sweep_orphaned_completions(q, now=later) == 0 + assert [e["delegation_id"] for e in _drain(q)] == [delegation_id] + + +@pytest.mark.parametrize("exhausted", ["attempts", "age"]) +def test_orphan_past_its_delivery_budget_converges_to_dropped(tmp_path, exhausted): + home = tmp_path / "home" + delegation_id = _orphan(home) + row = _row(home, delegation_id) + later = row["updated_at"] + ad._ORPHAN_STALE_S + 1 + if exhausted == "attempts": + _set(home, delegation_id, delivery_attempts=ad._MAX_DELIVERY_ATTEMPTS) + else: + later = row["completed_at"] + ad._MAX_COMPLETION_REPLAY_AGE_S + 1 + q = queue.Queue() + with _Home(home): + assert ad.sweep_orphaned_completions(q, now=later) == 0 + assert q.empty() + assert _row(home, delegation_id)["delivery_state"] == "dropped" + + +def test_in_flight_claim_is_left_to_its_holder(tmp_path): + home = tmp_path / "home" + delegation_id = _orphan(home) + row = _row(home, delegation_id) + later = row["updated_at"] + ad._ORPHAN_STALE_S + 1 + _set(home, delegation_id, delivery_claim="other-process", delivery_claimed_at=later - 1) + q = queue.Queue() + with _Home(home): + assert ad.sweep_orphaned_completions(q, now=later) == 0 + assert q.empty() + + +def test_gateway_watcher_sweeps_each_served_ledger_in_its_own_scope(tmp_path, monkeypatch): + """The gateway's delivery loop re-offers a secondary profile's orphan while it runs.""" + from gateway.run import GatewayRunner + + home_b = tmp_path / "b" + delegation_id = _orphan(home_b) + stale = time.time() - ad._ORPHAN_STALE_S - 60 + _set(home_b, delegation_id, updated_at=stale, completed_at=stale) + isolated = queue.Queue() + monkeypatch.setattr(process_registry, "completion_queue", isolated) + runner = object.__new__(GatewayRunner) + runner._primary_profile_name = "default" + runner._served_profile_homes = {"default": get_hermes_home(), "b": home_b} + runner._sweep_orphaned_completion_ledgers() + assert [e["delegation_id"] for e in _drain(isolated)] == [delegation_id] + + +def test_tui_notification_poller_sweeps_under_its_session_profile(tmp_path, monkeypatch): + """The desktop/TUI delivery loop runs the (throttled) sweep in the session's own profile scope.""" + from tui_gateway import server + + home_b = tmp_path / "b" + home_b.mkdir() + stop = threading.Event() + seen = [] + + def fake_sweep(target_queue): + seen.append((str(get_hermes_home()), target_queue)) + stop.set() + return 0 + + monkeypatch.setattr(ad, "maybe_sweep_orphaned_completions", fake_sweep) + isolated = queue.Queue() + monkeypatch.setattr(process_registry, "completion_queue", isolated) + session = {"profile_home": str(home_b), "_finalized": False} + server._notification_poller_loop(stop, "sid-orphan-sweep", session) + assert seen and seen[0][0] == str(home_b) and seen[0][1] is isolated + + +def _tui_session(session_key: str, home: Path) -> dict: + return {"history_lock": threading.RLock(), "running": False, "history": [], "session_key": session_key, + "profile_home": str(home), "_finalized": False, "_notification_emitted": set()} + + +def test_offer_dropped_by_a_session_that_cannot_own_it_is_re_offered_to_the_owner(tmp_path, monkeypatch): + """Every TUI/Desktop poller drains one process-wide queue. A session that cannot prove it owns an + async delegation drops its copy while the durable row stays pending, so the offer must not + suppress the next sweep: the owning session, live later, still gets the row without a restart.""" + from tools.process_registry_notifications import format_process_notification + from tui_gateway import server + + home = tmp_path / "home" + delegation_id = _orphan(home) + later = _row(home, delegation_id)["updated_at"] + ad._ORPHAN_STALE_S + 1 + q = queue.Queue() + registry = type("Registry", (), {"completion_queue": q, "is_completion_consumed": lambda self, sid: False})() + started = [] + monkeypatch.setattr(server, "_emit", lambda *a, **k: None) + monkeypatch.setattr(server, "_run_prompt_submit", lambda rid, sid, session, text, **kw: started.append(sid)) + + def drain(sid, session): + server._notif_handle_ready(sid, session, _drain(q), session["_notification_emitted"], registry, + format_process_notification, None) + + other, owner = _tui_session("other-chat", home), _tui_session("bot-chat", home) + with _Home(home): + monkeypatch.setitem(server._sessions, "sid-other", other) + assert ad.sweep_orphaned_completions(q, now=later) == 1 + drain("sid-other", other) # the wrong session wins the dequeue while the owner is not live yet + assert q.empty() and started == [] + assert _row(home, delegation_id)["delivery_state"] == "pending" + + monkeypatch.setitem(server._sessions, "sid-owner", owner) # the owner resumes + assert ad.sweep_orphaned_completions(q, now=later + ad.ORPHAN_SWEEP_INTERVAL_S) == 1 + drain("sid-owner", owner) + assert started == ["sid-owner"] + assert _row(home, delegation_id)["delivery_state"] == "delivered" + with _Home(home): # delivered rows are never offered again + assert ad.sweep_orphaned_completions(q, now=later + 2 * ad.ORPHAN_SWEEP_INTERVAL_S) == 0 + + +def test_offer_released_after_a_failed_tui_turn_is_re_offered(tmp_path, monkeypatch): + """The TUI poller releases its claim and discards its copy when the turn cannot start; the row is + pending again with one attempt spent, so the sweep offers it again instead of skipping it.""" + from tui_gateway import server + + home = tmp_path / "home" + delegation_id = _orphan(home) + later = _row(home, delegation_id)["updated_at"] + ad._ORPHAN_STALE_S + 1 + q = queue.Queue() + monkeypatch.setattr(server, "_emit", lambda *a, **k: None) + monkeypatch.setattr(server, "_run_prompt_submit", + lambda *a, **k: (_ for _ in ()).throw(RuntimeError("no free worker"))) + owner = _tui_session("bot-chat", home) + with _Home(home): + assert ad.sweep_orphaned_completions(q, now=later) == 1 + (evt,) = _drain(q) + assert server._notif_claim_turn(owner) + server._notif_dispatch_event("sid-owner", owner, evt, "text") + row = _row(home, delegation_id) + assert (row["delivery_state"], row["delivery_claim"], row["delivery_attempts"]) == ("pending", None, 1) + assert ad.sweep_orphaned_completions(q, now=row["updated_at"] + ad._ORPHAN_STALE_S + 1) == 1 + assert [e["delegation_id"] for e in _drain(q)] == [delegation_id] + + +def test_throttle_runs_at_most_one_sweep_per_home_per_interval(tmp_path, monkeypatch): + calls = [] + monkeypatch.setattr(ad, "sweep_orphaned_completions", lambda q, **kw: calls.append(str(get_hermes_home())) or 0) + q = queue.Queue() + with _Home(tmp_path / "a"): + ad.maybe_sweep_orphaned_completions(q, now=100.0) + ad.maybe_sweep_orphaned_completions(q, now=101.0) + with _Home(tmp_path / "b"): # another profile has its own clock + ad.maybe_sweep_orphaned_completions(q, now=101.0) + ad.maybe_sweep_orphaned_completions(q, now=100.0 + ad.ORPHAN_SWEEP_INTERVAL_S + 1) + assert calls == [str(tmp_path / "a"), str(tmp_path / "b"), str(tmp_path / "a")] diff --git a/tests/tools/test_tts_lifecycle_leases.py b/tests/tools/test_tts_lifecycle_leases.py index f0213239b3..86720db1b7 100644 --- a/tests/tools/test_tts_lifecycle_leases.py +++ b/tests/tools/test_tts_lifecycle_leases.py @@ -4,7 +4,7 @@ Local engines load lazily on first synthesis, so the first spoken reply after "read replies aloud" / voice conversation turns on pays the model load as dead air. The toggles now hold *leases*: acquiring warms the configured provider into the SAME cache slot synthesis reads; releasing the last lease unloads -resident local models. +resident local models once the keep-warm window passes (#118037). """ from __future__ import annotations @@ -63,6 +63,39 @@ def fake_piper(monkeypatch, tmp_path): return cfg +class _FakeTimer: + def __init__(self, interval, function, args=None, kwargs=None): + self.interval, self.function, self.args = interval, function, tuple(args or ()) + self.started = self.cancelled = False + self.daemon = False + + def start(self): + self.started = True + + def cancel(self): + self.cancelled = True + + def fire(self): + self.function(*self.args) + + +@pytest.fixture +def timers(monkeypatch): + created: list = [] + + def _factory(*args, **kwargs): + timer = _FakeTimer(*args, **kwargs) + created.append(timer) + return timer + + monkeypatch.setattr(tts_tool_lifecycle, "_make_keep_warm_timer", _factory, raising=False) + return created + + +def _pending(timers): + return [t for t in timers if t.started and not t.cancelled] + + # -------------------------------------------------------------------------- # warm_tts_provider: warm-up populates the exact slot synthesis reads # -------------------------------------------------------------------------- @@ -188,7 +221,7 @@ def test_acquire_warms_and_counts(fake_piper): assert tts_tool_lifecycle.tts_lease_holders() == ["desktop:read-aloud"] -def test_last_release_unloads_but_earlier_release_does_not(fake_piper): +def test_last_release_unloads_but_earlier_release_does_not(fake_piper, timers): tts_tool_lifecycle.acquire_tts_lease("desktop:read-aloud") tts_tool_lifecycle.acquire_tts_lease("tui:voice-tts") assert len(tts_tool_local._piper_voice_cache) == 1 @@ -198,9 +231,12 @@ def test_last_release_unloads_but_earlier_release_does_not(fake_piper): first = tts_tool_lifecycle.release_tts_lease("desktop:read-aloud") assert first == {"leases": 1, "released": 0} assert len(tts_tool_local._piper_voice_cache) == 1 + assert _pending(timers) == [] last = tts_tool_lifecycle.release_tts_lease("tui:voice-tts") - assert last == {"leases": 0, "released": 1} + assert last["leases"] == 0 + [timer] = _pending(timers) + timer.fire() assert tts_tool_local._piper_voice_cache == {} @@ -250,7 +286,7 @@ def test_every_local_warmer_has_a_registered_cache(): # -------------------------------------------------------------------------- -def test_plugin_provider_warm_and_release_follow_the_lease(monkeypatch): +def test_plugin_provider_warm_and_release_follow_the_lease(monkeypatch, timers): from agent import tts_provider, tts_registry calls: list = [] @@ -280,12 +316,14 @@ def test_plugin_provider_warm_and_release_follow_the_lease(monkeypatch): tts_tool_lifecycle.release_tts_lease("desktop:read-aloud") assert calls == ["warm", "warm"] # still one holder — no release yet tts_tool_lifecycle.release_tts_lease("tui:voice-tts") + assert calls == ["warm", "warm"] # parked for the keep-warm window + _pending(timers)[0].fire() assert calls == ["warm", "warm", "release"] finally: tts_registry._reset_for_tests() -def test_command_provider_runs_warm_and_release_commands(monkeypatch): +def test_command_provider_runs_warm_and_release_commands(monkeypatch, timers): import os ran: list = [] @@ -311,6 +349,7 @@ def test_command_provider_runs_warm_and_release_commands(monkeypatch): assert done.wait(5) done.clear() tts_tool_lifecycle.release_tts_lease("desktop:read-aloud") + _pending(timers)[0].fire() assert done.wait(5) # The {model} placeholder is unquoted in the template, so the renderer # shell-quotes it for the host platform (list2cmdline on Windows, @@ -318,3 +357,67 @@ def test_command_provider_runs_warm_and_release_commands(monkeypatch): quote = subprocess.list2cmdline([cfg["providers"]["srv"]["model"]]) if os.name == "nt" \ else shlex.quote(cfg["providers"]["srv"]["model"]) assert ran == [f"curl -s localhost:5002/load?model={quote}", "curl -s localhost:5002/unload"] + + +# -------------------------------------------------------------------------- +# Keep-warm window (#118037): the last release schedules the unload on a +# cancellable timer instead of dropping the model inline, so a wake-word loop +# that re-acquires within ``tts.keep_warm_seconds`` reuses the loaded voice. +# -------------------------------------------------------------------------- + + +def test_last_release_keeps_model_warm_for_configured_window(fake_piper, timers): + fake_piper["keep_warm_seconds"] = 30 + tts_tool_lifecycle.acquire_tts_lease("desktop:conversation") + + result = tts_tool_lifecycle.release_tts_lease("desktop:conversation") + + assert result == {"leases": 0, "released": 0} + assert len(tts_tool_local._piper_voice_cache) == 1 + [timer] = _pending(timers) + assert timer.interval == fake_piper["keep_warm_seconds"] + assert timer.daemon is True # a parked unload never holds the process open + + timer.fire() + assert tts_tool_local._piper_voice_cache == {} + + +def test_reacquire_within_window_reuses_the_loaded_voice(fake_piper, timers): + tts_tool_lifecycle.acquire_tts_lease("desktop:conversation") + tts_tool_lifecycle.release_tts_lease("desktop:conversation") + [stale] = _pending(timers) + + again = tts_tool_lifecycle.acquire_tts_lease("desktop:conversation") + + assert again["action"] == "cached" + assert _FakePiperVoice.loads == 1 + assert _pending(timers) == [] + stale.fire() # a timer that already woke up before the cancel must not unload + assert len(tts_tool_local._piper_voice_cache) == 1 + + +def test_release_during_warm_up_still_unloads_after_the_window(fake_piper, timers, monkeypatch): + """A release that lands mid-load finds an empty cache; the voice must not stay resident forever.""" + real_load = _FakePiperVoice.load.__func__ + + def _load_then_release(cls, model_path, use_cuda=False): + tts_tool_lifecycle.release_tts_lease("tui:voice-tts") + return real_load(cls, model_path, use_cuda) + + monkeypatch.setattr(_FakePiperVoice, "load", classmethod(_load_then_release)) + tts_tool_lifecycle.acquire_tts_lease("tui:voice-tts") + + assert tts_tool_lifecycle.tts_lease_holders() == [] + assert len(tts_tool_local._piper_voice_cache) == 1 + [timer] = _pending(timers) + timer.fire() + assert tts_tool_local._piper_voice_cache == {} + + +def test_zero_keep_warm_unloads_inline(fake_piper, timers): + fake_piper["keep_warm_seconds"] = 0 + tts_tool_lifecycle.acquire_tts_lease("cli:voice-tts") + + assert tts_tool_lifecycle.release_tts_lease("cli:voice-tts") == {"leases": 0, "released": 1} + assert tts_tool_local._piper_voice_cache == {} + assert _pending(timers) == [] diff --git a/tests/tui_gateway/test_tui_gateway_server.py b/tests/tui_gateway/test_tui_gateway_server.py index 923fa1f55d..126ceeb95f 100644 --- a/tests/tui_gateway/test_tui_gateway_server.py +++ b/tests/tui_gateway/test_tui_gateway_server.py @@ -11978,7 +11978,7 @@ def test_session_steer_calls_agent_steer_when_agent_supports_it(): def interrupt(self, *args, **kwargs): calls["interrupt_called"] = True - server._sessions["sid"] = _session(agent=_Agent()) + server._sessions["sid"] = _session(agent=_Agent(), running=True) try: resp = server.handle_request( { @@ -11997,6 +11997,64 @@ def test_session_steer_calls_agent_steer_when_agent_supports_it(): assert "interrupt_called" not in calls # must NOT interrupt +class _RecordingSteerAgent: + def __init__(self): + self.steered = [] + + def steer(self, text): + self.steered.append(text) + return True + + +def test_session_steer_on_idle_session_is_rejected_not_parked(): + """#64578: with no live turn a steer has no tool call to ride. Accepting it parked the text in + the agent's pending-steer slot, where the next turn's pre-API drain spliced it after an OLD tool + row. It must come back 'rejected' (clients then send it as a normal prompt) and never reach + agent.steer().""" + agent = _RecordingSteerAgent() + server._sessions["sid"] = _session(agent=agent, running=False) + try: + resp = server.handle_request( + {"id": "1", "method": "session.steer", "params": {"session_id": "sid", "text": "check the logs"}} + ) + finally: + server._sessions.pop("sid", None) + + assert resp["result"]["status"] == "rejected", resp + assert agent.steered == [] + + +def test_steer_slash_on_idle_session_sends_as_next_turn(): + """#64578: idle `/steer ` via command.dispatch must go out as a normal next-turn message + with a notice saying so, not claim "Steer queued" while stashing the text on the agent.""" + agent = _RecordingSteerAgent() + server._sessions["sid"] = _session(agent=agent, running=False) + try: + res = server._methods["command.dispatch"]( + "1", {"name": "steer", "arg": "check the logs", "session_id": "sid"}) + finally: + server._sessions.pop("sid", None) + + result = res["result"] + assert result["type"] == "send" + assert result["message"] == "check the logs" + assert result.get("notice") + assert agent.steered == [] + + +def test_steer_slash_during_live_turn_still_steers(): + agent = _RecordingSteerAgent() + server._sessions["sid"] = _session(agent=agent, running=True) + try: + res = server._methods["command.dispatch"]( + "1", {"name": "steer", "arg": "check the logs", "session_id": "sid"}) + finally: + server._sessions.pop("sid", None) + + assert res["result"]["type"] == "exec" + assert agent.steered == ["check the logs"] + + def test_session_steer_rejects_empty_text(): server._sessions["sid"] = _session( agent=types.SimpleNamespace(steer=lambda t: True) diff --git a/tools/async_delegation.py b/tools/async_delegation.py index 65186f44e5..f38e39e54a 100644 --- a/tools/async_delegation.py +++ b/tools/async_delegation.py @@ -20,7 +20,7 @@ import uuid from concurrent.futures import ThreadPoolExecutor from typing import Any, Callable, Dict, List, Optional -from hermes_constants import get_hermes_home +from hermes_constants import get_hermes_home, hermes_home_key from tools.daemon_pool import DaemonThreadPoolExecutor from tools.thread_context import propagate_context_to_thread @@ -47,8 +47,24 @@ _MAX_DELIVERY_ATTEMPTS = 8 # Pending completions older than this are dropped on restart replay instead of # re-run as a full-context turn; 48h keeps weekend results deliverable. _MAX_COMPLETION_REPLAY_AGE_S = 48 * 3600.0 +# A delivery claim older than this is abandoned and may be re-claimed. +_CLAIM_LEASE_S = 300.0 _DB_LOCK = threading.Lock() +# ── Orphaned-completion sweep ──────────────────────────────────────────────── +# Startup replay runs once per process, so a completion whose owner died while THIS process was +# already running (a desktop reload) would wait for the next restart (#97202). Delivery loops (gateway +# watcher, TUI poller) sweep each home they serve at most once per interval. +ORPHAN_SWEEP_INTERVAL_S = 30.0 +# Idle time before a dead owner's pending row is re-offered; keeps the sweep off a row just touched. +_ORPHAN_STALE_S = 60.0 +_orphan_lock = threading.Lock() +# (home key, delegation_id) put on this process's queue by replay or sweep and not re-offered while +# that copy is alive. A consumer that discards its copy with the row still pending hands it back +# (``return_completion_offer``); the delivery claim stays the only thing that settles the row. +_offered: set = set() +_last_orphan_sweep: Dict[str, float] = {} + # ── Stale-delegation detection (progress-based, on by default) ────────────── # A runner wedged before returning never reaches its finalizer, so it would show # "dispatched" forever. No wall-clock timeout (heavy work must never be killed for @@ -225,12 +241,25 @@ def _recovered_results(task: Dict[str, Any], result_json: Optional[str], error: return [recorded.get(i) or {"task_index": i, "status": "unknown", "summary": None, "error": error} for i in indexes] -def recover_abandoned_delegations() -> int: - """Classify records whose owning process disappeared as outcome unknown; children a multi-child unit had already - recorded (``record_unit_child``) are replayed with their real results.""" +def _owner_liveness() -> Optional[Callable[[Any, Any], bool]]: + """``alive(owner_pid, owner_started_at)`` over the shared drift-tolerant start-time comparator, + or None when the liveness probes cannot be imported.""" try: from gateway.status import _pid_exists, get_process_start_time, start_time_fingerprints_match except Exception: + return None + + def alive(pid, started) -> bool: + return bool(pid) and _pid_exists(int(pid)) and ( + started is None or start_time_fingerprints_match(started, get_process_start_time(int(pid)) or 0)) + return alive + + +def recover_abandoned_delegations() -> int: + """Classify records whose owning process disappeared as outcome unknown; children a multi-child unit had already + recorded (``record_unit_child``) are replayed with their real results.""" + alive = _owner_liveness() + if alive is None: return 0 now, recovered = time.time(), 0 with _DB_LOCK, _transaction() as conn: @@ -240,9 +269,7 @@ def recover_abandoned_delegations() -> int: FROM async_delegations WHERE state IN ('running','finalizing')""").fetchall() for row in rows: delegation_id, session_key, origin_ui, parent_id, dispatched_at, pid, started, task_json, origin_sid, result_json, last_state = row - if pid and _pid_exists(int(pid)) and ( - started is None or start_time_fingerprints_match(started, get_process_start_time(int(pid)) or 0) - ): + if alive(pid, started): continue task = json.loads(task_json or "{}") error = "Delegation owner exited before recording a terminal result; outcome unknown." @@ -293,31 +320,103 @@ def restore_undelivered_completions(target_queue) -> int: (#64484). """ recover_abandoned_delegations() - now, restored = time.time(), 0 + now = time.time() with _DB_LOCK, _transaction() as conn: rows = conn.execute("""SELECT delegation_id, event_json, completed_at, dispatched_at FROM async_delegations WHERE state != 'running' AND delivery_state='pending' AND event_json IS NOT NULL ORDER BY completed_at, delegation_id""").fetchall() - for delegation_id, payload, completed_at, dispatched_at in rows: - age_basis = completed_at or dispatched_at - if age_basis and (now - age_basis) > _MAX_COMPLETION_REPLAY_AGE_S: - conn.execute("""UPDATE async_delegations SET delivery_state='dropped', - delivery_claim=NULL, delivery_claimed_at=NULL, - updated_at=? - WHERE delegation_id=? AND delivery_state='pending'""", (now, delegation_id)) - logger.warning("Async delegation %s: pending completion is %.1fh old " - "(cap %.1fh); terminally dropping the replay (result remains queryable).", - delegation_id, (now - age_basis) / 3600.0, _MAX_COMPLETION_REPLAY_AGE_S / 3600.0) - continue - evt = json.loads(payload) - if isinstance(evt, dict): - evt["restored"] = True - target_queue.put(evt) - restored += 1 + return _replay_pending(conn, rows, target_queue, now) + + +def _replay_pending(conn, rows, target_queue, now: float) -> int: + """Put each pending ``(delegation_id, event_json, completed_at, dispatched_at)`` row on ``target_queue`` + stamped ``restored``, or terminally drop it past ``_MAX_COMPLETION_REPLAY_AGE_S``. Records the offer so + the orphan sweep skips the row until the copy is handed back (``return_completion_offer``).""" + home, restored = hermes_home_key(get_hermes_home()), 0 + for delegation_id, payload, completed_at, dispatched_at in rows: + age_basis = completed_at or dispatched_at + if age_basis and (now - age_basis) > _MAX_COMPLETION_REPLAY_AGE_S: + conn.execute("""UPDATE async_delegations SET delivery_state='dropped', + delivery_claim=NULL, delivery_claimed_at=NULL, + updated_at=? + WHERE delegation_id=? AND delivery_state='pending'""", (now, delegation_id)) + logger.warning("Async delegation %s: pending completion is %.1fh old " + "(cap %.1fh); terminally dropping the replay (result remains queryable).", + delegation_id, (now - age_basis) / 3600.0, _MAX_COMPLETION_REPLAY_AGE_S / 3600.0) + continue + evt = json.loads(payload) + if isinstance(evt, dict): + evt["restored"] = True + target_queue.put(evt) + with _orphan_lock: + _offered.add((home, delegation_id)) + restored += 1 return restored +def sweep_orphaned_completions(target_queue, *, now: Optional[float] = None) -> int: + """Offer this home's completions whose owner died after THIS process started (#97202). + + Startup replay (``restore_undelivered_completions``) covers owners that died before the process + started; this covers the rest while it runs. Abandoned in-flight rows are first classified by + ``recover_abandoned_delegations``. A terminal row qualifies when it is pending with an event, idle + past ``_ORPHAN_STALE_S``, not under a live delivery claim, and its owner fails the shared liveness + check. A row is offered once per live in-memory copy: a consumer that discards the copy with the row + still pending hands it back for the next sweep. The consumer's ``claim_completion_delivery`` stays + the atomic cross-process gate, so two processes offering one row never both deliver it. Rows past + the delivery budget or the replay age converge to ``dropped``. Reads the current profile's ledger: + callers bind the owning profile first.""" + alive = _owner_liveness() + if alive is None or not _db_path().exists(): + return 0 # never create a ledger just to sweep it + recover_abandoned_delegations() + now = time.time() if now is None else now + home = hermes_home_key(get_hermes_home()) + with _orphan_lock: + offered = {delegation_id for key, delegation_id in _offered if key == home} + with _DB_LOCK, _transaction() as conn: + rows = conn.execute("""SELECT delegation_id, event_json, completed_at, dispatched_at, + owner_pid, owner_started_at, delivery_attempts + FROM async_delegations + WHERE state NOT IN ('running','finalizing') AND delivery_state='pending' + AND event_json IS NOT NULL AND updated_at < ? + AND (delivery_claim IS NULL OR delivery_claimed_at < ?) + ORDER BY completed_at, delegation_id""", (now - _ORPHAN_STALE_S, now - _CLAIM_LEASE_S)).fetchall() + orphans = [] + for delegation_id, payload, completed_at, dispatched_at, pid, started, attempts in rows: + if delegation_id in offered or alive(pid, started): + continue + if (attempts or 0) >= _MAX_DELIVERY_ATTEMPTS: + # Its last claimant died holding the final attempt; converge like release_completion_delivery. + conn.execute("""UPDATE async_delegations SET delivery_state='dropped', + delivery_claim=NULL, delivery_claimed_at=NULL, updated_at=? + WHERE delegation_id=? AND delivery_state='pending'""", (now, delegation_id)) + logger.warning("Async delegation %s exhausted its %d delivery attempts; " + "marking terminally dropped (result remains queryable).", + delegation_id, _MAX_DELIVERY_ATTEMPTS) + continue + orphans.append((delegation_id, payload, completed_at, dispatched_at)) + return _replay_pending(conn, orphans, target_queue, now) + + +def maybe_sweep_orphaned_completions(target_queue, *, now: Optional[float] = None) -> int: + """``sweep_orphaned_completions`` at most once per ``ORPHAN_SWEEP_INTERVAL_S`` per home (``now`` is + monotonic), for delivery loops that tick far more often. Never raises into the loop.""" + home = hermes_home_key(get_hermes_home()) + now = time.monotonic() if now is None else now + with _orphan_lock: + last = _last_orphan_sweep.get(home) + if last is not None and now - last < ORPHAN_SWEEP_INTERVAL_S: + return 0 + _last_orphan_sweep[home] = now + try: + return sweep_orphaned_completions(target_queue) + except Exception: + logger.debug("Orphaned async delegation sweep failed", exc_info=True) + return 0 + + def _update_delivery(sql: str, params: tuple) -> bool: """Run one UPDATE on the ledger; True iff exactly one row changed.""" with _DB_LOCK, _transaction() as conn: @@ -344,7 +443,7 @@ def claim_completion_delivery(delegation_id: str, claim_id: str) -> bool: delivery_attempts=delivery_attempts+1, updated_at=? WHERE delegation_id=? AND delivery_state='pending' AND (delivery_claim IS NULL OR delivery_claimed_at < ?)""", - (claim_id, now, now, delegation_id, now - 300)) + (claim_id, now, now, delegation_id, now - _CLAIM_LEASE_S)) return cur.rowcount == 1 @@ -425,7 +524,22 @@ def complete_event_delivery(evt: Dict[str, Any], claim_id: str) -> None: def release_event_delivery(evt: Dict[str, Any], claim_id: str) -> None: + """Release a failed claim for a consumer that discards its copy (the TUI poller): the row is pending + again, so it must stay eligible for the orphan sweep.""" _event_delivery(release_completion_delivery, evt, claim_id) + return_completion_offer(evt) + + +def return_completion_offer(evt: Dict[str, Any]) -> None: + """Hand an offered completion back to the orphan sweep after its in-memory copy was discarded while + the durable row stays pending, e.g. a TUI session that cannot prove it owns the event drops it (every + session poller drains one process-wide queue). The next sweep may offer the row again. Delegation ids + are unique across profiles, so this clears the offer in every home.""" + delegation_id = str(evt.get("delegation_id") or "") if evt.get("type") == "async_delegation" else "" + if not delegation_id or is_interim_delegation_event(evt): + return + with _orphan_lock: + _offered.difference_update({key for key in _offered if key[1] == delegation_id}) def _event_delivery(fn, evt: Dict[str, Any], claim_id: str) -> None: @@ -1033,6 +1147,9 @@ def _reset_for_tests() -> None: thread.join(timeout=2) with _records_lock: _records.clear() + with _orphan_lock: + _offered.clear() + _last_orphan_sweep.clear() # ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ---- diff --git a/tools/tts_tool_lifecycle.py b/tools/tts_tool_lifecycle.py index 43341b1ba8..662cc6f8f3 100644 --- a/tools/tts_tool_lifecycle.py +++ b/tools/tts_tool_lifecycle.py @@ -2,9 +2,10 @@ Local engines load lazily on first synthesis (dead air on the first spoken reply) and then stay resident. Every surface that flips speech output on holds a *lease* here (warming the configured -engine); when the last lease is released the local model caches are dropped, so one surface's -"off" can't unload a model another surface still needs. Cloud providers have nothing resident; -warming only ensures the SDK imports. Origin seams (``_load_tts_config``, ``_get_provider``) are +engine); when the last lease is released the local model caches are dropped after a keep-warm +window (``tts.keep_warm_seconds``), so one surface's "off" can't unload a model another surface +still needs and a wake-word loop that re-acquires within the window reuses the loaded model. +Cloud providers have nothing resident; warming only ensures the SDK imports. Origin seams (``_load_tts_config``, ``_get_provider``) are resolved through :func:`_origin` per call. """ @@ -15,6 +16,7 @@ import threading import time from typing import Any, Callable, Dict, List, Optional +from agent.memory_provider import ctx_bound from tools import tts_command_provider from tools.tts_command_provider import ( BUILTIN_TTS_PROVIDERS, _get_command_tts_timeout, _get_named_provider_config, @@ -30,6 +32,12 @@ logger = logging.getLogger("tools.tts_tool") _tts_lease_lock = threading.Lock() _tts_leases: set = set() +# Pending keep-warm unload; the generation lets a timer that already woke up see it was superseded. +_DEFAULT_KEEP_WARM_SECONDS = 60.0 +_make_keep_warm_timer = threading.Timer # test seam +_keep_warm_timer: Optional[threading.Timer] = None +_keep_warm_generation = 0 + def _local_tts_warmers() -> Dict[str, Callable[[Dict[str, Any]], Any]]: """Provider name → loader populating that engine's cache slot (same key synthesis uses).""" @@ -140,22 +148,74 @@ def release_tts_provider(provider: Optional[str] = None) -> Dict[str, Any]: return {"released": released} +def _keep_warm_seconds() -> float: + """``tts.keep_warm_seconds``, read per call so each profile's config applies; ``0`` = unload now.""" + raw = _origin()._load_tts_config().get("keep_warm_seconds", _DEFAULT_KEEP_WARM_SECONDS) + try: + return max(0.0, float(raw)) + except (TypeError, ValueError): + return _DEFAULT_KEEP_WARM_SECONDS + + +def _cancel_keep_warm_locked() -> None: + global _keep_warm_timer, _keep_warm_generation + _keep_warm_generation += 1 + if _keep_warm_timer is not None: + _keep_warm_timer.cancel() + _keep_warm_timer = None + + +def _schedule_release_locked() -> int: + """No holders left: (re)start the keep-warm window, or unload inline when it is 0. + Returns the count released inline.""" + global _keep_warm_timer + _cancel_keep_warm_locked() + delay = _keep_warm_seconds() + if delay <= 0: + return release_tts_provider()["released"] + # ctx_bound: the unload reads config/secrets under the releasing caller's profile scope. + timer = _make_keep_warm_timer( + delay, ctx_bound(_release_after_keep_warm), args=(_keep_warm_generation,)) + timer.daemon = True + _keep_warm_timer = timer + timer.start() + return 0 + + +def _release_after_keep_warm(generation: int) -> None: + global _keep_warm_timer + with _tts_lease_lock: + if generation != _keep_warm_generation or _tts_leases: + return + _keep_warm_timer = None + release_tts_provider() + + def acquire_tts_lease(lease: str, tts_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Register ``lease`` (e.g. ``"desktop:read-aloud"``) and warm the provider. Re-acquiring is - idempotent but still re-warms (cheap on a cache hit; heals a cache cleared elsewhere).""" + idempotent but still re-warms (cheap on a cache hit; heals a cache cleared elsewhere). An + acquire inside the keep-warm window cancels the pending unload.""" with _tts_lease_lock: + _cancel_keep_warm_locked() _tts_leases.add(lease) holders = len(_tts_leases) - return {**warm_tts_provider(tts_config), "leases": holders} + result = warm_tts_provider(tts_config) + with _tts_lease_lock: + # Every holder released while the load ran: the window starts now that the model is resident. + if not _tts_leases: + _schedule_release_locked() + return {**result, "leases": holders} def release_tts_lease(lease: str) -> Dict[str, Any]: - """Drop ``lease``; the last one out unloads resident local models. A never-acquired lease is a - no-op (still reports the holder count) so surfaces can call this unconditionally.""" + """Drop ``lease``; the last one out unloads resident local models once the keep-warm window + passes with no new acquire. A never-acquired lease is a no-op (still reports the holder count) + so surfaces can call this unconditionally. ``released`` counts models unloaded inline.""" with _tts_lease_lock: + held = lease in _tts_leases _tts_leases.discard(lease) holders = len(_tts_leases) - released = release_tts_provider()["released"] if holders == 0 else 0 + released = _schedule_release_locked() if held and holders == 0 else 0 return {"leases": holders, "released": released} @@ -167,4 +227,5 @@ def tts_lease_holders() -> List[str]: def _reset_tts_leases_for_tests() -> None: with _tts_lease_lock: + _cancel_keep_warm_locked() _tts_leases.clear() diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 5104665a4e..423f6464b5 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -2148,6 +2148,10 @@ def _correction_method(name: str, verb: str, accepted_status: str, supported, un return _ok(rid, {"status": "queued", "text": text}) if not supported(agent): return _err(rid, 4010, unsupported) + # An idle agent accepts steer() but only the next turn drains it, spliced after an old tool + # row (#64578). 'rejected' makes the client queue it as a normal next prompt. + if verb == "steer" and not session.get("running"): + return _ok(rid, {"status": "rejected", "text": text}) return _apply_correction(rid, session, verb, text, accepted_status) diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index 7d835adbf3..956d47450f 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -778,13 +778,17 @@ def _cmd_retry(rid, params, session, name, arg): def _cmd_steer(rid, params, session, name, arg): if not arg: return _err(rid, 4004, "usage: /steer ") - agent = session.get("agent") if session else None + shown = f"{arg[:80]}{'...' if len(arg) > 80 else ''}" + # An idle agent still accepts steer(), but nothing drains it until the NEXT turn's pre-API + # drain, which splices it after whatever tool row is newest (#64578). Idle → a normal message. + if not (session and session.get("running")): + return _ok(rid, {"type": "send", "message": arg, "notice": f"No agent running; sent as next turn: {shown}"}) + agent = session.get("agent") if agent and hasattr(agent, "steer"): with contextlib.suppress(Exception): if agent.steer(arg): - shown = f"{arg[:80]}{'...' if len(arg) > 80 else ''}" return _exec_out(rid, f"⏩ Steer queued — arrives after the next tool call: {shown}") - return _ok(rid, {"type": "send", "message": arg}) # no active run: next-turn message + return _ok(rid, {"type": "send", "message": arg}) # turn still building / steer refused: next-turn message def _cmd_goal(rid, params, session, name, arg): diff --git a/tui_gateway/session_notifications.py b/tui_gateway/session_notifications.py index 74818a4c3f..326f912ec1 100644 --- a/tui_gateway/session_notifications.py +++ b/tui_gateway/session_notifications.py @@ -501,9 +501,15 @@ def _notif_handle_event(sid, session, evt, emitted, registry, fmt, deferred, com if not owned and _notification_event_requires_owner(evt) and not _session_owns_notification_event(sid, session, evt): origin, key = str(evt.get("origin_ui_session_id") or ""), str(evt.get("session_key") or "") if deferred is None: - (logger.warning if is_delegation else logger.debug)( + # A durable replay stays pending: hand it back so the orphan sweep re-offers it once its owner + # is live (#97202), and keep that retry out of WARNING. + restored = is_delegation and bool(evt.get("restored")) + (logger.warning if is_delegation and not restored else logger.debug)( "Dropping unowned %s notification (origin=%r key=%r) instead of delivering to session %s", evt_type, origin, key, sid) + if is_delegation: + from tools.async_delegation import return_completion_offer + return_completion_offer(evt) elif is_delegation: deferred.append(evt) else: @@ -690,6 +696,7 @@ def _notification_poller_scoped_loop(stop_event: threading.Event, sid: str, sess subscriptions and delivers terminal task events the same way (status.update + agent turn) — the delivery path tools/kanban_tools.py documents for platform="tui" rows (issue #59890). """ + from tools import async_delegation from tools.process_registry import process_registry from tools.process_registry_notifications import format_process_notification queue = process_registry.completion_queue @@ -699,6 +706,8 @@ def _notification_poller_scoped_loop(stop_event: threading.Event, sid: str, sess last_kanban_poll = last_loop_poll = last_bot_poll = 0.0 while not stop_event.is_set() and not session.get("_finalized"): now = time.monotonic() + # Completions whose owner process died after this one started (#97202); throttled per profile home. + async_delegation.maybe_sweep_orphaned_completions(queue) if now - last_bot_poll >= _BOT_DELIVERY_POLL_SECONDS: # bot DM → live-owner delivery latency ≤ 5 s last_bot_poll = now _poll_bot_live_delivery_guarded(sid, session, now) diff --git a/ui-tui/src/__tests__/steerCommand.test.ts b/ui-tui/src/__tests__/steerCommand.test.ts new file mode 100644 index 0000000000..45a1ef889d --- /dev/null +++ b/ui-tui/src/__tests__/steerCommand.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' + +import { findSlashCommand } from '../app/slash/registry.js' + +const guarded = + (fn: (r: T) => void) => + (r: null | T) => { + if (r) { + fn(r) + } + } + +const runSteer = async (arg: string, steerResult: unknown, busy = true) => { + const sys = vi.fn() + const enqueue = vi.fn() + const rpc = vi.fn((_method: string, _params: unknown) => Promise.resolve(steerResult)) + + const ctx = { + composer: { enqueue }, + gateway: { rpc }, + guarded, + guardedErr: vi.fn(), + sid: 'sid-1', + transcript: { sys }, + ui: { busy } + } + + findSlashCommand('steer')!.run(arg, ctx as never, `/steer ${arg}`) + await rpc.mock.results[0]?.value + await Promise.resolve() + + return { enqueue, printed: sys.mock.calls.map(c => String(c[0])).join('\n'), rpc } +} + +describe('/steer', () => { + it('steers the live turn when the gateway accepts', async () => { + const { enqueue, printed, rpc } = await runSteer('check the logs', { status: 'queued', text: 'check the logs' }) + + expect(rpc).toHaveBeenCalledWith('session.steer', { session_id: 'sid-1', text: 'check the logs' }) + expect(enqueue).not.toHaveBeenCalled() + expect(printed).toContain('steer queued') + }) + + // #64578: the turn can end between the client's busy check and the RPC; the gateway then + // answers 'rejected'. The text must fall back to the next-turn queue, not vanish. + it('queues the text for the next turn when the gateway rejects the steer', async () => { + const { enqueue, printed } = await runSteer('check the logs', { status: 'rejected', text: 'check the logs' }) + + expect(enqueue).toHaveBeenCalledWith('check the logs') + expect(printed).toContain('queued for next turn') + }) +}) diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 794457a168..1f4d6a3b8e 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -712,7 +712,9 @@ export const coreCommands: SlashCommand[] = [ `steer queued — arrives after next tool call: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"` ) } else { - ctx.transcript.sys('steer rejected') + // The turn ended before the steer landed (#64578): keep the words as the next turn. + ctx.composer.enqueue(payload) + ctx.transcript.sys('steer rejected — no active turn, queued for next turn') } }) ) diff --git a/web/src/components/HermesConsoleModal.tsx b/web/src/components/HermesConsoleModal.tsx index 069dd8a8e0..322e8fd19a 100644 --- a/web/src/components/HermesConsoleModal.tsx +++ b/web/src/components/HermesConsoleModal.tsx @@ -12,6 +12,10 @@ import { useModalBehavior } from "@/hooks/useModalBehavior"; import { useProfileScope } from "@/contexts/useProfileScope"; import { api } from "@/lib/api"; import { maybeReloadForLoopbackWsAuthFailure } from "@/lib/dashboard-auth-reload"; +import { + refitWhenTerminalFontLoads, + TERMINAL_FONT_FAMILY, +} from "@/lib/terminal-font-refit"; import { cn, themedBody } from "@/lib/utils"; import { useTheme } from "@/themes"; import { errorMessage } from "@/lib/api-error"; @@ -351,8 +355,7 @@ export function HermesConsoleModal({ open, onClose }: HermesConsoleModalProps) { const term = new XtermTerminal({ allowProposedApi: true, cursorBlink: true, - fontFamily: - "'JetBrains Mono', 'Cascadia Mono', 'Fira Code', 'MesloLGS NF', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace", + fontFamily: TERMINAL_FONT_FAMILY, fontSize: 13, lineHeight: 1.25, letterSpacing: 0, @@ -394,6 +397,7 @@ export function HermesConsoleModal({ open, onClose }: HermesConsoleModalProps) { const ro = new ResizeObserver(scheduleFit); ro.observe(host); scheduleFit(); + const stopFontRefit = refitWhenTerminalFontLoads(term, fitTerminal); const dataDisposable = term.onData(handleInputData); setConnectionState("connecting"); @@ -462,6 +466,7 @@ export function HermesConsoleModal({ open, onClose }: HermesConsoleModalProps) { dataDisposable.dispose(); ro.disconnect(); if (resizeFrame) cancelAnimationFrame(resizeFrame); + stopFontRefit(); wsRef.current?.close(); wsRef.current = null; term.dispose(); diff --git a/web/src/lib/terminal-font-refit.test.ts b/web/src/lib/terminal-font-refit.test.ts new file mode 100644 index 0000000000..ece0b5d2c3 --- /dev/null +++ b/web/src/lib/terminal-font-refit.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + refitWhenTerminalFontLoads, + TERMINAL_FONT_FAMILY, +} from "./terminal-font-refit"; + +/** Mirrors xterm: the cell is re-measured only when fontFamily changes. */ +function fakeTerminal() { + let family = TERMINAL_FONT_FAMILY; + const term = { + measuredWith: [] as string[], + options: { + get fontFamily() { + return family; + }, + set fontFamily(next: string) { + if (next === family) return; + family = next; + term.measuredWith.push(next); + }, + }, + rows: 24, + clearTextureAtlas: vi.fn(), + refresh: vi.fn(), + }; + return term; +} + +function deferredFontSet(opts: { loaded?: boolean; faces?: unknown[] } = {}) { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + return { + release, + fontSet: { + check: vi.fn(() => opts.loaded ?? false), + load: vi.fn(async () => { + await gate; + return (opts.faces ?? [{}]) as FontFace[]; + }), + }, + }; +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("refitWhenTerminalFontLoads", () => { + it("re-measures with the loaded face, then refits and redraws", async () => { + const term = fakeTerminal(); + const fit = vi.fn(); + const { fontSet, release } = deferredFontSet(); + + refitWhenTerminalFontLoads(term, fit, fontSet); + await settle(); + expect(fit).not.toHaveBeenCalled(); + + release(); + await settle(); + + expect(term.measuredWith.at(-1)).toBe(TERMINAL_FONT_FAMILY); + expect(term.options.fontFamily).toBe(TERMINAL_FONT_FAMILY); + expect(fit).toHaveBeenCalledTimes(1); + expect(term.clearTextureAtlas).toHaveBeenCalledTimes(1); + expect(term.refresh).toHaveBeenCalledWith(0, 23); + }); + + it("does nothing after cleanup runs before the font arrives", async () => { + const term = fakeTerminal(); + const fit = vi.fn(); + const { fontSet, release } = deferredFontSet(); + + const cleanup = refitWhenTerminalFontLoads(term, fit, fontSet); + cleanup(); + release(); + await settle(); + + expect(term.measuredWith).toEqual([]); + expect(fit).not.toHaveBeenCalled(); + }); + + it("skips the refit when the faces were already loaded at open()", async () => { + const term = fakeTerminal(); + const fit = vi.fn(); + const { fontSet } = deferredFontSet({ loaded: true }); + + refitWhenTerminalFontLoads(term, fit, fontSet); + await settle(); + + expect(fontSet.load).not.toHaveBeenCalled(); + expect(fit).not.toHaveBeenCalled(); + }); + + it("leaves fallback metrics alone when no bundled face could load", async () => { + const term = fakeTerminal(); + const fit = vi.fn(); + const { fontSet, release } = deferredFontSet({ faces: [] }); + + refitWhenTerminalFontLoads(term, fit, fontSet); + release(); + await settle(); + + expect(term.measuredWith).toEqual([]); + expect(fit).not.toHaveBeenCalled(); + }); + + it("is a no-op without a FontFaceSet", () => { + const fit = vi.fn(); + expect(() => refitWhenTerminalFontLoads(fakeTerminal(), fit, undefined)()).not.toThrow(); + expect(fit).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/lib/terminal-font-refit.ts b/web/src/lib/terminal-font-refit.ts new file mode 100644 index 0000000000..2e63c13352 --- /dev/null +++ b/web/src/lib/terminal-font-refit.ts @@ -0,0 +1,71 @@ +/** + * Dashboard xterm font stack. 'JetBrains Mono' is bundled via @font-face in + * index.css with `font-display: swap`, so it is usually still downloading + * when a terminal first opens. + */ +export const TERMINAL_FONT_FAMILY = + "'JetBrains Mono', 'Cascadia Mono', 'Fira Code', 'MesloLGS NF', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace"; + +const BUNDLED_FACES = ["400", "700", "italic 400"].map( + (descriptor) => `${descriptor} 1em 'JetBrains Mono'`, +); + +type TerminalFontSet = Pick; + +export interface RemeasurableTerminal { + options: { fontFamily?: string }; + rows: number; + clearTextureAtlas(): void; + refresh(start: number, end: number): void; +} + +function browserFontSet(): TerminalFontSet | undefined { + return typeof document === "undefined" ? undefined : document.fonts; +} + +/** + * xterm measures its cell size once at open() and afterwards only when + * fontFamily/fontSize *change* or the grid resizes. When the bundled font + * swaps in later, the grid keeps fallback-font metrics (and the WebGL atlas + * keeps fallback glyphs) until something resizes the host, e.g. toggling the + * sidebar (#92899). Once the bundled faces load, force a re-measure, refit, + * and redraw. Returns a cleanup that drops a still-pending load. + */ +export function refitWhenTerminalFontLoads( + term: RemeasurableTerminal, + fit: () => void, + fontSet: TerminalFontSet | undefined = browserFontSet(), +): () => void { + if (!fontSet?.load) return () => undefined; + try { + if (BUNDLED_FACES.every((face) => fontSet.check(face))) { + return () => undefined; + } + } catch { + /* check() throws on unparsable descriptors in some engines; just load */ + } + + let cancelled = false; + void Promise.allSettled( + BUNDLED_FACES.map((face) => Promise.resolve().then(() => fontSet.load(face))), + ).then((results) => { + if (cancelled) return; + const loaded = results.some( + (r) => r.status === "fulfilled" && r.value.length > 0, + ); + if (!loaded) return; + + // A same-value assignment is a no-op in xterm, so bounce through a + // generic family to make it re-measure against the loaded face. + const family = term.options.fontFamily ?? TERMINAL_FONT_FAMILY; + term.options.fontFamily = "monospace"; + term.options.fontFamily = family; + fit(); + term.clearTextureAtlas(); + if (term.rows > 0) term.refresh(0, term.rows - 1); + }); + + return () => { + cancelled = true; + }; +} diff --git a/web/src/pages/ChatPage.test.tsx b/web/src/pages/ChatPage.test.tsx index 035af07580..203c7ec9f8 100644 --- a/web/src/pages/ChatPage.test.tsx +++ b/web/src/pages/ChatPage.test.tsx @@ -43,6 +43,8 @@ class FakeTerminal { clearSelection() {} + clearTextureAtlas() {} + dispose() {} focus() {} @@ -579,6 +581,46 @@ describe("ChatPage side panel collapse", () => { // (that timer is set after `new WebSocket`). Without its own deadline the tab // strands on "connecting" with no retry. Mirrors the ChatSidebar events-feed // coverage in src/components/ChatSidebar.test.tsx. +describe("ChatPage bundled font swap-in", () => { + it("redraws the terminal with the bundled font once it finishes loading", async () => { + let releaseFont!: () => void; + const fontGate = new Promise((resolve) => { + releaseFont = resolve; + }); + Object.defineProperty(document, "fonts", { + configurable: true, + value: { + check: () => false, + load: async () => { + await fontGate; + return [{}]; + }, + }, + }); + const clearAtlas = vi.spyOn(FakeTerminal.prototype, "clearTextureAtlas"); + try { + const { default: ChatPage } = await import("./ChatPage"); + await render( + + + , + ); + expect(clearAtlas).not.toHaveBeenCalled(); + + await act(async () => { + releaseFont(); + await fontGate; + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(clearAtlas).toHaveBeenCalledTimes(1); + } finally { + clearAtlas.mockRestore(); + delete (document as { fonts?: unknown }).fonts; + } + }); +}); + describe("ChatPage PTY ticket connect deadline", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 2a97574dfa..dafdde133f 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -94,6 +94,10 @@ import { type PtyBannerAction, } from "@/lib/pty-close-copy"; import { ptyAttachToken } from "@/lib/pty-attach-token"; +import { + refitWhenTerminalFontLoads, + TERMINAL_FONT_FAMILY, +} from "@/lib/terminal-font-refit"; import { loseWebglContexts } from "@/lib/xterm-webgl-release"; import { PluginSlot } from "@/plugins"; import { useTheme } from "@/themes"; @@ -579,8 +583,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { const term = new Terminal({ allowProposedApi: true, cursorBlink: true, - fontFamily: - "'JetBrains Mono', 'Cascadia Mono', 'Fira Code', 'MesloLGS NF', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace", + fontFamily: TERMINAL_FONT_FAMILY, fontSize: terminalFontSizeForWidth(tierW0), lineHeight: terminalLineHeightForWidth(tierW0), letterSpacing: 0, @@ -1112,6 +1115,10 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { }); }); + // The rAF fits above still measure the fallback font if JetBrains Mono + // hasn't swapped in yet (#92899). + const stopFontRefit = refitWhenTerminalFontLoads(term, syncTerminalMetrics); + // WebSocket. In gated mode (``window.__HERMES_AUTH_REQUIRED__``) this // awaits a single-use ticket via /api/auth/ws-ticket before opening; // in loopback mode it resolves synchronously against the injected @@ -1607,6 +1614,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { if (hostSyncRaf) cancelAnimationFrame(hostSyncRaf); if (settleRaf1) cancelAnimationFrame(settleRaf1); if (settleRaf2) cancelAnimationFrame(settleRaf2); + stopFontRefit(); clearReconnectTimer(); clearConnectingTimer(); clearTicketTimer(); diff --git a/website/docs/developer-guide/desktop-plugin-sdk.md b/website/docs/developer-guide/desktop-plugin-sdk.md index 96452e7ebf..57a297700c 100644 --- a/website/docs/developer-guide/desktop-plugin-sdk.md +++ b/website/docs/developer-guide/desktop-plugin-sdk.md @@ -407,10 +407,165 @@ plugin is the worked example (it is also a complete, installable disk plugin). ### Composer extensions -`COMPOSER_AREAS` (`top`, `bottom`, `leading`, `actions`, `attachments`, -`middleware`) let a plugin add controls around the message composer, provide an -attachment source, or transform a draft before it is sent (`ComposerMiddleware` -with a `handler(draft) => draft | null`). +`COMPOSER_AREAS` (`top`, `bottom`, `underside`, `leading`, `actions`, +`attachments`, `middleware`) let a plugin add controls around the message +composer, provide an attachment source, or transform a draft before it is sent +(`ComposerMiddleware` with a `handler(draft) => draft | null`). `top` is a +banner strip above the input and `bottom` a row below the input grid, both +inside the composer chrome; `underside` is the floating strip BELOW the whole +composer with no chrome of its own — the seat for a suggestion pill or a status +hint that should sit outside the input frame (the next-prompt plugin renders its +"next prompt" pill there). + +### Composer draft API — read and write the live input + +For everything the composer areas can't do — put text INTO the input, replace +what's there, read the current draft, or send it — use `host.composer`. This +is the supported door; reaching for the ProseMirror DOM, `[data-composer-target]` +lookups, or synthetic `InputEvent`s is out of the plugin surface (catalog rule 8) +and breaks the moment the app's markup moves. Addressing: `null` = the composer +the user is typing in; a session id (stored or runtime) = that session's +composer, in the primary pane or a tile; `'new'` = the fresh draft that has no +session id yet. + +```javascript +import { host } from '@hermes/plugin-sdk' + +// Append to the active composer (modes: 'block' | 'inline' | 'prefix'; +// 'prefix' seats a slash command at the start). Acknowledged like setDraft: +// true when a mounted surface applied the text, false when the text is blank +// or no live surface answers for the address. +const inserted = await host.composer.insertText(null, 'draft note', { mode: 'inline' }) + +// Replace a session's whole draft — '@'-ref and '/command' tokens hydrate +// into chips exactly like an official paste. False when no mounted surface +// answers (an unmounted session is never half-written). +const ok = await host.composer.setDraft('sess-1', 'plan:\n- @file:src/app.ts') + +// Read the live draft: the mounted surface's in-DOM text (unsaved keystrokes +// included), falling back to the debounced persisted stash. Null when nothing +// holds it. +const text = await host.composer.getDraft('sess-1') + +// Send as if the user typed + pressed Enter. Fail-closed like the app's own +// panels: no visible surface for the address → false, never a broadcast. +const sent = host.composer.submit('sess-1', 'ship it') + +// Put the caret in a composer (same addressing). insertText/setDraft already +// focus a visible surface they paint; use this to return the caret after a +// plugin popover closes or from a "go to input" keybind. +host.composer.focus(null) +``` + +```ts +host.composer: { + getDraft(sessionId: string | null): Promise + setDraft(sessionId: string | null, text: string): Promise + insertText(sessionId: string | null, text: string, opts?: { mode?: 'block' | 'inline' | 'prefix' }): Promise + submit(sessionId: string | null, text: string): boolean + focus(sessionId: string | null): void +} +``` + +**Arbitration.** Every verb is fail-closed on its address: a request is +answered only by the mounted composer that owns that session (its tile, or the +primary pane when it shows that session); `null` is answered only by the surface +the app's focus bus currently routes to; `'new'` only by the primary pane while it +shows no session — it never falls through to the active composer. No exact +surface → `null`/`false`, never +a broadcast into whichever pane happens to be mounted. Writes go through the +app's own paint path, so `@`-ref / `/`-command tokens hydrate as chips and the +result is byte-for-byte what the user would get by pasting. These are discrete, +user-triggered actions with the same authority as typing — no plugin "owns" the +draft afterwards, so there is **nothing to tear down** on disable; a plugin that +wants a persistent presence around the input uses a `COMPOSER_AREAS` slot instead. + +A multi-session plugin keeps its per-session state on its side (which session +its panel is editing) and passes that id here; the bus guarantees one +plugin write can never land in another session's composer. + +**Migrating off DOM reach-in** (the held catalog plugins that motivated this API): + +| Plugin | Was | Now | +|---|---|---| +| next-prompt (#120660) | `window.dispatchEvent(new CustomEvent('hermes:composer-insert', …))` + a `setTimeout` `hermes:composer-focus`; `[data-composer-target]`/`[data-pane-hidden]` scan for the visible target | `await host.composer.insertText(null, suggestion.text, { mode: 'block' })`, then `host.composer.focus(null)` if the pill lost the caret | +| prompt-snippets (#116030) | same `hermes:composer-insert` event; `[data-slot="composer-input"]`/ProseMirror `textContent` + synthetic `InputEvent` fallback; `surfaceEditorEl().focus()` | `host.composer.insertText(sid, text, { mode: 'block' })`; `setDraft(sid, (await getDraft(sid) ?? '') + '\n' + text)` replaces the fallback; `host.composer.focus(sid)` — `sid = host.state.focusedSessionId.get()` | +| prompt-enhancer (#116031) | walks the editor's child nodes to serialize, rebuilds chip DOM, `replaceChildren` + synthetic `InputEvent` | `const draft = await host.composer.getDraft(sid)` → transform → `await host.composer.setDraft(sid, enhanced)` (chips hydrate app-side); revert is another `setDraft` | +| memory-review (#115966) | `host.request('slash.exec', { session_id, command })` for `/memory …` — already SDK-only | optional: `host.composer.insertText(sid, '/memory pending', { mode: 'prefix' })` to seat the command for the user instead of executing it | +| intelligent-tool-break (#115964) | "Message" button only toasts "type /break" (no composer write) | `host.composer.setDraft(host.state.focusedSessionId.get(), '/break ')` then `host.composer.focus(null)` restores the intended behaviour | + +`sessionId` in the table is the id the plugin's UI is bound to; for a composer +slot render it is `host.state.focusedSessionId.get()`. + +### Session rows — decorations + the session list API + +`SESSION_ROW_AREAS` (`leading`, `trailing`) let a plugin decorate sidebar +session rows. Register a `data` contribution whose `render({ sessionId })` +returns a small element (a badge, a swatch, a tag) or `null` for rows you don't +own — registering costs nothing on every other row: + +```ts +import { SESSION_ROW_AREAS, type SessionRowSlotContribution } from '@hermes/plugin-sdk' + +ctx.register({ + area: SESSION_ROW_AREAS.trailing, + id: 'my-tag', + data: { + render: ({ sessionId }) => (owned.has(sessionId) ? ★ : null) + } satisfies SessionRowSlotContribution +}) +``` + +Pair it with the session list API, which writes the same stores the app's own +controls write (so a plugin action and a hand click can never disagree): + +```ts +host.sessions.pin(storedSessionId: string, pinned?: boolean, index?: number): void +host.sessions.reorder(storedSessionIds: string[]): void // Recents; [] = clear manual order → default sort +host.sessions.reorderPinned(storedSessionIds: string[]): void // Pinned section; omitted pins keep their slot +host.sessions.setColor(storedSessionId: string, color: string | null): void +``` + +Ids are STORED session ids: a live id is resolved to its durable lineage root, +so pins and colours survive compression's id rotation — and the row-decoration +slots hand your render that same durable id (`_lineage_root_id ?? id`), never +the live one. Resolution goes through the rows this window has loaded; an id +that matches no loaded row is written as given, so pass the slot's durable id +(not a live id you remembered) for a session that may have scrolled out of the +list. `reorder` accepts the same ids and maps each to its row's live id +internally — the Recents order store is keyed by the live id, like the drag +path. `pin(id, true, index)` slots the pin at that position in the +Pinned list (a drop target between two pins); without `index` it appends, like +the row's ⇧-click. + +**Arbitration.** The verbs are discrete user-triggered edits of user data — +last write wins, exactly as if the user had clicked, and no plugin owns the +result afterwards. Slot contributions are ALL mounted (registration order, not +first-wins), each inside its own error boundary: a plugin that throws or +returns `null` for a row cannot suppress another plugin's decoration on it, and +two decorations on one row render side by side. Core keeps the row's layout, +gestures and title — slots augment, never replace. + +**Teardown.** The verbs need none. Slot contributions are removed by the +`ctx.register` disposer (disable/reload drops them and the row re-renders +without the decoration). + +Migration for the held catalog plugins: + +- **drag-to-pin-session** — replace the `__reactFiber$*` walk for `onTogglePin` + / `onReorderSessions` / `session._lineage_root_id` with the row's slot id + (`render: ({ sessionId }) => …` under `SESSION_ROW_AREAS.leading` gives you + the durable id per row), then `host.sessions.pin(sessionId, true, dropIndex)` + for a drop into the Pinned section, `host.sessions.pin(sessionId, false)` for + a drop back into Recents, `host.sessions.reorderPinned(ids)` for a drag + within the Pinned section, and `host.sessions.reorder([])` for its + "reset manual order" path. +- **better-session-appearance** — replace the `localStorage` + `hermes.desktop.sessionColors` write and the fiber-harvested `onChange` with + `host.sessions.setColor(sessionId, hex)` (`null` clears), and render its + per-row glyph through `SESSION_ROW_AREAS.leading` instead of mutating the + row's status dot (the durable id it needed from `_lineage_root_id` is the + slot's `sessionId`). ### Transcript directives — inline components the model addresses @@ -542,6 +697,11 @@ host.profileRoutes() // [{ profile, targetProfile, connect host.requestProfile(route, method, params?, timeoutMs?, { spawnPriority? }) // registry-routed RPC; no foreground swap host.requestProfile(profile, method, params?) // legacy v1/local overload host.request(method, params?) // active-gateway JSON-RPC — the real power +host.sessions.pin(storedSessionId, pinned?, index?) // pin/unpin (default pinned=true); index = slot in Pinned; + // same store the row's ⇧-click / drop writes +host.sessions.reorder(ids) // replace the manual Recents order (what a drag persists); [] resets +host.sessions.reorderPinned(ids) // permute the Pinned section (the pinned drag path) +host.sessions.setColor(storedSessionId, color | null) // per-session colour override; null clears ``` `host.request` is the same JSON-RPC the app itself uses (sessions, config, skills, @@ -662,6 +822,81 @@ The other doors (`openExternal`, `revealPath`, `writeClipboard`) resolve `false` instead of throwing when the capability isn't available (older desktop shell, plain browser) — branch on the result rather than sniffing the bridge. +### Desktop appearance settings — `host.settings` + +`host.settings` is the supported door for the small set of Desktop-local +appearance preferences plugins may share with the native Settings page. Every +key is bound to the store atom + setter the Settings page itself uses, so a +plugin write is exactly a user click on that control: it takes effect at once, +persists through the preference's existing storage schema, and the last write +wins (no plugin "owns" the value afterwards, nothing to tear down for `set`). + +```ts +type DesktopSettingValues = { + 'backdrop.v1': boolean + 'composerPopout.gesturesEnabled': boolean + 'intro-splash.v1': boolean + 'reasoning.collapsedByDefault': boolean + sessionListDensity: 'compact' | 'comfortable' | 'detailed' + tabStripDefault: 'auto' | 'always' | 'never' +} +host.settings.get(key: K): DesktopSettingValues[K] +host.settings.set(key: K, value: DesktopSettingValues[K]): void +host.settings.subscribe(key: K, fn: (value: DesktopSettingValues[K]) => void): () => void +``` + +```ts +register(ctx) { + host.settings.set('sessionListDensity', 'detailed') + + // subscribe emits the current value now, then after every native or plugin write. + const dispose = host.settings.subscribe('backdrop.v1', enabled => { /* … */ }) + // Teardown rule: `host` is a module singleton and cannot tell which plugin + // subscribed, so YOU retire the listener — otherwise it outlives a disable/reload. + ctx.onDispose(dispose) +} +``` + +Arbitration: the allowlist above is closed. An unknown key or a value outside +the key's type throws **synchronously** (`Unsupported desktop setting: …` / +`Invalid value for desktop setting: …`) and nothing is written — `host.settings` +never touches `localStorage` directly, so it cannot bypass a store's schema or +migration. Feature-detect `host.settings` when supporting older Desktop builds. + +Deliberately **not** keys, and why: + +| Wanted | Use instead | Why not a raw key | +|--------|-------------|-------------------| +| keybind map (`hermes.desktop.keybinds`) | `KEYBINDS_AREA` contribution | a raw map write rebinds every other plugin's shortcuts; the area merges per plugin and is torn down with it | +| active theme / mode record | `THEMES_AREA` (register a theme; the user selects it) | theme selection is per window/profile and arbitrated by the app, not a flat preference | +| `pluginDecisions` (desktop plugin on/off) | the app's Plugins tab (a read-only view is a separate SDK hook) | a plugin toggling another plugin's enable state is plugins interfering with each other | +| `toolView.technical`, `embed-mode`, `titlebarAppActions`, `translucency.v2`, `user-bubble-transparency.v1`, `hermesDesktop.zoom.*` | follow-up keys after each store is audited | some drive the main process or window chrome; each needs its own guard and ownership review before it becomes plugin-writable | + +Migration — `hermes-appearance-hub`, which today does +`localStorage.setItem('hermes.desktop.sessionListDensity', id)` followed by +`window.dispatchEvent(new StorageEvent('storage', …))` to wake the app's store +(`readSimpleKey`/`writeSimpleKey`, `readBoolKey`/`writeBoolKey`): + +```ts +// before +localStorage.setItem('hermes.desktop.backdrop.v1', String(on)) +window.dispatchEvent(new StorageEvent('storage', { key: 'hermes.desktop.backdrop.v1', newValue: String(on) })) +// after — the store notifies its own subscribers; no synthetic StorageEvent +host.settings.set('backdrop.v1', on) +host.settings.set('sessionListDensity', id) // was hermes.desktop.sessionListDensity +host.settings.set('tabStripDefault', id) // was hermes.desktop.tabStripDefault +host.settings.set('reasoning.collapsedByDefault', on) // was hermes.desktop.reasoning.collapsedByDefault +host.settings.set('composerPopout.gesturesEnabled', on) +host.settings.set('intro-splash.v1', mode !== 'off') // replaces clicking #setting-field-appearance.intro-splash +``` + +Reads become `host.settings.get(key)`; its `MutationObserver` on the Settings +page's intro-splash switch becomes `host.settings.subscribe('intro-splash.v1', fn)` +(disposer → `ctx.onDispose`). `prompt-snippets` reads +`localStorage.getItem('hermes.desktop.keybinds')` to back up its shortcut — that +is the keybind-map row above: contribute the default through `KEYBINDS_AREA` and +keep the user's override in `ctx.storage`, not in the app's map. + ## Data layer — React Query + nanostores Plugins share the app's single `QueryClient`, so plugin queries cache, dedupe, @@ -966,10 +1201,10 @@ pipeline as a trust boundary. | Category | Exports | |----------|---------| -| Host | `host` (`.state.*`, `.notify`, `.notifyError`, `.navigate`, `.onEvent`, `.logs`, `.status`, `.restartGateway`, `.request`) | +| Host | `host` (`.state.*`, `.settings`, `.notify`, `.notifyError`, `.navigate`, `.onEvent`, `.logs`, `.status`, `.restartGateway`, `.request`, `.composer`, `.sessions`) | | Plugin contract | `HermesPlugin`, `PluginContext`, `PluginContribution`, `PluginStorage`, `PluginOs`, `PluginRestOptions`, `PluginNativeNotificationInput`, `PluginNotificationAction`, `HermesOpenTarget`, `Contribution` | -| Area constants | `PANES_AREA`, `ROUTES_AREA`, `SIDEBAR_NAV_AREA`, `STATUSBAR_AREAS`, `TITLEBAR_AREAS`, `WORKSPACE_PAGE_HEADER_AREA`, `PALETTE_AREA`, `KEYBINDS_AREA`, `THEMES_AREA`, `COMPOSER_AREAS` | -| Area payloads | `RouteContribution`, `SidebarNavContribution`, `StatusbarItem`, `TitlebarTool`, `PaletteContribution`, `KeybindContribution`, `ComposerMiddleware`, `ComposerAttachmentProvider` | +| Area constants | `PANES_AREA`, `ROUTES_AREA`, `SIDEBAR_NAV_AREA`, `STATUSBAR_AREAS`, `TITLEBAR_AREAS`, `WORKSPACE_PAGE_HEADER_AREA`, `PALETTE_AREA`, `KEYBINDS_AREA`, `THEMES_AREA`, `COMPOSER_AREAS`, `SESSION_ROW_AREAS` | +| Area payloads | `RouteContribution`, `SidebarNavContribution`, `StatusbarItem`, `TitlebarTool`, `PaletteContribution`, `KeybindContribution`, `ComposerMiddleware`, `ComposerAttachmentProvider`, `SessionRowSlotContribution` | | React / state | `useValue`, `atom`, `computed`, `useQuery`, `useMutation`, `useQueryClient`, `queryClient`, `Contribute` | | Theming | `useTheme`, `requestTheme`, `setAccentOverride`, `$accentOverride`, `retintTheme`, `themeHue`, `DesktopTheme`, `DesktopThemeColors`, plus OKLCH math (`hexToOklch`, `oklchToHex`, `oklchToSrgb255`, `mixOklab`, `maxChroma`, `hueDelta`, `normalizeHex`) and sRGB measures (`contrastRatio` — `number | null`, null for unparseable input — `readableOn`) | | UI kit | `Button`, `Input`, `Textarea`, `Select*`, `Switch`, `Checkbox`, `SegmentedControl`, `Tabs*`, `Dialog*`, `ConfirmDialog`, `DropdownMenu*`, `ContextMenu*`, `Popover*`, `Tip`/`Tooltip*`, `Badge`, `Kbd`/`KbdGroup`, `SearchField`, `ScrollArea`, `Separator`, `Skeleton`, `GlyphSpinner`, `Loader`, `EmptyState`, `ErrorState`, `CopyButton`, `StatusDot`, `LogView`, `Codicon`, `DecodeText` | diff --git a/website/docs/getting-started/updating.md b/website/docs/getting-started/updating.md index 9d8da85505..0d4e14d021 100644 --- a/website/docs/getting-started/updating.md +++ b/website/docs/getting-started/updating.md @@ -187,6 +187,10 @@ If you *deliberately* run a custom branch (local patches maintained on top of ma When the parked branch has **uncommitted changes** (dirty tree), Hermes does **not** touch it. The code update is marked **SKIPPED** with a loud warning naming the branch, how far behind `origin/main` it is, and the exact commands to resolve — instead of pretending the update succeeded. The completion line always shows the actual branch and HEAD (`✓ Update complete! [main @ 30fcf9580]`) so drift is visible at a glance. Set `updates.auto_switch_parked_branch: false` in `config.yaml` to disable the auto-switch entirely (the skip warning still fires). +### Local commits on the target branch + +Commits made directly on the update target (`main`) stop fast-forwards once upstream moves, and the checkout cannot tell them apart from an upstream force-push, so the update resets `main` to `origin/main`. Before the reset it saves the old HEAD as `refs/hermes-update-backups/diverged-main--` and prints that ref along with how many commits leave the branch. `git log origin/main..` lists them; `git branch ` or `git cherry-pick` brings them back. Re-running the installer over an existing checkout (`install.sh` / `install.ps1`, which desktop bootstrap does) writes the same refs. Whenever `hermes update` writes one, it keeps the ten newest per kind and drops any older than 30 days. To carry patches across updates, keep them on a custom branch with `updates.parked_branch_strategy: update_in_place` instead. + ### Local changes on non-interactive updates When you run `hermes update` in a terminal, Hermes stashes any uncommitted source-tree changes, pulls, then **asks** whether to restore them — exactly as it always has. Nothing changes for interactive updates. diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index b5a1fd75ed..1883092fe0 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -2119,6 +2119,7 @@ Legitimately slow work is not penalized: streaming responses, tool heartbeats (e tts: provider: "edge" # "edge" | "elevenlabs" | "openai" | "minimax" | "mistral" | "gemini" | "xai" | "neutts" | "kittentts" | "piper" | "deepinfra" speed: 1.0 # Global speed multiplier (fallback for all providers) + keep_warm_seconds: 60 # Keep a local engine loaded this long after the last speech toggle turns off (0 = unload at once) edge: voice: "en-US-AriaNeural" # 322 voices, 74 languages speed: 1.0 # Speed multiplier (converted to rate percentage, e.g. 1.5 → +50%) diff --git a/website/docs/user-guide/features/tts.md b/website/docs/user-guide/features/tts.md index 4912947a23..625a2b815c 100644 --- a/website/docs/user-guide/features/tts.md +++ b/website/docs/user-guide/features/tts.md @@ -277,7 +277,7 @@ Local engines (Piper, KittenTTS) load their model lazily, so without help the *f - **Desktop** — **Read replies aloud** is a desktop-local preference, independent of the gateway's `voice.auto_tts` setting in Settings → Voice. It migrates the shared value once, then later gateway configuration changes do not override the desktop toggle. If local storage is full or unavailable, the choice still lasts for this window; persistence across a reload remains best-effort. Turning on **Read replies aloud**, or starting a **voice conversation**, pre-loads the configured engine in the background right away. Turning both off again unloads the resident model (a Piper voice is tens of MB; KittenTTS up to ~80MB) so it isn't parked in RAM for nothing. - **CLI / TUI** — `/voice tts` (and `/voice on` when `voice.auto_tts` is set) do the same; `/voice off` releases. -Each toggle holds a *lease* on the engine; the model is only unloaded when the last lease across surfaces is released, so switching off read-aloud in one Desktop window never pulls the voice out from under a conversation running in another. For cloud providers there is no model to hold — the toggle only makes sure a lazily-installed SDK (edge-tts, ElevenLabs, Mistral) is present. Warm-up is best-effort: if the engine can't load, the toggle still succeeds and the first reply falls back to loading on demand as before. +Each toggle holds a *lease* on the engine; the model is only unloaded when the last lease across surfaces is released, so switching off read-aloud in one Desktop window never pulls the voice out from under a conversation running in another. The unload waits `tts.keep_warm_seconds` (default `60`) after the last release, and any toggle turning speech back on within that window keeps the loaded model, so a wake-word loop or a quickly restarted voice conversation doesn't reload the voice each time. Set it to `0` to unload immediately. For cloud providers there is no model to hold — the toggle only makes sure a lazily-installed SDK (edge-tts, ElevenLabs, Mistral) is present. Warm-up is best-effort: if the engine can't load, the toggle still succeeds and the first reply falls back to loading on demand as before. The Desktop calls `POST /api/audio/tts-lease` with `{"lease": "", "active": true|false}`; other frontends can use the same endpoint.