diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 5084f20e74..24eea03278 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -155,6 +155,8 @@ fill/shadow), `ghost`, `floating` (a control loose from any surface — opaque popover fill + `shadow-md`, hover lifts the glyph only), `link`, `text` (boxless quiet inline — "Cancel", "Clear"), `textStrong` (bold underlined inline affordance — "Change", "Open logs"). +`grip` is the quiet, fill-free drawer handle; pair it with size `grip` for a +48×16 hit area around a small horizontal ridge. **Sizes:** `default`, `xs`, `sm`, `lg`, `inline` (flush, zero box — for buttons that sit inside a heading/sentence; replaces `h-auto px-0 py-0`), `micro` @@ -347,6 +349,10 @@ so glass and message-bubble transparency do not reveal scrolling text. pause/resume preserve the user's disclosure choice. Error banners meet the stack's top edge without a blank padding strip. File and preview links remain visible at the bottom of the stack, below the queue and all status groups. + A centered ridge on the composer's top edge hides/reveals the entire stack, + including the git row, with a short downward/upward drawer slide. Its choice + persists per conversation and owner, not globally. Hidden sections stay + mounted but inert so their disclosure choices survive; reduced motion is instant. - Popping out a composer makes it the window's only visible composer. It keeps its viewport placement while hover or keyboard focus selects a chat pane; moving back into the editor retains that recipient. Drafts, attachments and diff --git a/apps/desktop/src/app/chat/composer/hooks/use-status-drawer.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-status-drawer.test.tsx new file mode 100644 index 0000000000..f414f7af50 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-status-drawer.test.tsx @@ -0,0 +1,31 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, expect, it, vi } from 'vitest' + +import { $collapsedStatusDrawers } from '@/store/composer-status-drawer' + +import { useStatusDrawer } from './use-status-drawer' + +vi.mock('@/store/session-states', () => ({ + knownOwnerForSession: () => ({ connectionId: 'local', profile: 'default', targetProfile: 'default' }) +})) + +beforeEach(() => $collapsedStatusDrawers.set([])) + +it('keeps stored conversations independent from drafts and other conversations', () => { + const { result, rerender } = renderHook(({ sessionKey }) => useStatusDrawer(sessionKey), { + initialProps: { sessionKey: null as string | null } + }) + + act(() => result.current.toggle()) + expect(result.current.collapsed).toBe(true) + + rerender({ sessionKey: 'existing-a' }) + expect(result.current.collapsed).toBe(false) + act(() => result.current.toggle()) + rerender({ sessionKey: 'existing-b' }) + expect(result.current.collapsed).toBe(false) + rerender({ sessionKey: 'existing-a' }) + expect(result.current.collapsed).toBe(true) + rerender({ sessionKey: null }) + expect(result.current.collapsed).toBe(false) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-status-drawer.ts b/apps/desktop/src/app/chat/composer/hooks/use-status-drawer.ts new file mode 100644 index 0000000000..27985d6221 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-status-drawer.ts @@ -0,0 +1,50 @@ +import { useStore } from '@nanostores/react' +import { useState } from 'react' + +import { useStoreSelector } from '@/lib/use-session-slice' +import { $collapsedStatusDrawers, setStatusDrawerCollapsed, statusDrawerKey } from '@/store/composer-status-drawer' +import { $activeConnectionId } from '@/store/connections' +import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' +import { isSessionOwnerRoute } from '@/store/session-request-router' +import { knownOwnerForSession } from '@/store/session-states' + +import { useComposerScope } from '../scope' + +export function useStatusDrawer(sessionKey: string | null) { + const scope = useComposerScope() + const activeProfile = useStore($activeGatewayProfile) + const activeConnection = useStore($activeConnectionId) + const owner = knownOwnerForSession(sessionKey) + const route = isSessionOwnerRoute(owner) ? owner : null + + const profile = normalizeProfileKey( + route?.profile ?? (typeof owner === 'string' ? owner : scope.profile ?? activeProfile) + ) + + const targetProfile = normalizeProfileKey(route?.targetProfile ?? profile) + const connectionId = route ? route.connectionId ?? null : owner ? null : scope.connectionId ?? activeConnection + const ownerKey = JSON.stringify([connectionId, profile, targetProfile]) + const key = sessionKey ? statusDrawerKey({ connectionId, profile, targetProfile, sessionId: sessionKey }) : null + const saved = useStoreSelector($collapsedStatusDrawers, keys => key !== null && keys.includes(key)) + const draftKey = key ?? ownerKey + const [draft, setDraft] = useState({ key: draftKey, collapsed: false }) + + // Unsaved drafts have no durable preference. Never carry their choice into + // a stored conversation selected from the sidebar or a subsequent new chat. + if (draft.key !== draftKey) { + setDraft({ key: draftKey, collapsed: false }) + } + + const collapsed = key ? saved : draft.key === draftKey && draft.collapsed + + return { + collapsed, + toggle: () => { + if (key) { + setStatusDrawerCollapsed(key, !collapsed) + } else { + setDraft({ key: draftKey, collapsed: !collapsed }) + } + } + } +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 249f8e5f72..9d2a953f1d 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -1,6 +1,15 @@ import { ComposerPrimitive } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useMemo, useRef } from 'react' +import { + type ClipboardEvent, + type FormEvent, + type KeyboardEvent, + useCallback, + useEffect, + useId, + useMemo, + useRef +} from 'react' import { useTourMarker } from '@/app/chat/tour-marker' import { useHudComposerDrag } from '@/app/hud/composer-drag' @@ -66,6 +75,7 @@ import { useComposerVoice } from './hooks/use-composer-voice' import { useEmojiCompletions } from './hooks/use-emoji-completions' import { useComposerMicroActions } from './hooks/use-micro-actions' import { useSlashCompletions } from './hooks/use-slash-completions' +import { useStatusDrawer } from './hooks/use-status-drawer' import { useSessionStatusPresence } from './hooks/use-status-presence' import { shouldConvertPasteToAttachment } from './large-paste' import { ActionBadges } from './micro-actions' @@ -84,6 +94,7 @@ import { import { useComposerScope, useComposerSurfaceId } from './scope' import { ComposerStatusStack } from './status-stack' import { CodingStatusRow } from './status-stack/coding-row' +import { StatusDrawerContent, StatusDrawerToggle } from './status-stack/drawer' import { SuggestionPills } from './suggestion-pills' import { extractClipboardImageBlobs, openDirectiveScope } from './text-utils' import { ComposerTriggerPopover } from './trigger-popover' @@ -193,6 +204,9 @@ export function ChatBar({ // undelivered behind the blocked tool batch). Drives the button affordance. const blockingPrompt = useStore(useMemo(() => sessionBlockingPrompt(sessionId ?? null), [sessionId])) const activeQueueSessionKey = queueSessionKey || sessionId || null + const { collapsed: statusDrawerCollapsed, toggle: toggleStatusDrawer } = useStatusDrawer(activeQueueSessionKey) + const statusDrawerId = useId() + const codingDrawerId = useId() // Status items (subagents, background processes) are keyed by the RUNTIME // session id — gateway events and process.list both speak that id. Only the @@ -1298,37 +1312,39 @@ export function ChatBar({ queue). An in-flow dock child: the dock is bottom-anchored, so it grows upward over the thread and the dock's own measurement covers it. Collapses to nothing when every status is empty. */} - 0 ? ( - { - if (removeQueuedPrompt(activeQueueSessionKey, id) && queueEdit?.entryId === id) { - exitQueuedEdit('cancel') - } - }} - onEdit={beginQueuedEdit} - onResume={() => { - unparkQueuedPrompts(activeQueueSessionKey) + + 0 ? ( + { + if (removeQueuedPrompt(activeQueueSessionKey, id) && queueEdit?.entryId === id) { + exitQueuedEdit('cancel') + } + }} + onEdit={beginQueuedEdit} + onResume={() => { + unparkQueuedPrompts(activeQueueSessionKey) - // Idle → kick the head immediately; busy → the settle drain - // takes over now that the park is lifted. - if (!busy) { - void drainNextQueued() - } - }} - onSendNow={id => void sendQueuedNow(id)} - onSteerNow={id => void steerQueuedNow(id)} - parked={queueParked} - /> - ) : null - } - sessionId={statusSessionId} - /> + // Idle → kick the head immediately; busy → the settle drain + // takes over now that the park is lifted. + if (!busy) { + void drainNextQueued() + } + }} + onSendNow={id => void sendQueuedNow(id)} + onSteerNow={id => void steerQueuedNow(id)} + parked={queueParked} + /> + ) : null + } + sessionId={statusSessionId} + /> + )}
+ {!hudMode && !guidedChat && ( + + )} {hudMode && busy && }
{!guidedChat && ( - toggleReview(scope.target === 'main' ? null : (cwd ?? null), scope.target)} - onOpenWorktree={openInWorktree} - onSwitchBranch={handleSwitchBranch} - // Blank in a bot chat: the row hides itself without a repo, - // and stops probing git / GitHub for a surface that has no - // branch to show. Cheaper than a second composer. - repoPath={botChat ? undefined : cwd} - /> + + toggleReview(scope.target === 'main' ? null : (cwd ?? null), scope.target)} + onOpenWorktree={openInWorktree} + onSwitchBranch={handleSwitchBranch} + // Blank in a bot chat: the row hides itself without a repo, + // and stops probing git / GitHub for a surface that has no + // branch to show. Cheaper than a second composer. + repoPath={botChat ? undefined : cwd} + /> + )} + ) +} + +interface StatusDrawerToggleProps { + collapsed: boolean + controls: string + onToggle: () => void +} + +export function StatusDrawerToggle({ collapsed, controls, onToggle }: StatusDrawerToggleProps) { + const { t } = useI18n() + const label = collapsed ? t.statusStack.showStack : t.statusStack.hideStack + + return ( + + + + ) +} diff --git a/apps/desktop/src/components/ui/button.tsx b/apps/desktop/src/components/ui/button.tsx index fdbb64b8c0..97fe05a740 100644 --- a/apps/desktop/src/components/ui/button.tsx +++ b/apps/desktop/src/components/ui/button.tsx @@ -26,6 +26,7 @@ const buttonVariants = cva( secondary: 'bg-(--ui-bg-quaternary) text-(--ui-text-primary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)', ghost: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)', + grip: 'bg-transparent text-(--ui-text-tertiary) transition-colors hover:text-(--ui-text-secondary) focus-visible:text-(--ui-text-primary)', // A control floating free of any surface (fan-menu discs, detached // chips): the menu/popover treatment — opaque popover fill + the // shared `shadow-md` ring-and-drop. Hover only lifts the glyph; a fill @@ -55,6 +56,7 @@ const buttonVariants = cva( 'icon-xs': "size-6 rounded-[4px] [&_svg:not([class*='size-'])]:size-3", 'icon-sm': 'size-8 rounded-[4px]', 'icon-lg': 'size-10 rounded-[4px]', + grip: 'h-4 w-12 rounded-full p-0', 'icon-titlebar': 'titlebar-icon-button h-(--titlebar-control-height) w-(--titlebar-control-size) rounded-[4px] [&_svg:not([class*="size-"])]:size-(--titlebar-icon-size)' } diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 82a14780a4..ae205771d0 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -2206,6 +2206,8 @@ export const ar = defineLocale({ } }, statusStack: { + hideStack: 'إخفاء لوحة الحالة', + showStack: 'إظهار لوحة الحالة', agents: 'الوكلاء', background: count => `${count} في الخلفية`, goalActive: 'الهدف نشط', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index f173b8d65d..61b337d54c 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -3158,6 +3158,8 @@ export const en: Translations = { }, statusStack: { + hideStack: 'Hide status stack', + showStack: 'Show status stack', agents: 'Agents', background: count => `${count} Background`, goalActive: 'Goal active', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 2357178ac5..f062c3b744 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -2584,6 +2584,8 @@ export const ja = defineLocale({ }, statusStack: { + hideStack: 'ステータススタックを隠す', + showStack: 'ステータススタックを表示', agents: 'エージェント', background: count => `バックグラウンド ${count} 件`, goalActive: '目標進行中', diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts index 04c5521f72..a7ec6f02ab 100644 --- a/apps/desktop/src/i18n/ru.ts +++ b/apps/desktop/src/i18n/ru.ts @@ -2889,6 +2889,8 @@ export const ru = defineLocale({ } }, statusStack: { + hideStack: 'Скрыть панель состояния', + showStack: 'Показать панель состояния', agents: 'Агенты', background: count => `${count} ${RU_NOUN(count, 'фоновая задача', 'фоновые задачи', 'фоновых задач')}`, goalActive: 'Цель активна', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 158974cbce..2bee181c1e 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -2662,6 +2662,8 @@ export interface Translations { } statusStack: { + hideStack: string + showStack: string agents: string background: (count: number) => string goalActive: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 11315e3d59..532349f027 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -2569,6 +2569,8 @@ export const zhHant = defineLocale({ }, statusStack: { + hideStack: '隱藏狀態面板', + showStack: '顯示狀態面板', agents: '代理', background: count => `${count} 個背景任務`, goalActive: '目標進行中', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 27d0a6c7a9..e10a62d137 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -3299,6 +3299,8 @@ export const zh = defineLocale({ }, statusStack: { + hideStack: '隐藏状态面板', + showStack: '显示状态面板', agents: '代理', background: count => `${count} 个后台任务`, goalActive: '目标进行中', diff --git a/apps/desktop/src/lib/keybinds/composer-focus-keys.test.ts b/apps/desktop/src/lib/keybinds/composer-focus-keys.test.ts index c1fe51484c..98ed53b158 100644 --- a/apps/desktop/src/lib/keybinds/composer-focus-keys.test.ts +++ b/apps/desktop/src/lib/keybinds/composer-focus-keys.test.ts @@ -166,13 +166,14 @@ describe('composerFocusKeysAllowed', () => { expect(composerFocusKeysAllowed(keydown({ key: 'h', code: 'KeyH', target: document.body }), 'type')).toBe(true) }) - it('refuses editables; refuses Enter on buttons but allows / and typing', () => { + it('refuses editables; leaves Enter and Space to buttons but allows / and typing', () => { const input = document.createElement('input') const button = document.createElement('button') document.body.append(input, button) expect(composerFocusKeysAllowed(keydown({ key: 'a', code: 'KeyA', target: input }), 'type')).toBe(false) expect(composerFocusKeysAllowed(keydown({ key: 'Enter', code: 'Enter', target: button }), 'enter')).toBe(false) + expect(composerFocusKeysAllowed(keydown({ key: ' ', code: 'Space', target: button }), 'type')).toBe(false) expect(composerFocusKeysAllowed(keydown({ key: '/', code: 'Slash', target: button }), '/')).toBe(true) expect(composerFocusKeysAllowed(keydown({ key: 'a', code: 'KeyA', target: button }), 'type')).toBe(true) }) diff --git a/apps/desktop/src/lib/keybinds/composer-focus-keys.ts b/apps/desktop/src/lib/keybinds/composer-focus-keys.ts index 1c01f3ec4c..8c009991d8 100644 --- a/apps/desktop/src/lib/keybinds/composer-focus-keys.ts +++ b/apps/desktop/src/lib/keybinds/composer-focus-keys.ts @@ -184,5 +184,6 @@ export function composerFocusKeysAllowed(event: KeyboardEvent, combo: string): b return false } - return !(combo === 'enter' && isActivateOnEnterTarget(event.target)) + // Space activates focused buttons too; it must not become a composer draft. + return !((combo === 'enter' || event.key === ' ') && isActivateOnEnterTarget(event.target)) } diff --git a/apps/desktop/src/store/composer-status-drawer.test.ts b/apps/desktop/src/store/composer-status-drawer.test.ts new file mode 100644 index 0000000000..b7e0690673 --- /dev/null +++ b/apps/desktop/src/store/composer-status-drawer.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, expect, it, vi } from 'vitest' + +beforeEach(() => { + window.localStorage.clear() + vi.resetModules() +}) + +it('restores the hidden choice without sharing it with another session or owner', async () => { + const store = await import('./composer-status-drawer') + const scope = { connectionId: 'local', profile: 'default', targetProfile: 'default', sessionId: 'chat-a' } + const key = store.statusDrawerKey(scope) + const otherSession = store.statusDrawerKey({ ...scope, sessionId: 'chat-b' }) + const otherProfile = store.statusDrawerKey({ ...scope, profile: 'coder', targetProfile: 'coder' }) + const otherConnection = store.statusDrawerKey({ ...scope, connectionId: 'remote' }) + + store.setStatusDrawerCollapsed(key, true) + store.setStatusDrawerCollapsed(otherSession, true) + store.setStatusDrawerCollapsed(otherSession, false) + vi.resetModules() + + const restored = await import('./composer-status-drawer') + expect(restored.$collapsedStatusDrawers.get()).toEqual([key]) + expect(restored.$collapsedStatusDrawers.get()).not.toContain(otherProfile) + expect(restored.$collapsedStatusDrawers.get()).not.toContain(otherConnection) +}) + +it('follows local profile renames and scopes profile removal to the exact owner', async () => { + const store = await import('./composer-status-drawer') + const scope = { connectionId: 'local', profile: 'coder', targetProfile: 'coder', sessionId: 'chat' } + const local = store.statusDrawerKey(scope) + const remote = store.statusDrawerKey({ ...scope, connectionId: 'remote' }) + const renamed = store.statusDrawerKey({ ...scope, profile: 'work', targetProfile: 'work' }) + + store.setStatusDrawerCollapsed(local, true) + store.setStatusDrawerCollapsed(remote, true) + store.migrateStatusDrawersForProfile('coder', 'work') + expect(store.$collapsedStatusDrawers.get()).toEqual([renamed, remote]) + store.dropStatusDrawersForProfile('work') + expect(store.$collapsedStatusDrawers.get()).toEqual([remote]) + store.dropStatusDrawersForProfile('coder', { connectionId: 'remote', profile: 'coder', targetProfile: 'coder' }) + expect(store.$collapsedStatusDrawers.get()).toEqual([]) +}) diff --git a/apps/desktop/src/store/composer-status-drawer.ts b/apps/desktop/src/store/composer-status-drawer.ts new file mode 100644 index 0000000000..22c6035dd0 --- /dev/null +++ b/apps/desktop/src/store/composer-status-drawer.ts @@ -0,0 +1,100 @@ +import { Codecs, persistentAtom } from '@/lib/persisted' + +interface StatusDrawerScope { + connectionId: string | null + profile: string + targetProfile: string + sessionId: string +} + +const MAX_SAVED_DRAWERS = 512 + +function decodeScope(key: string): StatusDrawerScope | null { + try { + const value: unknown = JSON.parse(key) + + if ( + !Array.isArray(value) || + value.length !== 4 || + (value[0] !== null && typeof value[0] !== 'string') || + !value.slice(1).every(part => typeof part === 'string' && part.length > 0) + ) { + return null + } + + const [connectionId, profile, targetProfile, sessionId] = value as [string | null, string, string, string] + + return { connectionId, profile, targetProfile, sessionId } + } catch { + return null + } +} + +export function statusDrawerKey(scope: StatusDrawerScope): string { + return JSON.stringify([scope.connectionId, scope.profile, scope.targetProfile, scope.sessionId]) +} + +/** Only hidden drawers need an entry; new conversations keep the existing open default. */ +export const $collapsedStatusDrawers = persistentAtom( + 'hermes.desktop.collapsedStatusDrawers.v1', + [], + Codecs.json(value => + Array.isArray(value) + ? [...new Set(value.filter((key): key is string => typeof key === 'string' && decodeScope(key) !== null))].slice( + -MAX_SAVED_DRAWERS + ) + : [] + ) +) + +export function setStatusDrawerCollapsed(key: string, collapsed: boolean): void { + const next = $collapsedStatusDrawers.get().filter(saved => saved !== key) + + if (collapsed) { + next.push(key) + } + + $collapsedStatusDrawers.set(next.slice(-MAX_SAVED_DRAWERS)) +} + +export function migrateStatusDrawersForProfile(from: string, to: string): void { + const renamed = $collapsedStatusDrawers.get().map(key => { + const scope = decodeScope(key) + + if (!scope || (scope.connectionId !== null && scope.connectionId !== 'local')) { + return key + } + + return statusDrawerKey({ + ...scope, + profile: scope.profile === from ? to : scope.profile, + targetProfile: scope.targetProfile === from ? to : scope.targetProfile + }) + }) + + $collapsedStatusDrawers.set([...new Set(renamed)]) +} + +export function dropStatusDrawersForProfile( + profile: string, + route?: { connectionId?: string; profile?: string; targetProfile?: string } +): void { + $collapsedStatusDrawers.set( + $collapsedStatusDrawers.get().filter(key => { + const scope = decodeScope(key) + + if (!scope) { + return false + } + + const matches = route + ? scope.profile === route.profile?.trim() && + (!route.targetProfile || scope.targetProfile === route.targetProfile.trim()) && + (!route.connectionId || scope.connectionId === route.connectionId.trim()) + : (scope.connectionId === null || scope.connectionId === 'local') && + (scope.profile === profile || scope.targetProfile === profile) + + return !matches + }) + ) +} diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts index 95e56af19d..ad815c6b80 100644 --- a/apps/desktop/src/store/session-states.ts +++ b/apps/desktop/src/store/session-states.ts @@ -36,6 +36,7 @@ import { stableArray } from '@/lib/stable-array' import { readJson, writeJson } from '@/lib/storage' import type { SessionInfo } from '@/types/hermes' +import { dropStatusDrawersForProfile, migrateStatusDrawersForProfile } from './composer-status-drawer' import { dropPreviewTabsForProfile, migratePreviewTabsForProfile, setPreviewScope } from './preview' import { dropPreviewArtifactsForProfile, migratePreviewArtifactsForProfile } from './preview-status' import { $activeGatewayProfile, normalizeProfileKey } from './profile' @@ -2080,6 +2081,7 @@ export function dropTilesForProfile( const name = normalizeProfileKey(profile) dropPreviewArtifactsForProfile(name, route) + dropStatusDrawersForProfile(name, route) // Route fields go through the SAME canonicalization as `name` below — a // source-scoped delete must not be defeated by stray whitespace around a // profile name that a non-route delete trims away. @@ -2222,6 +2224,7 @@ export function migrateTilesForProfile(oldProfile: string, newProfile: string): migrateRememberedNavigationForProfile(from, to) migrateSessionOwnerHintsForProfile(from, to) migratePreviewArtifactsForProfile(from, to) + migrateStatusDrawersForProfile(from, to) // Sibling family: the rail's profile-keyed buckets move with the rename, or // the renamed profile opens with an empty rail and the old name keeps them. migratePreviewTabsForProfile(from, to) diff --git a/gateway/run_notifications.py b/gateway/run_notifications.py index 56999f56e8..a9aa431b64 100644 --- a/gateway/run_notifications.py +++ b/gateway/run_notifications.py @@ -14,6 +14,7 @@ import json import logging import time from contextlib import suppress +from datetime import datetime from pathlib import Path from typing import Any, Dict, Optional, cast @@ -32,6 +33,12 @@ _UPDATE_FAILED_NOTICE = ( "❌ Hermes update failed; the previous version is still running. Run `hermes update` on the " "host to see the full error, or try /update again later.") +# An update's completion notice waits for its target platform adapter to (re)connect before it +# can be delivered. Nothing bounds that wait, so a marker naming a platform that is not +# configured at all — no adapter will ever appear — would keep itself on disk and re-log a +# deferred line on every poll, in every process, forever. Stop waiting past this age. +_UPDATE_NOTIFY_MAX_ADAPTER_WAIT_SECONDS = 3600.0 + def _served_notice_target_key(profile: Optional[str], platform_value: str, chat_id, thread_id) -> tuple: """Notice-dedupe key for one SERVED profile's home channel. @@ -522,6 +529,24 @@ class GatewayNotificationsMixin: return profile_from_session_key_namespace(parts[1]) return None + @staticmethod + def _marker_age_seconds(data: dict) -> Optional[float]: + """Age of a persisted update marker, from the ``timestamp`` stamped by its writer. + + ``None`` when the marker carries no parseable stamp — the field is absent on markers + written before it existed, and callers keep the old retry behavior rather than guess. + """ + raw = str(data.get("timestamp") or "").strip() + if not raw: + return None + try: + stamped = datetime.fromisoformat(raw) + except ValueError: + return None + # The writer stamps a naive local ``datetime.now()``; tolerate a tz-aware one too. + now = datetime.now(stamped.tzinfo) if stamped.tzinfo else datetime.now() + return (now - stamped).total_seconds() + def _resolve_update_target(self, paths: "_UpdatePaths") -> Optional["_UpdateTarget"]: """Resolve adapter/chat/session for update watcher messages from the pending marker.""" for path in (paths.claimed, paths.pending): @@ -731,6 +756,18 @@ class GatewayNotificationsMixin: platform = Platform(platform_str) adapter = self._authorization_adapter(platform, self._marker_profile(pending)) if chat_id and not adapter: + age = self._marker_age_seconds(pending) + if age is not None and age > _UPDATE_NOTIFY_MAX_ADAPTER_WAIT_SECONDS: + # The platform never came back. Deferring forever leaks the markers and re-logs + # on every poll for the life of the install: the startup path reschedules this + # watcher whenever the markers are still on disk, so an undeliverable marker + # outlives every restart. Give up loudly, clear the markers, and report a + # definitive decision (True) so the caller stops rescheduling. + logger.warning( + "Post-update notification for %s:%s dropped after %.1fh: %s adapter never " + "connected", platform_str, chat_id, age / 3600.0, platform_str) + self._clear_update_markers(paths, pending.get("session_key")) + return True # Target platform not reconnected yet (common right after the update's restart): keep the # markers for a later retry instead of silently losing the notification. return _defer("Update notification deferred: %s adapter not connected yet", platform_str) diff --git a/hermes_cli/models_catalog_static.py b/hermes_cli/models_catalog_static.py index fe79673652..d468b9c760 100644 --- a/hermes_cli/models_catalog_static.py +++ b/hermes_cli/models_catalog_static.py @@ -159,7 +159,10 @@ _XAI_MODELS = _xai_curated_models() # Curated per-provider lists. ``-cn`` twins share the international catalog on a domestic endpoint. _PROVIDER_MODELS: dict[str, list[str]] = { "moa": ["default"], - "nous": [mid for mid, _ in OPENROUTER_MODELS if mid not in _OPENROUTER_ONLY and not mid.endswith(":free")], + # grok-4.7 is on the Nous Portal ahead of the OpenRouter listing; pinned first so it heads the picker. + "nous": ["x-ai/grok-4.7"] + [ + mid for mid, _ in OPENROUTER_MODELS if mid not in _OPENROUTER_ONLY and not mid.endswith(":free") + ], # Used by /model counts and provider_model_ids fallback when /v1/models is unavailable. "openai": list(_OPENAI_CHAT_MODELS), "openai-api": [ diff --git a/plugins/video_gen/fal/__init__.py b/plugins/video_gen/fal/__init__.py index f79df81b04..11c8d06420 100644 --- a/plugins/video_gen/fal/__init__.py +++ b/plugins/video_gen/fal/__init__.py @@ -274,10 +274,15 @@ def _submit_fal_video_request(endpoint: str, arguments: Dict[str, Any]): managed_gateway = _resolve_managed_fal_video_gateway() if managed_gateway is None: return client.submit(endpoint, arguments=arguments, headers=headers) + from tools.fal_common import ( + _extract_http_status, _managed_fal_billing_error, submit_managed_fal_with_rate_limit_retry, + ) try: - return _get_managed_fal_video_client(managed_gateway).submit(endpoint, arguments=arguments, headers=headers) + return submit_managed_fal_with_rate_limit_retry( + lambda request_headers: _get_managed_fal_video_client(managed_gateway).submit( + endpoint, arguments=arguments, headers=request_headers), + what="video endpoint", name=endpoint) except Exception as exc: - from tools.fal_common import _extract_http_status, _managed_fal_billing_error status = _extract_http_status(exc) if status is not None and 400 <= status < 500: billing = _managed_fal_billing_error(exc, "endpoint") diff --git a/tests/gateway/test_update_command.py b/tests/gateway/test_update_command.py index bc858f9a96..cb9cdab382 100644 --- a/tests/gateway/test_update_command.py +++ b/tests/gateway/test_update_command.py @@ -5,6 +5,7 @@ the _send_update_notification startup hook (sends results after restart). """ import json +from datetime import datetime, timedelta from pathlib import Path from unittest.mock import patch, MagicMock, AsyncMock @@ -366,6 +367,68 @@ class TestSendUpdateNotification: assert "Update complete" in call_args[0][1] or "update finished" in call_args[0][1].lower() + @pytest.mark.asyncio + async def test_drops_stale_marker_when_the_platform_never_connects(self, tmp_path, caplog): + """A marker past the wait cap is abandoned instead of deferred forever. + + Regression: an update notice addressed to a platform that has no adapter — + and never will, because the platform is not configured at all — kept its + markers on disk and re-logged a deferred line on every poll. The startup + path reschedules the watcher for as long as the markers exist, so the + notice outlived every restart, in every process. + """ + runner = _make_runner() + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + + pending_path = hermes_home / ".update_pending.json" + pending_path.write_text(json.dumps({ + "platform": "telegram", + "chat_id": "67890", + "user_id": "12345", + "timestamp": (datetime.now() - timedelta(hours=2)).isoformat(), + })) + (hermes_home / ".update_exit_code").write_text("0") + # runner.adapters stays empty: no adapter for the target platform, ever. + + with patch("gateway.run._hermes_home", hermes_home): + result = await runner._send_update_notification() + + # True is the definitive answer the startup caller keys off to stop rescheduling. + assert result is True + assert not pending_path.exists() + assert not (hermes_home / ".update_pending.claimed.json").exists() + assert not (hermes_home / ".update_output.txt").exists() + assert not (hermes_home / ".update_exit_code").exists() + assert any("adapter never connected" in r.getMessage() for r in caplog.records) + + @pytest.mark.asyncio + async def test_keeps_waiting_for_a_recent_marker(self, tmp_path): + """A recent marker is still held: the cap must not swallow its own notice. + + Right after the update's restart the adapter is legitimately absent for a + while, which is the case the defer path exists to cover. + """ + runner = _make_runner() + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + + pending_path = hermes_home / ".update_pending.json" + pending_path.write_text(json.dumps({ + "platform": "telegram", + "chat_id": "67890", + "user_id": "12345", + "timestamp": (datetime.now() - timedelta(minutes=5)).isoformat(), + })) + (hermes_home / ".update_exit_code").write_text("0") + + with patch("gateway.run._hermes_home", hermes_home): + result = await runner._send_update_notification() + + assert result is False + assert pending_path.exists(), "marker kept so a later poll can still deliver it" + + @pytest.mark.asyncio async def test_cleans_up_on_error(self, tmp_path): """Files are cleaned up even if notification fails.""" diff --git a/tests/tools/test_image_generation.py b/tests/tools/test_image_generation.py index ddc6bfd556..4971b92183 100644 --- a/tests/tools/test_image_generation.py +++ b/tests/tools/test_image_generation.py @@ -563,6 +563,42 @@ class TestManagedGatewayErrorTranslation: with pytest.raises(ConnectionError): image_tool._submit_fal_request("fal-ai/flux-2-pro", {"prompt": "x"}) + @staticmethod + def _rate_limited(retry_after): + return _MockHttpxError(429, "Too Many Requests", payload={"error": { + "code": "RATE_LIMIT_EXCEEDED", "message": "Rate limit exceeded.", "retryAfter": retry_after}}) + + def test_short_429_is_retried_once_under_a_fresh_idempotency_key(self, image_tool, monkeypatch): + """A gateway 429 with a short retryAfter is waited out and resubmitted once (new key).""" + from unittest.mock import MagicMock + + monkeypatch.setattr(image_tool, "_resolve_managed_fal_gateway", lambda: MagicMock()) + mock_managed_client = MagicMock() + mock_managed_client.submit.side_effect = [self._rate_limited(1), "handle"] + monkeypatch.setattr(image_tool, "_get_managed_fal_client", lambda gw: mock_managed_client) + + assert image_tool._submit_fal_request("fal-ai/gpt-image-2", {"prompt": "x"}) == "handle" + + keys = [call.kwargs["headers"]["x-idempotency-key"] for call in mock_managed_client.submit.call_args_list] + assert len(keys) == 2 and keys[0] != keys[1] + + def test_long_429_is_reported_as_a_rate_limit_not_a_missing_model(self, image_tool, monkeypatch): + """A 429 beyond the retry cap names the rate limit; agents must not be told to switch models.""" + from unittest.mock import MagicMock + + monkeypatch.setattr(image_tool, "_resolve_managed_fal_gateway", lambda: MagicMock()) + mock_managed_client = MagicMock() + mock_managed_client.submit.side_effect = self._rate_limited(120) + monkeypatch.setattr(image_tool, "_get_managed_fal_client", lambda gw: mock_managed_client) + + with pytest.raises(ValueError) as exc_info: + image_tool._submit_fal_request("fal-ai/gpt-image-2", {"prompt": "x"}) + + msg = str(exc_info.value) + assert "rate-limited" in msg and "retry after 120s" in msg + assert "may not yet be enabled" not in msg + assert mock_managed_client.submit.call_count == 1 + class TestKreaModelNormalization: """Native ``krea-2-*`` detection for managed Krea routing.""" diff --git a/tools/fal_common.py b/tools/fal_common.py index c7803e4a5b..41601ce511 100644 --- a/tools/fal_common.py +++ b/tools/fal_common.py @@ -26,9 +26,14 @@ issue #26241 for details. from __future__ import annotations -from typing import Any, Dict, Optional, Union +import time +import uuid +from typing import Any, Callable, Dict, Optional, Union from urllib.parse import urlencode +# A 429 whose Retry-After is longer than this is reported, not waited out inside a tool call. +MANAGED_FAL_RATE_LIMIT_RETRY_CAP_SECONDS = 30.0 + def import_fal_client() -> Any: """Import ``fal_client`` (via ``pm`` when available) and return @@ -99,6 +104,61 @@ def _managed_fal_billing_error(exc: BaseException, what: str) -> Optional[str]: ) +def _managed_fal_retry_after_seconds(exc: BaseException) -> Optional[float]: + """Seconds the managed gateway asked us to wait after a 429: the ``Retry-After`` header, + else the body's ``error.retryAfter``; None when the status is not 429 or neither is present.""" + response = getattr(exc, "response", None) + if response is None or _extract_http_status(exc) != 429: + return None + headers = getattr(response, "headers", None) + raw = headers.get("Retry-After") if headers is not None and hasattr(headers, "get") else None + if raw is None: + try: + error = response.json().get("error") + except Exception: # noqa: BLE001 — a non-JSON 429 body simply has no hint + return None + raw = error.get("retryAfter") if isinstance(error, dict) else None + try: + return float(raw) if raw is not None else None + except (TypeError, ValueError): + return None + + +def _managed_fal_rate_limit_message(what: str, name: str, retry_after: Optional[float]) -> str: + hint = f"retry after {retry_after:g}s" if retry_after is not None else "no Retry-After given" + return ( + f"Nous Subscription gateway rate-limited {what} '{name}' (HTTP 429; {hint}). " + "The model is enabled — retry later instead of switching models or setting FAL_KEY." + ) + + +def submit_managed_fal_with_rate_limit_retry( + submit: Callable[[Dict[str, str]], Any], *, what: str, name: str, +): + """Call ``submit(headers)`` with a fresh ``x-idempotency-key``; on a 429 whose Retry-After + fits the cap, wait it out (interrupt-aware) and resubmit ONCE under a new key. + + A second 429, or one with an unknown/too-long Retry-After, raises ValueError naming the + rate limit — it must never fall through to the callers' "model may not be enabled" 4xx text, + which sends agents off to switch models. Every other exception propagates untouched. + """ + from tools.interrupt import is_interrupted + for attempt in (1, 2): + try: + return submit({"x-idempotency-key": str(uuid.uuid4())}) + except Exception as exc: + if _extract_http_status(exc) != 429: + raise + retry_after = _managed_fal_retry_after_seconds(exc) + if attempt == 2 or retry_after is None or retry_after > MANAGED_FAL_RATE_LIMIT_RETRY_CAP_SECONDS: + raise ValueError(_managed_fal_rate_limit_message(what, name, retry_after)) from exc + deadline = time.monotonic() + retry_after + while time.monotonic() < deadline: + if is_interrupted(): + raise ValueError(_managed_fal_rate_limit_message(what, name, retry_after)) from exc + time.sleep(min(0.5, max(0.0, deadline - time.monotonic()))) + + def _require(value: Any, what: str) -> Any: if value is None: raise RuntimeError(f"{what} is required for managed FAL gateway mode") diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index 6d00b89fcd..6c7d684118 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -31,7 +31,7 @@ def _load_fal_client() -> Any: from tools.debug_helpers import DebugSession from tools.fal_common import ( _ManagedFalSyncClient, _extract_http_status, _managed_fal_billing_error, - _normalize_fal_queue_url_format, + _normalize_fal_queue_url_format, submit_managed_fal_with_rate_limit_retry, ) from tools.image_generation_catalog import ( DEFAULT_ASPECT_RATIO, DEFAULT_MODEL, FAL_MODELS, UPSCALER_CREATIVITY, UPSCALER_DEFAULT_PROMPT, @@ -127,8 +127,10 @@ def _submit_fal_request(model: str, arguments: Dict[str, Any]): if managed_gateway is None: return fal_client.submit(model, arguments=arguments, headers=request_headers) try: - return _get_managed_fal_client(managed_gateway).submit( - model, arguments=arguments, headers=request_headers) + return submit_managed_fal_with_rate_limit_retry( + lambda headers: _get_managed_fal_client(managed_gateway).submit( + model, arguments=arguments, headers=headers), + what="image model", name=model) except Exception as exc: # A managed-gateway 4xx usually means the portal doesn't proxy this model # (allowlist miss, billing gate): give remediation instead of a raw httpx error. diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json index d35465ad42..fc1ad48036 100644 --- a/website/static/api/model-catalog.json +++ b/website/static/api/model-catalog.json @@ -1,6 +1,6 @@ { "version": 1, - "updated_at": "2026-09-20T18:57:41Z", + "updated_at": "2026-09-21T17:31:22Z", "metadata": { "source": "hermes-agent repo", "docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog" @@ -397,4 +397,4 @@ ] } } -} \ No newline at end of file +}