From 0e65c6f773635f0811f1f20016a918c2ae0275c7 Mon Sep 17 00:00:00 2001 From: Omar Baradei Date: Sun, 5 Jul 2026 18:29:05 -0700 Subject: [PATCH 1/4] Refresh onto current upstream/main (no behavior change) Co-Authored-By: Claude Opus 4.8 --- apps/desktop/src/app/chat/sidebar/index.tsx | 110 ++ apps/desktop/src/app/desktop-controller.tsx | 1370 +++++++++++++++++ .../hooks/use-session-actions/index.ts | 61 + apps/desktop/src/hermes.test.ts | 51 + apps/desktop/src/hermes.ts | 21 + hermes_cli/web_server.py | 107 +- hermes_state.py | 86 ++ tests/hermes_cli/test_web_server.py | 166 ++ 8 files changed, 1966 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/app/desktop-controller.tsx diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 0d82ef787252..f4987397a584 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -8,7 +8,18 @@ import { useLocation } from 'react-router-dom' import { PlatformAvatar } from '@/app/messaging/platform-icon' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' +<<<<<<< HEAD import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu' +======= +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +>>>>>>> 239cbaba6 (Refresh onto current upstream/main (no behavior change)) import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { KbdGroup } from '@/components/ui/kbd' import { SearchField } from '@/components/ui/search-field' @@ -21,8 +32,12 @@ import { SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar' +<<<<<<< HEAD import { Tip, TipKeybindLabel } from '@/components/ui/tooltip' import { useContributions } from '@/contrib/react/use-contributions' +======= +import { Tip } from '@/components/ui/tooltip' +>>>>>>> 239cbaba6 (Refresh onto current upstream/main (no behavior change)) import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes' import { useI18n } from '@/i18n' import { comboTokens } from '@/lib/keybinds/combo' @@ -227,6 +242,7 @@ interface ChatSidebarProps extends React.ComponentProps { onLoadMoreSessions: () => Promise | void onLoadMoreProfileSessions?: (profile: string) => Promise | void onLoadMoreMessaging?: (platform: string) => Promise | void + onArchiveAllSessions: () => Promise | void onResumeSession: (sessionId: string) => void onDeleteSession: (sessionId: string) => void onArchiveSession: (sessionId: string) => void @@ -244,6 +260,7 @@ export function ChatSidebar({ onLoadMoreSessions, onLoadMoreProfileSessions, onLoadMoreMessaging, + onArchiveAllSessions, onResumeSession, onDeleteSession, onArchiveSession, @@ -338,6 +355,8 @@ export function ChatSidebar({ // Per-platform count of rows currently revealed (starts at NON_SESSION_INITIAL_ROWS). const [messagingVisible, setMessagingVisible] = useState>({}) const searchInputRef = useRef(null) + const [archiveAllOpen, setArchiveAllOpen] = useState(false) + const [archiveAllSubmitting, setArchiveAllSubmitting] = useState(false) const trimmedQuery = searchQuery.trim() // Hotkey (session.focusSearch) → focus the field once it's mounted. @@ -1011,6 +1030,31 @@ export function ChatSidebar({ ? Object.values(sessionProfilesTruncated).some(Boolean) : Boolean(sessionProfilesTruncated[profileScope]) +<<<<<<< HEAD +======= + const hasMoreSessions = knownSessionTotal > loadedSessionCount + + const recentsMeta = countLabel(displayAgentSessions.length, knownSessionTotal) + const archiveAllDisabled = sessionsLoading || agentSessions.length === 0 || archiveAllSubmitting + + const handleArchiveAll = async () => { + if (archiveAllSubmitting) { + return + } + + setArchiveAllSubmitting(true) + + try { + await onArchiveAllSessions() + setArchiveAllOpen(false) + } catch { + // The caller owns the error toast/rollback; keep the dialog open. + } finally { + setArchiveAllSubmitting(false) + } + } + +>>>>>>> 239cbaba6 (Refresh onto current upstream/main (no behavior change)) const displayRecentsCountRef = useRef(0) const loadedRecentsCountRef = useRef(0) displayRecentsCountRef.current = displayAgentSessions.length @@ -1379,6 +1423,30 @@ export function ChatSidebar({ ) : (
+
+ {!showAllProfiles && agentSessions.length > 0 ? ( + + + + ) : null} +
{!showAllProfiles ? ( + + + + + ) +} + interface MessagingSection { sourceId: string label: string diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx new file mode 100644 index 000000000000..3d741c06c19b --- /dev/null +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -0,0 +1,1370 @@ +import { useStore } from '@nanostores/react' +import { useQueryClient } from '@tanstack/react-query' +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from 'react' +import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom' + +import { BootFailureOverlay } from '@/components/boot-failure-overlay' +import { DesktopInstallOverlay } from '@/components/desktop-install-overlay' +import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay' +import { DesktopOnboardingOverlay } from '@/components/onboarding' +import { Pane, PaneMain } from '@/components/pane-shell' +import { RemoteDisplayBanner } from '@/components/remote-display-banner' +import { useMediaQuery } from '@/hooks/use-media-query' +import { isFocusWithin } from '@/lib/keybinds/combo' +import { cn } from '@/lib/utils' +import { useSkinCommand } from '@/themes/use-skin-command' + +import { formatRefValue } from '../components/assistant-ui/directive-text' +import { getSessionMessages, type SessionMessage, triggerCronJob } from '../hermes' +import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages' +import { storedSessionIdForNotification } from '../lib/session-ids' +import { isMessagingSource } from '../lib/session-source' +import { latestSessionTodos } from '../lib/todos' +import { setCronFocusJobId } from '../store/cron' +import { + $fileBrowserOpen, + $panesFlipped, + $pinnedSessionIds, + FILE_BROWSER_DEFAULT_WIDTH, + FILE_BROWSER_MAX_WIDTH, + FILE_BROWSER_MIN_WIDTH, + pinSession, + PREVIEW_PANE_ID, + restoreWorktree, + setSidebarOverlayMounted, + SIDEBAR_DEFAULT_WIDTH, + SIDEBAR_MAX_WIDTH, + unpinSession +} from '../store/layout' +import { respondToApprovalAction } from '../store/native-notifications' +import { $paneOpen } from '../store/panes' +import { setPetActivity } from '../store/pet' +import { setPetScale } from '../store/pet-gallery' +import { + setPetOverlayOpenAppHandler, + setPetOverlayScaleHandler, + setPetOverlaySubmitHandler +} from '../store/pet-overlay' +import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview' +import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '../store/profile' +import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '../store/projects' +import { $reviewOpen, REVIEW_PANE_ID } from '../store/review' +import { + $activeSessionId, + $attentionSessionIds, + $currentCwd, + $freshDraftReady, + $gatewayState, + $messages, + $messagingSessions, + $resumeExhaustedSessionId, + $resumeFailedSessionId, + $selectedStoredSessionId, + $sessions, + getRememberedSessionId, + sessionPinId, + setAwaitingResponse, + setBusy, + setCurrentBranch, + setCurrentCwd, + setCurrentModel, + setCurrentProvider, + setMessages, + setRememberedSessionId +} from '../store/session' +import { onSessionsChanged } from '../store/session-sync' +import { clearSessionTodos, setSessionTodos, todosForHydration } from '../store/todos' +import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates' +import { isSecondaryWindow } from '../store/windows' + +import { ChatView } from './chat' +import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus' +import { useComposerActions } from './chat/hooks/use-composer-actions' +import { + ChatPreviewRail, + PREVIEW_RAIL_MAX_WIDTH, + PREVIEW_RAIL_MIN_WIDTH, + PREVIEW_RAIL_PANE_WIDTH +} from './chat/right-rail' +import { ChatSidebar } from './chat/sidebar' +import { CommandPalette } from './command-palette' +import { useGatewayBoot } from './gateway/hooks/use-gateway-boot' +import { useGatewayRequest } from './gateway/hooks/use-gateway-request' +import { useKeybinds } from './hooks/use-keybinds' +import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants' +import { ModelPickerOverlay } from './model-picker-overlay' +import { ModelVisibilityOverlay } from './model-visibility-overlay' +import { PetGenerateOverlay } from './pet-generate/pet-generate-overlay' +import { RightSidebarPane } from './right-sidebar' +import { FileActionDialogs } from './right-sidebar/file-actions' +import { RemoteFolderPicker } from './right-sidebar/files/remote-picker' +import { ReviewPane } from './right-sidebar/review' +import { $terminalTakeover } from './right-sidebar/store' +import { TerminalPaneChrome } from './right-sidebar/terminal/chrome' +import { PersistentTerminal } from './right-sidebar/terminal/persistent' +import { closeActiveTerminal } from './right-sidebar/terminal/terminals' +import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes' +import { SessionPickerOverlay } from './session-picker-overlay' +import { SessionSwitcher } from './session-switcher' +import { useContextSuggestions } from './session/hooks/use-context-suggestions' +import { useCwdActions } from './session/hooks/use-cwd-actions' +import { useHermesConfig } from './session/hooks/use-hermes-config' +import { useMessageStream } from './session/hooks/use-message-stream' +import { useModelControls } from './session/hooks/use-model-controls' +import { usePreviewRouting } from './session/hooks/use-preview-routing' +import { usePromptActions } from './session/hooks/use-prompt-actions' +import { useRouteResume } from './session/hooks/use-route-resume' +import { useSessionActions } from './session/hooks/use-session-actions' +import { useSessionListActions } from './session/hooks/use-session-list-actions' +import { useSessionStateCache } from './session/hooks/use-session-state-cache' +import { AppShell } from './shell/app-shell' +import { useOverlayRouting } from './shell/hooks/use-overlay-routing' +import { useStatusSnapshot } from './shell/hooks/use-status-snapshot' +import { useStatusbarItems } from './shell/hooks/use-statusbar-items' +import { ModelMenuPanel } from './shell/model-menu-panel' +import type { StatusbarItem } from './shell/statusbar-controls' +import type { TitlebarTool } from './shell/titlebar-controls' +import { useGroupRegistry } from './shell/use-group-registry' +import { UpdatesOverlay } from './updates-overlay' + +const AgentsView = lazy(async () => ({ default: (await import('./agents')).AgentsView })) +const ArtifactsView = lazy(async () => ({ default: (await import('./artifacts')).ArtifactsView })) +const CommandCenterView = lazy(async () => ({ default: (await import('./command-center')).CommandCenterView })) +const CronView = lazy(async () => ({ default: (await import('./cron')).CronView })) +const StarmapView = lazy(async () => ({ default: (await import('./starmap')).StarmapView })) +const MessagingView = lazy(async () => ({ default: (await import('./messaging')).MessagingView })) +const ProfilesView = lazy(async () => ({ default: (await import('./profiles')).ProfilesView })) +const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView })) +const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView })) + +// Latest cron-job sessions surfaced in the collapsed "Cron jobs" section. The +// Cron sessions are written by a background scheduler tick (the desktop +// backend), so no user action signals the UI. Poll the bounded cron list on +// this cadence while the app is open + visible so new runs surface promptly +// instead of waiting for the next user-triggered refreshSessions(). +const CRON_POLL_INTERVAL_MS = 30_000 +// Messaging-platform turns are written by the background gateway (WeChat, +// Telegram, Discord, …), not the desktop websocket that drives local chats. +// Poll the bounded messaging slice while visible so inbound platform traffic +// appears without requiring a manual refresh or route change. +const MESSAGING_POLL_INTERVAL_MS = 10_000 +const ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS = 5_000 + +function sessionMatchesStoredId(session: { id: string; _lineage_root_id?: null | string }, id: string): boolean { + return session.id === id || session._lineage_root_id === id +} + +function hashString(hash: number, value: string): number { + let next = hash + + for (let i = 0; i < value.length; i++) { + next ^= value.charCodeAt(i) + next = Math.imul(next, 16777619) + } + + return next >>> 0 +} + +function sessionMessagesSignature(messages: SessionMessage[]): string { + let hash = 2166136261 + + for (const m of messages) { + hash = hashString(hash, m.role) + hash = hashString(hash, String(m.timestamp ?? '')) + hash = hashString(hash, typeof m.content === 'string' ? m.content : (JSON.stringify(m.content) ?? '')) + } + + return `${messages.length}:${hash}` +} + +export function DesktopController() { + const queryClient = useQueryClient() + const location = useLocation() + const navigate = useNavigate() + + const busyRef = useRef(false) + const creatingSessionRef = useRef(false) + const messagingTranscriptSignatureRef = useRef(new Map()) + + const gatewayState = useStore($gatewayState) + const activeSessionId = useStore($activeSessionId) + const currentCwd = useStore($currentCwd) + const freshDraftReady = useStore($freshDraftReady) + const resumeFailedSessionId = useStore($resumeFailedSessionId) + const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId) + const filePreviewTarget = useStore($filePreviewTarget) + const previewTarget = useStore($previewTarget) + const selectedStoredSessionId = useStore($selectedStoredSessionId) + const messagingSessions = useStore($messagingSessions) + const terminalTakeover = useStore($terminalTakeover) + const reviewOpen = useStore($reviewOpen) + const fileBrowserOpen = useStore($fileBrowserOpen) + const previewPaneOpen = useStore($paneOpen(PREVIEW_PANE_ID)) + const panesFlipped = useStore($panesFlipped) + const profileScope = useStore($profileScope) + // Below SIDEBAR_COLLAPSE_BREAKPOINT_PX there's no room for a docked rail — + // collapse both sidebars (without touching their stored open state) so the + // hover-reveal overlay becomes the way in. Restores once it's wide again. + const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY) + + const routedSessionId = routeSessionId(location.pathname) + const routeToken = `${location.pathname}:${location.search}:${location.hash}` + const routeTokenRef = useRef(routeToken) + routeTokenRef.current = routeToken + const getRouteToken = useCallback(() => routeTokenRef.current, []) + + const { + agentsOpen, + chatOpen, + closeOverlayToPreviousRoute, + commandCenterInitialSection, + commandCenterOpen, + cronOpen, + currentView, + openAgents, + openCommandCenterSection, + openStarmap, + profilesOpen, + settingsOpen, + starmapOpen, + toggleCommandCenter + } = useOverlayRouting() + + const terminalSidebarOpen = chatOpen && terminalTakeover + + const titlebarToolGroups = useGroupRegistry() + const statusbarItemGroups = useGroupRegistry() + const setTitlebarToolGroup = titlebarToolGroups.set + const setStatusbarItemGroup = statusbarItemGroups.set + + const { + activeSessionIdRef, + ensureSessionState, + runtimeIdByStoredSessionIdRef, + selectedStoredSessionIdRef, + sessionStateByRuntimeIdRef, + syncSessionStateToView, + updateSessionState + } = useSessionStateCache({ + activeSessionId, + busyRef, + selectedStoredSessionId, + setAwaitingResponse, + setBusy, + setMessages + }) + + const { connectionRef, gatewayRef, requestGateway } = useGatewayRequest() + + useEffect(() => { + window.hermesDesktop?.setPreviewShortcutActive?.(Boolean(chatOpen && (filePreviewTarget || previewTarget))) + }, [chatOpen, filePreviewTarget, previewTarget]) + + useEffect(() => { + startUpdatePoller() + const unsubscribe = window.hermesDesktop?.onOpenUpdatesRequested?.(() => openUpdatesWindow()) + + return () => { + unsubscribe?.() + stopUpdatePoller() + } + }, []) + + // Remember the open chat so a relaunch reopens it instead of an empty new-chat. + useEffect(() => { + if (routedSessionId) { + setRememberedSessionId(routedSessionId) + } + }, [routedSessionId]) + + // Restore that chat once, on cold start only (we're at the new-chat route and + // haven't navigated yet). A dead/deleted id self-clears via the exhausted latch + // below, so we never boot-loop into an error screen. + const restoredLastSessionRef = useRef(false) + useEffect(() => { + if (restoredLastSessionRef.current) { + return + } + + restoredLastSessionRef.current = true + const last = getRememberedSessionId() + + if (last && location.pathname === NEW_CHAT_ROUTE) { + navigate(sessionRoute(last), { replace: true }) + } + }, [location.pathname, navigate]) + + useEffect(() => { + if (resumeExhaustedSessionId && getRememberedSessionId() === resumeExhaustedSessionId) { + setRememberedSessionId(null) + } + }, [resumeExhaustedSessionId]) + + // Notification click: the main process already focused the window; jump to its + // session. Notifications are tagged with the gateway *runtime* session id, but + // the chat route is keyed by the *stored* id — navigating with the runtime id + // resumes a non-existent stored session ("session not found") and strands the + // user. Translate runtime -> stored before navigating. + useEffect(() => { + const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => { + if (sessionId) { + navigate(sessionRoute(storedSessionIdForNotification(sessionId, runtimeIdByStoredSessionIdRef.current))) + } + }) + + return () => unsubscribe?.() + }, [navigate, runtimeIdByStoredSessionIdRef]) + + // Notification action button (Approve/Reject) — resolve in place, no navigation. + useEffect(() => { + const unsubscribe = window.hermesDesktop?.onNotificationAction?.(({ actionId, sessionId }) => { + void respondToApprovalAction(sessionId ?? null, actionId) + }) + + return () => unsubscribe?.() + }, []) + + // hermes:// deep links (e.g. a docs "Send to App" button for an automation blueprint). + // Build the equivalent /blueprint slash command from the payload and drop + // it into the composer — the user reviews/edits, then sends; the agent (or + // the shared command handler) creates the job. Signal readiness so a link + // that arrived during boot is flushed exactly once. + useEffect(() => { + const unsubscribe = window.hermesDesktop?.onDeepLink?.(payload => { + if (!payload || payload.kind !== 'blueprint' || !payload.name) { + return + } + + const slots = Object.entries(payload.params || {}) + .map(([k, v]) => { + const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v + + return `${k}=${sval}` + }) + .join(' ') + + const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}` + requestComposerInsert(command, { mode: 'block', target: 'main' }) + requestComposerFocus('main') + }) + + // Tell the main process the renderer is ready to receive deep links. + void window.hermesDesktop?.signalDeepLinkReady?.() + + return () => unsubscribe?.() + }, []) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.altKey || event.shiftKey || event.key.toLowerCase() !== 'w' || (!event.metaKey && !event.ctrlKey)) { + return + } + + // Terminal focused: ⌘W closes the active terminal. Ctrl+W is left untouched + // for the shell's werase, and nothing else may steal ⌘/Ctrl+W from a + // focused terminal (so it never closes a preview tab out from under it). + if (isFocusWithin('[data-terminal]')) { + if (event.metaKey && !event.ctrlKey) { + event.preventDefault() + event.stopPropagation() + closeActiveTerminal() + } + + return + } + + // Otherwise ⌘/Ctrl+W closes the active preview tab when one is open. + if ($filePreviewTarget.get() || $previewTarget.get()) { + event.preventDefault() + event.stopPropagation() + closeActiveRightRailTab() + } + } + + const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(closeActiveRightRailTab) + + window.addEventListener('keydown', onKeyDown, { capture: true }) + + return () => { + unsubscribe?.() + window.removeEventListener('keydown', onKeyDown, { capture: true }) + } + }, []) + + const { + loadMoreMessagingForPlatform, + loadMoreSessions, + loadMoreSessionsForProfile, + refreshCronJobs, + refreshMessagingSessions, + refreshSessions + } = useSessionListActions({ profileScope }) + + // Another window mutated the shared session list (e.g. a chat started in the + // pop-out). Re-pull so the sidebar reflects it. Pop-outs have no sidebar, so + // only real windows bother. + useEffect(() => { + if (isSecondaryWindow()) { + return + } + + return onSessionsChanged(() => void refreshSessions().catch(() => undefined)) + }, [refreshSessions]) + + const toggleSelectedPin = useCallback(() => { + const sessionId = $selectedStoredSessionId.get() + + if (!sessionId) { + return + } + + // Pin on the durable lineage-root id so the pin survives auto-compression. + const session = $sessions.get().find(s => s.id === sessionId || s._lineage_root_id === sessionId) + const pinId = session ? sessionPinId(session) : sessionId + + if ($pinnedSessionIds.get().includes(pinId)) { + unpinSession(pinId) + } else { + pinSession(pinId) + } + }, []) + + const { inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway) + + const updateActiveSessionRuntimeInfo = useCallback( + (info: { branch?: string; cwd?: string }) => { + const sessionId = activeSessionIdRef.current + + if (!sessionId) { + return + } + + updateSessionState(sessionId, state => ({ + ...state, + branch: info.branch ?? state.branch, + cwd: info.cwd ?? state.cwd + })) + }, + [activeSessionIdRef, updateSessionState] + ) + + const { refreshProjectBranch } = useCwdActions({ + activeSessionId, + activeSessionIdRef, + onSessionRuntimeInfo: updateActiveSessionRuntimeInfo, + requestGateway + }) + + const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({ + activeSessionIdRef, + refreshProjectBranch + }) + + const { refreshCurrentModel, selectModel, updateModelOptionsCache } = useModelControls({ + activeSessionId, + queryClient, + requestGateway + }) + + const openProviderSettings = useCallback(() => { + navigate(`${SETTINGS_ROUTE}?tab=providers`) + }, [navigate]) + + const modelMenuContent = useMemo( + () => + gatewayState === 'open' ? ( + + ) : null, + [gatewayRef, gatewayState, requestGateway, selectModel] + ) + + useContextSuggestions({ + activeSessionId, + activeSessionIdRef, + currentCwd, + gatewayState, + requestGateway + }) + + const hydrateFromStoredSession = useCallback( + async ( + attempts = 1, + storedSessionId = selectedStoredSessionIdRef.current, + runtimeSessionId = activeSessionIdRef.current + ) => { + if (!storedSessionId || !runtimeSessionId) { + return + } + + const storedProfile = $sessions + .get() + .find(session => session.id === storedSessionId || session._lineage_root_id === storedSessionId)?.profile + + for (let index = 0; index < Math.max(1, attempts); index += 1) { + try { + const latest = await getSessionMessages(storedSessionId, storedProfile) + const messages = toChatMessages(latest.messages) + updateSessionState( + runtimeSessionId, + state => ({ + ...state, + messages: preserveLocalAssistantErrors(messages, state.messages) + }), + storedSessionId + ) + + // Rehydration runs *after* a turn completes, so an "active" stored + // list (last `todo` still pending/in_progress) means the turn ended + // without a final update — it's stale, not in-flight. Re-seeding it + // would re-pin "Tasks N/M" above the composer and undo the turn-end + // clear (and survive restarts, since it's read back from history). + // todosForHydration restores only a *finished* list (its short linger + // shows the last checkmark); anything still active is dropped. + const restored = todosForHydration(latestSessionTodos(messages)) + + if (restored) { + setSessionTodos(runtimeSessionId, restored) + } else { + clearSessionTodos(runtimeSessionId) + } + + return + } catch { + // Best-effort fallback when live stream payloads are empty. + } + + if (index < attempts - 1) { + await new Promise(resolve => window.setTimeout(resolve, 250)) + } + } + }, + [activeSessionIdRef, selectedStoredSessionIdRef, updateSessionState] + ) + + const refreshActiveMessagingTranscript = useCallback(async () => { + const storedSessionId = selectedStoredSessionIdRef.current + const runtimeSessionId = activeSessionIdRef.current + + if (!storedSessionId || !runtimeSessionId || busyRef.current) { + return + } + + const stored = $messagingSessions.get().find(s => sessionMatchesStoredId(s, storedSessionId)) + + if (!stored || !isMessagingSource(stored.source)) { + return + } + + try { + const latest = await getSessionMessages(storedSessionId, stored.profile) + const signatureKey = `${stored.profile ?? 'default'}:${storedSessionId}` + const sig = sessionMessagesSignature(latest.messages) + + if (messagingTranscriptSignatureRef.current.get(signatureKey) === sig) { + return + } + + messagingTranscriptSignatureRef.current.set(signatureKey, sig) + const messages = toChatMessages(latest.messages) + + updateSessionState( + runtimeSessionId, + state => ({ ...state, messages: preserveLocalAssistantErrors(messages, state.messages) }), + storedSessionId + ) + } catch { + // Non-fatal: next poll or manual refresh can hydrate. + } + }, [activeSessionIdRef, busyRef, selectedStoredSessionIdRef, updateSessionState]) + + const { handleGatewayEvent } = useMessageStream({ + activeSessionIdRef, + hydrateFromStoredSession, + queryClient, + refreshHermesConfig, + refreshSessions, + sessionStateByRuntimeIdRef, + updateSessionState + }) + + const { handleDesktopGatewayEvent, restartPreviewServer } = usePreviewRouting({ + activeSessionIdRef, + baseHandleGatewayEvent: handleGatewayEvent, + currentCwd, + currentView, + requestGateway, + routedSessionId, + selectedStoredSessionId + }) + + const { + archiveAllSessions, + archiveSession, + branchCurrentSession, + branchStoredSession, + createBackendSessionForSend, + openSettings, + removeSession, + resumeSession, + selectSidebarItem, + startFreshSessionDraft + } = useSessionActions({ + activeSessionId, + activeSessionIdRef, + busyRef, + creatingSessionRef, + ensureSessionState, + getRouteToken, + navigate, + requestGateway, + runtimeIdByStoredSessionIdRef, + selectedStoredSessionId, + selectedStoredSessionIdRef, + sessionStateByRuntimeIdRef, + syncSessionStateToView, + updateSessionState + }) + + // Single global listener for every rebindable hotkey (incl. profile switching) + // plus the on-screen keybind editor's capture mode. + useKeybinds({ + startFreshSession: startFreshSessionDraft, + toggleCommandCenter, + toggleSelectedPin + }) + + // A profile switch/create drops to a fresh new-session draft so the previously + // open session doesn't bleed across contexts. Skip the initial value. + const freshSessionRequest = useStore($freshSessionRequest) + const lastFreshRef = useRef(freshSessionRequest) + + useEffect(() => { + if (freshSessionRequest === lastFreshRef.current) { + return + } + + lastFreshRef.current = freshSessionRequest + startFreshSessionDraft() + }, [freshSessionRequest, startFreshSessionDraft]) + + // Swapping the live gateway to another profile must re-pull that profile's + // global model + active-profile pill. Both are nanostores, so the blanket + // invalidateQueries() the profile store fires on swap doesn't touch them — + // without this the statusbar keeps showing the previous profile's model + // (the "forgets the LLM setting" report). gatewayState stays 'open' across a + // swap (background sockets persist), so the open→open effect won't re-run. + const activeGatewayProfile = useStore($activeGatewayProfile) + const lastGatewayProfileRef = useRef(activeGatewayProfile) + + useEffect(() => { + if (activeGatewayProfile === lastGatewayProfileRef.current) { + return + } + + lastGatewayProfileRef.current = activeGatewayProfile + // Force: the new profile has its own default, so reseed even if the composer + // already shows the previous profile's model. + void refreshCurrentModel(true) + void refreshActiveProfile() + }, [activeGatewayProfile, refreshCurrentModel]) + + const composer = useComposerActions({ + activeSessionId, + currentCwd, + requestGateway + }) + + const branchInNewChat = useCallback( + async (messageId?: string) => { + const branched = await branchCurrentSession(messageId) + + if (branched) { + await refreshSessions().catch(() => undefined) + } + + return branched + }, + [branchCurrentSession, refreshSessions] + ) + + // Clear a failed turn's red error banner from the transcript. Errors are + // renderer-local state (never persisted), so dismissing is purely a view + + // session-cache edit. A message that errored before emitting any visible + // text is a bare error placeholder → drop it entirely; one that streamed + // partial output then failed keeps its content and just sheds the error. + // Both the per-runtime cache AND the live $messages view must be updated: + // `preserveLocalAssistantErrors` re-grafts any still-errored message it + // finds in the view onto the next session.info flush, so clearing only the + // cache would let the heartbeat resurrect the banner. + const dismissError = useCallback( + (messageId: string) => { + const runtimeSessionId = activeSessionIdRef.current + + if (!runtimeSessionId) { + return + } + + const clearErrorIn = (messages: ChatMessage[]): ChatMessage[] => + messages.flatMap(message => { + if (message.id !== messageId || !message.error) { + return [message] + } + + if (!chatMessageText(message).trim() && !message.parts.some(part => part.type !== 'text')) { + return [] + } + + return [{ ...message, error: undefined, pending: false }] + }) + + // View first: the flush below reads $messages as the "current" baseline + // for error preservation, so the banner must be gone from it before the + // cache update triggers a re-sync. + setMessages(clearErrorIn($messages.get())) + + updateSessionState(runtimeSessionId, state => ({ + ...state, + messages: clearErrorIn(state.messages) + })) + }, + [activeSessionIdRef, updateSessionState] + ) + + const startSessionInWorkspace = useCallback( + (path: null | string) => { + startFreshSessionDraft() + + // A worktree lane carries its own path; the trunk "+" can be path-less (the + // main checkout is implicit), so fall back to the active project's root + // instead of no-op'ing on null — that was "+ on main does nothing". + const target = path?.trim() || resolveNewSessionCwd() + + if (!target) { + return + } + + // The next message creates the backend session in $currentCwd, so seed + // it (and the branch) from the workspace the user clicked the + on. + setCurrentCwd(target) + void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target }) + .then(info => { + const resolved = info.cwd || target + + setCurrentCwd(resolved) + setCurrentBranch(info.branch || '') + + // An EXPLICIT target (a worktree/lane path — e.g. just-created via + // "convert a branch" / "new worktree") drills the sidebar into that + // project so the new lane is visible at once. Without this, a brand-new + // worktree session is invisible from the all-projects overview (the + // live overlay skips `.worktrees` rows, and the session.info cwd-follow + // only fires on a same-session move, not a fresh session). The + // path-less trunk "+" keeps the current scope untouched. + if (path?.trim()) { + restoreWorktree(resolved) + void followActiveSessionCwd(resolved) + } + }) + .catch(() => undefined) + }, + [requestGateway, startFreshSessionDraft] + ) + + // Composer "branch off into a new worktree": the composer already created the + // worktree and cleared its draft; open a fresh session anchored to that tree, + // then prefill the task that kicked it off. startSessionInWorkspace owns the + // reset+cwd seed (it runs startFreshSessionDraft, which would otherwise stomp + // the cwd back to the default), so the prefill is dispatched right after — its + // deferred event lands once the fresh composer has remounted and rebound. + const startWorkSessionRequest = useStore($startWorkSessionRequest) + const lastStartWorkTokenRef = useRef(startWorkSessionRequest?.token ?? 0) + + useEffect(() => { + if (!startWorkSessionRequest || startWorkSessionRequest.token === lastStartWorkTokenRef.current) { + return + } + + lastStartWorkTokenRef.current = startWorkSessionRequest.token + startSessionInWorkspace(startWorkSessionRequest.path) + + if (startWorkSessionRequest.draft) { + requestComposerInsert(startWorkSessionRequest.draft, { target: 'main' }) + } + }, [startSessionInWorkspace, startWorkSessionRequest]) + + const handleSkinCommand = useSkinCommand() + + const { + cancelRun, + editMessage, + handleThreadMessagesChange, + reloadFromMessage, + restoreToMessage, + steerPrompt, + submitText, + transcribeVoiceAudio + } = usePromptActions({ + activeSessionId, + activeSessionIdRef, + branchCurrentSession: branchInNewChat, + busyRef, + createBackendSessionForSend, + handleSkinCommand, + openMemoryGraph: openStarmap, + refreshSessions, + requestGateway, + resumeStoredSession: resumeSession, + selectedStoredSessionIdRef, + startFreshSessionDraft, + sttEnabled, + updateSessionState + }) + + // The popped-out pet drives two actions back into the app: send a prompt, and + // open the most recent thread. Both are registered ONCE through refs that track + // the latest callbacks — re-registering on every `submitText`/`resumeSession` + // identity change left a brief window where the handler was nulled (cleanup + // before re-register), which could drop a submit fired from the overlay (e.g. + // creating a session from the new-session screen). The ref form keeps a stable, + // always-current handler. Primary window only — it owns the overlay. + const submitTextRef = useRef(submitText) + submitTextRef.current = submitText + const resumeSessionRef = useRef(resumeSession) + resumeSessionRef.current = resumeSession + const requestGatewayRef = useRef(requestGateway) + requestGatewayRef.current = requestGateway + + useEffect(() => { + if (isSecondaryWindow()) { + return + } + + setPetOverlaySubmitHandler(text => void submitTextRef.current(text)) + // Alt+wheel resize from the popped-out pet — persist it through this + // window's gateway (the overlay has none) so it survives restart. + setPetOverlayScaleHandler(scale => setPetScale(requestGatewayRef.current, scale)) + // Mail icon: $sessions is ordered most-recent-first; the pet is global (not + // per session) so "most recent" is the right target. main.cjs already raised + // the window before forwarding this. + setPetOverlayOpenAppHandler(() => { + const recent = $sessions.get()[0] + + if (recent?.id) { + void resumeSessionRef.current(recent.id) + } + }) + + return () => { + setPetOverlaySubmitHandler(null) + setPetOverlayOpenAppHandler(null) + setPetOverlayScaleHandler(null) + } + }, []) + + // Mirror "a session is blocked on the user" (clarify/approval) into the pet's + // awaitingInput flag so it shows the `waiting` pose. Lives on $petActivity so + // it rides the same atom the pop-out overlay mirrors — no session list needed + // there. Every window keeps its own in-window pet in sync. + useEffect(() => { + const sync = () => setPetActivity({ awaitingInput: $attentionSessionIds.get().length > 0 }) + + sync() + + return $attentionSessionIds.listen(sync) + }, []) + + useGatewayBoot({ + handleGatewayEvent: handleDesktopGatewayEvent, + onConnectionReady: c => { + connectionRef.current = c + }, + onGatewayReady: g => { + gatewayRef.current = g + }, + refreshHermesConfig, + refreshSessions + }) + + useEffect(() => { + if (gatewayState === 'open') { + void refreshCurrentModel() + void refreshActiveProfile() + void refreshSessions().catch(() => undefined) + } + }, [gatewayState, refreshCurrentModel, refreshSessions]) + + // Keep the cron jobs section live without a user action: the scheduler ticks + // in the background (advancing next-run/state and creating runs), so poll the + // job list on an interval (and on tab re-focus) while connected. + useEffect(() => { + if (gatewayState !== 'open') { + return + } + + const tick = () => { + if (document.visibilityState === 'visible') { + void refreshCronJobs() + } + } + + const intervalId = window.setInterval(tick, CRON_POLL_INTERVAL_MS) + document.addEventListener('visibilitychange', tick) + + return () => { + window.clearInterval(intervalId) + document.removeEventListener('visibilitychange', tick) + } + }, [gatewayState, refreshCronJobs]) + + // Keep messaging-platform session lists live: inbound Telegram/WeChat/Discord + // turns are written by the gateway, not the desktop websocket, so they won't + // appear without polling. + useEffect(() => { + if (gatewayState !== 'open') { + return + } + + const tick = () => { + if (document.visibilityState === 'visible') { + void refreshMessagingSessions() + } + } + + const intervalId = window.setInterval(tick, MESSAGING_POLL_INTERVAL_MS) + document.addEventListener('visibilitychange', tick) + + return () => { + window.clearInterval(intervalId) + document.removeEventListener('visibilitychange', tick) + } + }, [gatewayState, refreshMessagingSessions]) + + // Only the open messaging transcript needs a poll — local chats are already + // live over the websocket, so arming a timer for them would just no-op every + // tick. Gate on the active session actually being a messaging source. + const activeIsMessaging = + !!selectedStoredSessionId && + isMessagingSource(messagingSessions.find(s => sessionMatchesStoredId(s, selectedStoredSessionId))?.source) + + // Keep the currently-viewed messaging transcript live. + useEffect(() => { + if (gatewayState !== 'open' || !activeIsMessaging) { + return + } + + const tick = () => { + if (document.visibilityState === 'visible') { + void refreshActiveMessagingTranscript() + } + } + + const intervalId = window.setInterval(tick, ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS) + document.addEventListener('visibilitychange', tick) + tick() + + return () => { + window.clearInterval(intervalId) + document.removeEventListener('visibilitychange', tick) + } + }, [activeIsMessaging, gatewayState, refreshActiveMessagingTranscript]) + + useEffect(() => { + if (gatewayState === 'open' && !activeSessionId && freshDraftReady) { + void refreshCurrentModel() + void refreshHermesConfig() + } + }, [activeSessionId, freshDraftReady, gatewayState, refreshCurrentModel, refreshHermesConfig]) + + useRouteResume({ + activeSessionId, + activeSessionIdRef, + creatingSessionRef, + currentView, + freshDraftReady, + gatewayState, + locationPathname: location.pathname, + resumeSession, + resumeFailedSessionId, + resumeExhaustedSessionId, + routedSessionId, + runtimeIdByStoredSessionIdRef, + selectedStoredSessionId, + selectedStoredSessionIdRef, + startFreshSessionDraft + }) + + const { leftStatusbarItems, statusbarItems } = useStatusbarItems({ + agentsOpen, + chatOpen, + commandCenterOpen, + extraLeftItems: statusbarItemGroups.flat.left, + extraRightItems: statusbarItemGroups.flat.right, + gatewayState, + inferenceStatus, + openAgents, + freshDraftReady, + openCommandCenterSection, + requestGateway, + statusSnapshot, + toggleCommandCenter + }) + + const sidebar = ( + archiveAllSessions().then(() => refreshSessions())} + onArchiveSession={sessionId => void archiveSession(sessionId)} + onBranchSession={sessionId => void branchStoredSession(sessionId)} + onDeleteSession={sessionId => void removeSession(sessionId)} + onLoadMoreMessaging={loadMoreMessagingForPlatform} + onLoadMoreProfileSessions={loadMoreSessionsForProfile} + onLoadMoreSessions={loadMoreSessions} + onManageCronJob={jobId => { + setCronFocusJobId(jobId) + navigate(CRON_ROUTE) + }} + onNavigate={selectSidebarItem} + onNewSessionInWorkspace={startSessionInWorkspace} + onResumeSession={sessionId => navigate(sessionRoute(sessionId))} + onTriggerCronJob={jobId => { + void triggerCronJob(jobId) + .then(() => refreshCronJobs()) + .catch(() => undefined) + }} + /> + ) + + // The persistent xterm layer (one host per terminal tab), CSS-overlaid onto the + // pane's . Lives in main's stacking context (not the root overlay + // layer) so pane resize handles still paint above it. Terminals own their state + // (incl. a snapshotted cwd) independent of the session, so switching sessions + // never rebuilds or closes them; toggling the pane never rebuilds the shells. + const mainOverlays = + + const overlays = ( + <> + + {!isSecondaryWindow() && } + {!isSecondaryWindow() && ( + { + void refreshHermesConfig() + void refreshCurrentModel() + void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + }} + requestGateway={requestGateway} + /> + )} + + + + + + + + + + + + + {settingsOpen && ( + + { + void refreshHermesConfig() + void refreshCurrentModel() + void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + }} + onMainModelChanged={(provider, model) => { + setCurrentProvider(provider) + setCurrentModel(model) + updateModelOptionsCache(provider, model, true) + void refreshCurrentModel() + void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + }} + /> + + )} + + {commandCenterOpen && ( + + navigate(path)} + onOpenSession={sessionId => navigate(sessionRoute(sessionId))} + /> + + )} + + {agentsOpen && ( + + + + )} + + {cronOpen && ( + + navigate(sessionRoute(sessionId))} + /> + + )} + + {profilesOpen && ( + + + + )} + + {starmapOpen && ( + + + + )} + + ) + + const chatView = ( + composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)} + onAttachDroppedItems={composer.attachDroppedItems} + onAttachImageBlob={composer.attachImageBlob} + onBranchInNewChat={branchInNewChat} + onCancel={cancelRun} + onDeleteSelectedSession={() => { + if (selectedStoredSessionId) { + void removeSession(selectedStoredSessionId) + } + }} + onDismissError={dismissError} + onEdit={editMessage} + onPasteClipboardImage={opts => composer.pasteClipboardImage(opts)} + onPickFiles={() => void composer.pickContextPaths('file')} + onPickFolders={() => void composer.pickContextPaths('folder')} + onPickImages={() => void composer.pickImages()} + onReload={reloadFromMessage} + onRemoveAttachment={id => void composer.removeAttachment(id)} + onRestoreToMessage={restoreToMessage} + onRetryResume={sessionId => void resumeSession(sessionId, true)} + onSteer={steerPrompt} + onSubmit={submitText} + onThreadMessagesChange={handleThreadMessagesChange} + onToggleSelectedPin={toggleSelectedPin} + onTranscribeAudio={transcribeVoiceAudio} + /> + ) + + // Flipped layout mirrors the default: sessions sidebar → right, file + // browser + preview rail → left. Same panes, swapped sides. + const sidebarSide = panesFlipped ? 'right' : 'left' + const railSide = panesFlipped ? 'left' : 'right' + + // Other sidebars docked as real columns on the terminal's rail. Force-collapsed + // hover-reveal overlays (narrow window) don't take a column, so they don't count. + const railColumnOpen = + (chatOpen && Boolean(previewTarget || filePreviewTarget) && previewPaneOpen) || + (chatOpen && !narrowViewport && fileBrowserOpen) || + (chatOpen && Boolean(currentCwd.trim()) && !narrowViewport && reviewOpen) + + // Once the terminal would share its rail with another sidebar, drop it to a + // full-width row beneath them rather than cramming in one more skinny column. + const terminalAsRow = terminalSidebarOpen && railColumnOpen + + const previewPane = ( + + {chatOpen ? ( + + ) : null} + + ) + + const fileBrowserPane = ( + + {/* Key on the project (cwd) so switching projects unmounts the old tree and + mounts a fresh one straight into its skeleton — no stale-then-blip. */} + composer.insertContextPathInlineRef(path)} + onActivateFolder={path => composer.insertContextPathInlineRef(path, true)} + /> + + ) + + const reviewPane = ( + + + + ) + + const terminalPane = ( + + {/* As a column the terminal clears the titlebar; as a bottom row it sits + below the rail's panes (so it fills its row edge-to-edge) and gets a + left border separating it from the chat — the column-mode separator + lives on the resize sash, which moves to the top edge as a row. */} +
+ +
+
+ ) + + return ( + + {!isSecondaryWindow() && ( + + {sidebar} + + )} + + + + + + + + } + path="skills" + /> + + + + } + path="messaging" + /> + + + + } + path="artifacts" + /> + + + + + + } path="new" /> + } path="sessions/:sessionId" /> + } path="*" /> + + + {/* + Order within a side maps to column order. Default (rail on the right): + main | terminal | preview | file-browser. Flipped (rail on the left): + mirror to file-browser | preview | terminal | main so terminal stays + adjacent to the chat. + */} + {panesFlipped ? fileBrowserPane : terminalPane} + {previewPane} + {reviewPane} + {panesFlipped ? terminalPane : fileBrowserPane} + + ) +} + +function LegacySessionRedirect() { + const { sessionId } = useParams() + + return +} 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 266a33b65fd2..5fbd54e57aa8 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 @@ -2,8 +2,12 @@ import { useStore } from '@nanostores/react' import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' import type { NavigateFunction } from 'react-router-dom' +<<<<<<< HEAD import { revealTreePane } from '@/components/pane-shell/tree/store' import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes' +======= +import { bulkArchiveSessions, deleteSession, getSessionMessages, setSessionArchived } from '@/hermes' +>>>>>>> 239cbaba6 (Refresh onto current upstream/main (no behavior change)) import { useI18n } from '@/i18n' import { type ChatMessage, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages' import { isMissingRpcMethod } from '@/lib/gateway-rpc' @@ -13,7 +17,12 @@ import { migrateSessionDraft } from '@/store/composer' import { clearQueuedPrompts, migrateQueuedPrompts } from '@/store/composer-queue' import { $pinnedSessionIds } from '@/store/layout' import { clearNotifications, notify, notifyError } from '@/store/notifications' +<<<<<<< HEAD import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' +======= +import { $activeGatewayProfile, $newChatProfile, $profileScope, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' +import { resolveNewSessionCwd, tombstoneSessions, untombstoneSessions } from '@/store/projects' +>>>>>>> 239cbaba6 (Refresh onto current upstream/main (no behavior change)) import { beginSessionMutation, endSessionMutation, @@ -31,6 +40,8 @@ import { $messages, $newChatWorkspaceTarget, $sessions, + $sessionsTotal, + $workingSessionIds, $yoloActive, type NewChatWorkspaceTarget, resolveComposerSessionKey, @@ -67,7 +78,11 @@ import { } from '@/store/session-states' import { broadcastSessionsChanged } from '@/store/session-sync' import { isWatchWindow } from '@/store/windows' +<<<<<<< HEAD import type { SessionCreateResponse, SessionMessage, SessionResumeResponse, UsageStats } from '@/types/hermes' +======= +import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes' +>>>>>>> 239cbaba6 (Refresh onto current upstream/main (no behavior change)) import { navigateToWorkspacePage, NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../../routes' import type { ClientSessionState, SidebarNavItem } from '../../../types' @@ -1449,7 +1464,53 @@ export function useSessionActions({ [copy, runtimeIdByStoredSessionIdRef, selectedStoredSessionId, sessionStateByRuntimeIdRef, startFreshSessionDraft] ) + const archiveAllSessions = useCallback(async () => { + clearNotifications() + + const previousSessions = $sessions.get() + const previousTotal = $sessionsTotal.get() + const preserveIds = new Set([...$pinnedSessionIds.get(), ...$workingSessionIds.get()]) + + if (selectedStoredSessionId) { + preserveIds.add(selectedStoredSessionId) + } + + if (activeSessionId) { + preserveIds.add(activeSessionId) + } + + for (const session of previousSessions) { + if (session.id === selectedStoredSessionId || session.id === activeSessionId) { + preserveIds.add(sessionPinId(session)) + } + } + + const shouldPreserve = (session: SessionInfo) => + preserveIds.has(session.id) || (session._lineage_root_id != null && preserveIds.has(session._lineage_root_id)) + + const keptSessions = previousSessions.filter(shouldPreserve) + setSessions(keptSessions) + setSessionsTotal(keptSessions.length) + + try { + const result = await bulkArchiveSessions([...preserveIds], $profileScope.get()) + notify({ + durationMs: 2_500, + kind: 'success', + message: result.archived === 1 ? 'Archived 1 session' : `Archived ${result.archived} sessions` + }) + + return result + } catch (err) { + setSessions(previousSessions) + setSessionsTotal(previousTotal) + notifyError(err, 'Archive all failed') + throw err + } + }, [activeSessionId, selectedStoredSessionId]) + return { + archiveAllSessions, archiveSession, branchCurrentSession, branchStoredSession, diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts index a1dc61c6095f..15f435b33468 100644 --- a/apps/desktop/src/hermes.test.ts +++ b/apps/desktop/src/hermes.test.ts @@ -1,12 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { +<<<<<<< HEAD AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS, AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS, AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS, AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS, audioSpeakRequestTimeoutMs, audioTranscribeRequestTimeoutMs, +======= + bulkArchiveSessions, +>>>>>>> 239cbaba6 (Refresh onto current upstream/main (no behavior change)) getCronJobs, getGlobalModelInfo, getGlobalModelOptions, @@ -411,3 +415,50 @@ describe('Hermes REST helpers', () => { ) }) }) + +describe('bulkArchiveSessions', () => { + const originalHermesDesktop = window.hermesDesktop + + afterEach(() => { + vi.restoreAllMocks() + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: originalHermesDesktop, + writable: true + }) + }) + + it('posts deduped preserve ids to the manual bulk archive endpoint', async () => { + const api = vi.fn().mockResolvedValue({ ok: true, archived: 12 }) + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { api }, + writable: true + }) + + await expect(bulkArchiveSessions(['pin', '', 'current', 'pin'])).resolves.toEqual({ ok: true, archived: 12 }) + + expect(api).toHaveBeenCalledWith({ + path: '/api/sessions/bulk-archive', + method: 'POST', + body: { preserve_ids: ['pin', 'current'] } + }) + }) + + it('passes the visible profile scope to the bulk archive endpoint', async () => { + const api = vi.fn().mockResolvedValue({ ok: true, archived: 3 }) + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { api }, + writable: true + }) + + await bulkArchiveSessions(['pin'], '__all__') + + expect(api).toHaveBeenCalledWith({ + path: '/api/sessions/bulk-archive', + method: 'POST', + body: { preserve_ids: ['pin'], profile: '__all__' } + }) + }) +}) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index f94f0d2d741f..d9e4a5246f3d 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -573,6 +573,27 @@ export async function listSidebarSessions(req: SidebarSessionsRequest): Promise< } } +export function bulkArchiveSessions( + preserveIds: string[] = [], + profile?: null | string +): Promise<{ ok: boolean; archived: number }> { + const body: { preserve_ids: string[]; profile?: string } = { + preserve_ids: Array.from(new Set(preserveIds.filter(Boolean))).slice(0, 5000) + } + + const scopedProfile = profile?.trim() + + if (scopedProfile) { + body.profile = scopedProfile + } + + return window.hermesDesktop.api<{ ok: boolean; archived: number }>({ + path: '/api/sessions/bulk-archive', + method: 'POST', + body + }) +} + // Mutations take the owning `profile` so Electron routes them to that profile's // backend (remote pool or local primary) via request.profile — matching the // read path. A remote session's row lives only on its remote host, so a mutation diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index b6cad13476e8..5447d0b6ff05 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -11545,12 +11545,107 @@ def started(row): # templated ``/api/sessions/{session_id}`` family that follows. FastAPI/ # Starlette match routes in registration order, and the ``{session_id}`` # pattern is unconstrained — it would otherwise swallow e.g. -# ``DELETE /api/sessions/empty``, ``POST /api/sessions/bulk-delete``, or -# ``GET /api/sessions/stats`` as "operate on the session with id -# 'empty'" / "'bulk-delete'" / "'stats'", which would 404 (or worse, -# succeed and delete the wrong row). Same story as the older -# ``/api/sessions/search`` endpoint up at line ~1191. If you split or -# reorder this block, move every route in it together. +# ``DELETE /api/sessions/empty``, ``POST /api/sessions/bulk-delete``, +# ``POST /api/sessions/bulk-archive``, or ``GET /api/sessions/stats`` as +# "operate on the session with id 'empty'" / "'bulk-delete'" / +# "'bulk-archive'" / "'stats'", which would 404 (or worse, succeed and +# update the wrong row). Same story as the older ``/api/sessions/search`` +# endpoint up at line ~1191. If you split or reorder this block, move every +# route in it together. + +class BulkArchiveSessions(BaseModel): + preserve_ids: Optional[List[str]] = None + min_messages: Optional[int] = 1 + active_grace_seconds: Optional[int] = None + profile: Optional[str] = None + + +@app.post("/api/sessions/bulk-archive") +async def bulk_archive_sessions_endpoint(body: BulkArchiveSessions): + """Soft-archive all normal sessions except caller-preserved rows. + + The desktop can pass pinned IDs, the selected chat, and running session + IDs in ``preserve_ids`` and then hide everything else from the regular + sidebar without deleting the underlying transcript. When the sidebar is in + the unified profiles view, ``profile="all"``/``"__all__"`` archives the + matching rows across profile DBs without spawning profile backends. + """ + preserve_ids = [ + str(sid).strip() + for sid in (body.preserve_ids or []) + if str(sid).strip() + ] + if len(preserve_ids) > 5000: + raise HTTPException( + status_code=400, + detail="preserve_ids must contain at most 5000 entries", + ) + + active_grace_seconds = ( + body.active_grace_seconds + if body.active_grace_seconds is not None + else 0 + ) + + profile_scope = str(body.profile or "").strip() + min_message_count = max( + 0, + int(body.min_messages if body.min_messages is not None else 1), + ) + grace_seconds = max(0, int(active_grace_seconds or 0)) + + from hermes_state import SessionDB + + def _archive_db(db_path: Optional[Path] = None) -> int: + kwargs = {"db_path": db_path} if db_path is not None else {} + db = SessionDB(**kwargs) + try: + return db.archive_surfaced_sessions( + preserve_ids=preserve_ids, + min_message_count=min_message_count, + active_grace_seconds=grace_seconds, + ) + finally: + db.close() + + if profile_scope in ("all", "__all__"): + from hermes_cli import profiles as profiles_mod + + try: + targets = [(info.name, info.path) for info in profiles_mod.list_profiles()] + except Exception: + _log.exception("POST /api/sessions/bulk-archive: list_profiles failed") + targets = [] + if not targets: + targets = [("default", profiles_mod.get_profile_dir("default"))] + + archived = 0 + for _name, home in targets: + db_path = Path(home) / "state.db" + if db_path.exists(): + archived += _archive_db(db_path) + return {"ok": True, "archived": archived} + + if profile_scope: + _name, home = _cron_profile_home(profile_scope) + db_path = Path(home) / "state.db" + if not db_path.exists(): + return {"ok": True, "archived": 0} + return {"ok": True, "archived": _archive_db(db_path)} + + return {"ok": True, "archived": _archive_db()} + + +class BulkDeleteSessions(BaseModel): + ids: List[str] + profile: Optional[str] = None + + +class SessionImport(BaseModel): + sessions: List[Dict[str, Any]] + profile: Optional[str] = None + + # Keep the dashboard import endpoint stream-safe: FastAPI otherwise parses and # buffers an arbitrarily large JSON body before SessionDB can enforce its own # per-session and transaction-work limits. diff --git a/hermes_state.py b/hermes_state.py index 97c14ea214fd..fc3acb2a1e65 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -4532,6 +4532,92 @@ def _do(conn): rowcount = self._execute_write(_do) return rowcount > 0 + def archive_sessions(self, session_ids: List[str]) -> int: + """Soft-archive the listed sessions in one or more bounded updates. + + Unknown IDs are skipped. Returns the number of rows that actually + moved from active to archived. + """ + unique_ids = list({sid for sid in session_ids if isinstance(sid, str) and sid}) + if not unique_ids: + return 0 + + def _do(conn): + updated = 0 + for start in range(0, len(unique_ids), 500): + chunk = unique_ids[start:start + 500] + placeholders = ",".join("?" for _ in chunk) + cursor = conn.execute( + f"UPDATE sessions SET archived = 1 " + f"WHERE archived = 0 AND id IN ({placeholders})", + chunk, + ) + updated += cursor.rowcount + return updated + + return self._execute_write(_do) + + def archive_surfaced_sessions( + self, + *, + preserve_ids: Optional[List[str]] = None, + min_message_count: int = 1, + active_grace_seconds: int = 0, + ) -> int: + """Soft-archive every surfaced, unarchived conversation not preserved. + + This backs an explicit desktop "archive all" action. The caller passes + client-owned preserved IDs such as pins, the selected chat, and running + sessions. A recency grace is opt-in for callers that want extra + protection for open-ended sessions. Compression continuations are + archived by lineage root so one logical conversation moves together. + """ + preserved = { + str(sid).strip() + for sid in (preserve_ids or []) + if str(sid).strip() + } + min_message_count = max(0, int(min_message_count or 0)) + active_grace_seconds = max(0, int(active_grace_seconds or 0)) + now = time.time() + archive_ids: List[str] = [] + seen_targets = set() + + sessions = self.list_sessions_rich( + limit=100000, + offset=0, + min_message_count=min_message_count, + include_archived=False, + archived_only=False, + order_by_last_active=True, + ) + for session in sessions: + sid = str(session.get("id") or "").strip() + if not sid: + continue + root_id = str(session.get("_lineage_root_id") or sid).strip() + target_id = root_id or sid + if target_id in seen_targets: + continue + seen_targets.add(target_id) + if sid in preserved or target_id in preserved: + continue + + started_at = float(session.get("started_at") or 0) + last_active = float(session.get("last_active") or started_at) + ended_at = session.get("ended_at") + recently_active = ( + ended_at is None + and active_grace_seconds > 0 + and now - last_active < active_grace_seconds + ) + if recently_active: + continue + + archive_ids.append(target_id) + + return self.archive_sessions(archive_ids) + def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]: """Look up a session by exact title. Returns session dict or None.""" with self._read_ctx() as conn: diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 8742e4ef7cf3..98aeb9fce06c 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2174,6 +2174,172 @@ def test_archive_session_via_patch(self): restored = self.client.get("/api/sessions").json() assert any(s["id"] == "arch-me" for s in restored["sessions"]) + def test_bulk_archive_endpoint_preserves_ids_and_recent_live_sessions(self): + import time as _time + + from hermes_state import SessionDB + + def _seed(db, sid: str, last_active: float, *, ended: bool = True): + db.create_session(session_id=sid, source="cli") + db.append_message(session_id=sid, role="user", content=f"hello {sid}") + if ended: + db.end_session(sid, end_reason="done") + db._conn.execute( + "UPDATE sessions SET started_at = ?, ended_at = ? WHERE id = ?", + (last_active - 10, last_active if ended else None, sid), + ) + db._conn.execute( + "UPDATE messages SET timestamp = ? WHERE session_id = ?", + (last_active, sid), + ) + + now = _time.time() + db = SessionDB() + try: + _seed(db, "pinned", now - 500) + _seed(db, "current", now - 400) + _seed(db, "recent-live", now, ended=False) + _seed(db, "old-a", now - 300) + _seed(db, "old-b", now - 200) + db._conn.commit() + finally: + db.close() + + resp = self.client.post( + "/api/sessions/bulk-archive", + json={ + "preserve_ids": ["pinned", "current"], + "min_messages": 1, + "active_grace_seconds": 300, + }, + ) + + assert resp.status_code == 200 + assert resp.json() == {"ok": True, "archived": 2} + + listed = self.client.get("/api/sessions?limit=10").json()["sessions"] + listed_ids = {s["id"] for s in listed} + assert {"pinned", "current", "recent-live"}.issubset(listed_ids) + assert "old-a" not in listed_ids + assert "old-b" not in listed_ids + + archived = self.client.get( + "/api/sessions?archived=only&limit=10" + ).json()["sessions"] + archived_ids = {s["id"] for s in archived} + assert {"old-a", "old-b"}.issubset(archived_ids) + assert "pinned" not in archived_ids + assert "current" not in archived_ids + assert "recent-live" not in archived_ids + + def test_bulk_archive_endpoint_archives_open_ended_rows_by_default(self): + import time as _time + + from hermes_state import SessionDB + + now = _time.time() + db = SessionDB() + try: + db.create_session(session_id="stale-open", source="cli") + db.append_message(session_id="stale-open", role="user", content="hello") + db._conn.execute( + "UPDATE sessions SET started_at = ?, ended_at = ? WHERE id = ?", + (now - 10, None, "stale-open"), + ) + db._conn.execute( + "UPDATE messages SET timestamp = ? WHERE session_id = ?", + (now, "stale-open"), + ) + db._conn.commit() + finally: + db.close() + + resp = self.client.post( + "/api/sessions/bulk-archive", + json={"preserve_ids": [], "min_messages": 1}, + ) + + assert resp.status_code == 200 + assert resp.json() == {"ok": True, "archived": 1} + + listed = self.client.get("/api/sessions?limit=10").json()["sessions"] + assert "stale-open" not in {s["id"] for s in listed} + + archived = self.client.get( + "/api/sessions?archived=only&limit=10" + ).json()["sessions"] + assert "stale-open" in {s["id"] for s in archived} + + def test_bulk_archive_endpoint_archives_all_profile_scope(self): + import time as _time + + from hermes_constants import get_hermes_home + from hermes_cli.profiles import get_profile_dir + from hermes_state import SessionDB + + def _seed(db, sid: str, last_active: float): + db.create_session(session_id=sid, source="cli") + db.append_message(session_id=sid, role="user", content=f"hello {sid}") + db.end_session(sid, end_reason="done") + db._conn.execute( + "UPDATE sessions SET started_at = ?, ended_at = ? WHERE id = ?", + (last_active - 10, last_active, sid), + ) + db._conn.execute( + "UPDATE messages SET timestamp = ? WHERE session_id = ?", + (last_active, sid), + ) + + def _archived_ids(db_path=None): + kwargs = {"db_path": db_path} if db_path is not None else {} + db = SessionDB(**kwargs) + try: + rows = db.list_sessions_rich( + limit=20, + min_message_count=1, + include_archived=False, + archived_only=True, + ) + return {row["id"] for row in rows} + finally: + db.close() + + now = _time.time() + get_hermes_home().mkdir(parents=True, exist_ok=True) + profile_dir = get_profile_dir("research") + profile_dir.mkdir(parents=True, exist_ok=True) + profile_db = profile_dir / "state.db" + + default_db = SessionDB() + try: + _seed(default_db, "default-old", now - 500) + _seed(default_db, "default-keep", now - 400) + default_db._conn.commit() + finally: + default_db.close() + + named_db = SessionDB(db_path=profile_db) + try: + _seed(named_db, "research-old", now - 300) + _seed(named_db, "research-keep", now - 200) + named_db._conn.commit() + finally: + named_db.close() + + resp = self.client.post( + "/api/sessions/bulk-archive", + json={ + "preserve_ids": ["default-keep", "research-keep"], + "min_messages": 1, + "profile": "__all__", + }, + ) + + assert resp.status_code == 200 + assert resp.json() == {"ok": True, "archived": 2} + assert _archived_ids() == {"default-old"} + assert _archived_ids(profile_db) == {"research-old"} + def test_patch_session_without_fields_is_400(self): """An existing session + empty body is a bad request, not a 404.""" from hermes_state import SessionDB From bc62d7605a61336b9a838e3de40f0f6afc004ad0 Mon Sep 17 00:00:00 2001 From: Omar Baradei Date: Thu, 9 Jul 2026 17:22:35 -0700 Subject: [PATCH 2/4] rename ID-based archive helper to archive_sessions_by_ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream main added a filter-driven SessionDB.archive_sessions( older_than_days, source, **filters) after this branch introduced an ID-list-based method of the same name. Both definitions coexisted in the class after the upstream merge with no textual conflict, and the later (filter-driven) def silently shadowed this branch's method, so archive_surfaced_sessions passed its ID list into older_than_days and list_prune_candidates raised TypeError (float - list) — the CI failure in the three bulk-archive endpoint tests. Rename this branch's method to archive_sessions_by_ids and point archive_surfaced_sessions at it; the upstream filter-driven API keeps the archive_sessions name. Co-Authored-By: Claude Fable 5 --- hermes_state.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index fc3acb2a1e65..aa87b44dafad 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -4532,11 +4532,13 @@ def _do(conn): rowcount = self._execute_write(_do) return rowcount > 0 - def archive_sessions(self, session_ids: List[str]) -> int: + def archive_sessions_by_ids(self, session_ids: List[str]) -> int: """Soft-archive the listed sessions in one or more bounded updates. Unknown IDs are skipped. Returns the number of rows that actually - moved from active to archived. + moved from active to archived. Distinct from the filter-driven + :meth:`archive_sessions`, which selects rows by prune-style filters + rather than an explicit ID list. """ unique_ids = list({sid for sid in session_ids if isinstance(sid, str) and sid}) if not unique_ids: @@ -4616,7 +4618,7 @@ def archive_surfaced_sessions( archive_ids.append(target_id) - return self.archive_sessions(archive_ids) + return self.archive_sessions_by_ids(archive_ids) def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]: """Look up a session by exact title. Returns session dict or None.""" From b5842ea3b142157b6681e59269a52e7e0714fa69 Mon Sep 17 00:00:00 2001 From: Omar Baradei Date: Wed, 29 Jul 2026 07:40:32 -0700 Subject: [PATCH 3/4] port bulk-archive wiring onto the contribution shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit upstream retired apps/desktop/src/app/desktop-controller.tsx (369d0eeef, "views as contributions"); the rebase resurrected it from this branch's two-line edit. Drop the file again and re-apply the same intent where the sidebar is now wired: SidebarActions gains onArchiveAllSessions, the latest-actions adapter forwards it, and contrib/wiring.tsx supplies archiveAllSessions().then(refreshSessions) — identical behavior to the retired controller's prop. Co-Authored-By: Claude Fable 5 --- .../src/app/contrib/latest-actions.test.ts | 1 + .../desktop/src/app/contrib/latest-actions.ts | 1 + apps/desktop/src/app/contrib/types.ts | 1 + apps/desktop/src/app/contrib/wiring.tsx | 2 + apps/desktop/src/app/desktop-controller.tsx | 1370 ----------------- 5 files changed, 5 insertions(+), 1370 deletions(-) delete mode 100644 apps/desktop/src/app/desktop-controller.tsx diff --git a/apps/desktop/src/app/contrib/latest-actions.test.ts b/apps/desktop/src/app/contrib/latest-actions.test.ts index 9ff335054f37..e23fae2249f9 100644 --- a/apps/desktop/src/app/contrib/latest-actions.test.ts +++ b/apps/desktop/src/app/contrib/latest-actions.test.ts @@ -34,6 +34,7 @@ function makeChatActions(): ChatActions { function makeSidebarActions(): SidebarActions { return { + onArchiveAllSessions: vi.fn(), onArchiveSession: vi.fn(), onBranchSession: vi.fn(), onDeleteSession: vi.fn(), diff --git a/apps/desktop/src/app/contrib/latest-actions.ts b/apps/desktop/src/app/contrib/latest-actions.ts index 7d191208a27c..9394905b02ff 100644 --- a/apps/desktop/src/app/contrib/latest-actions.ts +++ b/apps/desktop/src/app/contrib/latest-actions.ts @@ -57,6 +57,7 @@ export function latestChatActions(actions: ChatActions): ChatActions { export function latestSidebarActions(actions: SidebarActions): SidebarActions { return { + onArchiveAllSessions: (...args) => actions.onArchiveAllSessions(...args), onArchiveSession: (...args) => actions.onArchiveSession(...args), onBranchSession: (...args) => actions.onBranchSession(...args), onDeleteSession: (...args) => actions.onDeleteSession(...args), diff --git a/apps/desktop/src/app/contrib/types.ts b/apps/desktop/src/app/contrib/types.ts index 1e2c60ac776a..2cc9173359bc 100644 --- a/apps/desktop/src/app/contrib/types.ts +++ b/apps/desktop/src/app/contrib/types.ts @@ -11,6 +11,7 @@ export type GatewayRequester = ReturnType['requestGate /** The ChatSidebar handlers the controller owns — forwarded verbatim. */ export type SidebarActions = Pick< ComponentProps, + | 'onArchiveAllSessions' | 'onArchiveSession' | 'onBranchSession' | 'onDeleteSession' diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 190e4b4acb5a..f98fb8f4ef27 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -424,6 +424,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { }, [restartPreviewServer]) const { + archiveAllSessions, archiveSession, branchCurrentSession, branchStoredSession, @@ -827,6 +828,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { const nextActions: WiringActions = { onAddContextRef: composer.addContextRefAttachment, onAddUrl: url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url), + onArchiveAllSessions: () => archiveAllSessions().then(() => refreshSessions()), onArchiveSession: sessionId => void archiveSession(sessionId), onAttachDroppedItems: composer.attachDroppedItems, onAttachImageBlob: composer.attachImageBlob, diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx deleted file mode 100644 index 3d741c06c19b..000000000000 --- a/apps/desktop/src/app/desktop-controller.tsx +++ /dev/null @@ -1,1370 +0,0 @@ -import { useStore } from '@nanostores/react' -import { useQueryClient } from '@tanstack/react-query' -import { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from 'react' -import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom' - -import { BootFailureOverlay } from '@/components/boot-failure-overlay' -import { DesktopInstallOverlay } from '@/components/desktop-install-overlay' -import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay' -import { DesktopOnboardingOverlay } from '@/components/onboarding' -import { Pane, PaneMain } from '@/components/pane-shell' -import { RemoteDisplayBanner } from '@/components/remote-display-banner' -import { useMediaQuery } from '@/hooks/use-media-query' -import { isFocusWithin } from '@/lib/keybinds/combo' -import { cn } from '@/lib/utils' -import { useSkinCommand } from '@/themes/use-skin-command' - -import { formatRefValue } from '../components/assistant-ui/directive-text' -import { getSessionMessages, type SessionMessage, triggerCronJob } from '../hermes' -import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages' -import { storedSessionIdForNotification } from '../lib/session-ids' -import { isMessagingSource } from '../lib/session-source' -import { latestSessionTodos } from '../lib/todos' -import { setCronFocusJobId } from '../store/cron' -import { - $fileBrowserOpen, - $panesFlipped, - $pinnedSessionIds, - FILE_BROWSER_DEFAULT_WIDTH, - FILE_BROWSER_MAX_WIDTH, - FILE_BROWSER_MIN_WIDTH, - pinSession, - PREVIEW_PANE_ID, - restoreWorktree, - setSidebarOverlayMounted, - SIDEBAR_DEFAULT_WIDTH, - SIDEBAR_MAX_WIDTH, - unpinSession -} from '../store/layout' -import { respondToApprovalAction } from '../store/native-notifications' -import { $paneOpen } from '../store/panes' -import { setPetActivity } from '../store/pet' -import { setPetScale } from '../store/pet-gallery' -import { - setPetOverlayOpenAppHandler, - setPetOverlayScaleHandler, - setPetOverlaySubmitHandler -} from '../store/pet-overlay' -import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview' -import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '../store/profile' -import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '../store/projects' -import { $reviewOpen, REVIEW_PANE_ID } from '../store/review' -import { - $activeSessionId, - $attentionSessionIds, - $currentCwd, - $freshDraftReady, - $gatewayState, - $messages, - $messagingSessions, - $resumeExhaustedSessionId, - $resumeFailedSessionId, - $selectedStoredSessionId, - $sessions, - getRememberedSessionId, - sessionPinId, - setAwaitingResponse, - setBusy, - setCurrentBranch, - setCurrentCwd, - setCurrentModel, - setCurrentProvider, - setMessages, - setRememberedSessionId -} from '../store/session' -import { onSessionsChanged } from '../store/session-sync' -import { clearSessionTodos, setSessionTodos, todosForHydration } from '../store/todos' -import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates' -import { isSecondaryWindow } from '../store/windows' - -import { ChatView } from './chat' -import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus' -import { useComposerActions } from './chat/hooks/use-composer-actions' -import { - ChatPreviewRail, - PREVIEW_RAIL_MAX_WIDTH, - PREVIEW_RAIL_MIN_WIDTH, - PREVIEW_RAIL_PANE_WIDTH -} from './chat/right-rail' -import { ChatSidebar } from './chat/sidebar' -import { CommandPalette } from './command-palette' -import { useGatewayBoot } from './gateway/hooks/use-gateway-boot' -import { useGatewayRequest } from './gateway/hooks/use-gateway-request' -import { useKeybinds } from './hooks/use-keybinds' -import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants' -import { ModelPickerOverlay } from './model-picker-overlay' -import { ModelVisibilityOverlay } from './model-visibility-overlay' -import { PetGenerateOverlay } from './pet-generate/pet-generate-overlay' -import { RightSidebarPane } from './right-sidebar' -import { FileActionDialogs } from './right-sidebar/file-actions' -import { RemoteFolderPicker } from './right-sidebar/files/remote-picker' -import { ReviewPane } from './right-sidebar/review' -import { $terminalTakeover } from './right-sidebar/store' -import { TerminalPaneChrome } from './right-sidebar/terminal/chrome' -import { PersistentTerminal } from './right-sidebar/terminal/persistent' -import { closeActiveTerminal } from './right-sidebar/terminal/terminals' -import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes' -import { SessionPickerOverlay } from './session-picker-overlay' -import { SessionSwitcher } from './session-switcher' -import { useContextSuggestions } from './session/hooks/use-context-suggestions' -import { useCwdActions } from './session/hooks/use-cwd-actions' -import { useHermesConfig } from './session/hooks/use-hermes-config' -import { useMessageStream } from './session/hooks/use-message-stream' -import { useModelControls } from './session/hooks/use-model-controls' -import { usePreviewRouting } from './session/hooks/use-preview-routing' -import { usePromptActions } from './session/hooks/use-prompt-actions' -import { useRouteResume } from './session/hooks/use-route-resume' -import { useSessionActions } from './session/hooks/use-session-actions' -import { useSessionListActions } from './session/hooks/use-session-list-actions' -import { useSessionStateCache } from './session/hooks/use-session-state-cache' -import { AppShell } from './shell/app-shell' -import { useOverlayRouting } from './shell/hooks/use-overlay-routing' -import { useStatusSnapshot } from './shell/hooks/use-status-snapshot' -import { useStatusbarItems } from './shell/hooks/use-statusbar-items' -import { ModelMenuPanel } from './shell/model-menu-panel' -import type { StatusbarItem } from './shell/statusbar-controls' -import type { TitlebarTool } from './shell/titlebar-controls' -import { useGroupRegistry } from './shell/use-group-registry' -import { UpdatesOverlay } from './updates-overlay' - -const AgentsView = lazy(async () => ({ default: (await import('./agents')).AgentsView })) -const ArtifactsView = lazy(async () => ({ default: (await import('./artifacts')).ArtifactsView })) -const CommandCenterView = lazy(async () => ({ default: (await import('./command-center')).CommandCenterView })) -const CronView = lazy(async () => ({ default: (await import('./cron')).CronView })) -const StarmapView = lazy(async () => ({ default: (await import('./starmap')).StarmapView })) -const MessagingView = lazy(async () => ({ default: (await import('./messaging')).MessagingView })) -const ProfilesView = lazy(async () => ({ default: (await import('./profiles')).ProfilesView })) -const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView })) -const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView })) - -// Latest cron-job sessions surfaced in the collapsed "Cron jobs" section. The -// Cron sessions are written by a background scheduler tick (the desktop -// backend), so no user action signals the UI. Poll the bounded cron list on -// this cadence while the app is open + visible so new runs surface promptly -// instead of waiting for the next user-triggered refreshSessions(). -const CRON_POLL_INTERVAL_MS = 30_000 -// Messaging-platform turns are written by the background gateway (WeChat, -// Telegram, Discord, …), not the desktop websocket that drives local chats. -// Poll the bounded messaging slice while visible so inbound platform traffic -// appears without requiring a manual refresh or route change. -const MESSAGING_POLL_INTERVAL_MS = 10_000 -const ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS = 5_000 - -function sessionMatchesStoredId(session: { id: string; _lineage_root_id?: null | string }, id: string): boolean { - return session.id === id || session._lineage_root_id === id -} - -function hashString(hash: number, value: string): number { - let next = hash - - for (let i = 0; i < value.length; i++) { - next ^= value.charCodeAt(i) - next = Math.imul(next, 16777619) - } - - return next >>> 0 -} - -function sessionMessagesSignature(messages: SessionMessage[]): string { - let hash = 2166136261 - - for (const m of messages) { - hash = hashString(hash, m.role) - hash = hashString(hash, String(m.timestamp ?? '')) - hash = hashString(hash, typeof m.content === 'string' ? m.content : (JSON.stringify(m.content) ?? '')) - } - - return `${messages.length}:${hash}` -} - -export function DesktopController() { - const queryClient = useQueryClient() - const location = useLocation() - const navigate = useNavigate() - - const busyRef = useRef(false) - const creatingSessionRef = useRef(false) - const messagingTranscriptSignatureRef = useRef(new Map()) - - const gatewayState = useStore($gatewayState) - const activeSessionId = useStore($activeSessionId) - const currentCwd = useStore($currentCwd) - const freshDraftReady = useStore($freshDraftReady) - const resumeFailedSessionId = useStore($resumeFailedSessionId) - const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId) - const filePreviewTarget = useStore($filePreviewTarget) - const previewTarget = useStore($previewTarget) - const selectedStoredSessionId = useStore($selectedStoredSessionId) - const messagingSessions = useStore($messagingSessions) - const terminalTakeover = useStore($terminalTakeover) - const reviewOpen = useStore($reviewOpen) - const fileBrowserOpen = useStore($fileBrowserOpen) - const previewPaneOpen = useStore($paneOpen(PREVIEW_PANE_ID)) - const panesFlipped = useStore($panesFlipped) - const profileScope = useStore($profileScope) - // Below SIDEBAR_COLLAPSE_BREAKPOINT_PX there's no room for a docked rail — - // collapse both sidebars (without touching their stored open state) so the - // hover-reveal overlay becomes the way in. Restores once it's wide again. - const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY) - - const routedSessionId = routeSessionId(location.pathname) - const routeToken = `${location.pathname}:${location.search}:${location.hash}` - const routeTokenRef = useRef(routeToken) - routeTokenRef.current = routeToken - const getRouteToken = useCallback(() => routeTokenRef.current, []) - - const { - agentsOpen, - chatOpen, - closeOverlayToPreviousRoute, - commandCenterInitialSection, - commandCenterOpen, - cronOpen, - currentView, - openAgents, - openCommandCenterSection, - openStarmap, - profilesOpen, - settingsOpen, - starmapOpen, - toggleCommandCenter - } = useOverlayRouting() - - const terminalSidebarOpen = chatOpen && terminalTakeover - - const titlebarToolGroups = useGroupRegistry() - const statusbarItemGroups = useGroupRegistry() - const setTitlebarToolGroup = titlebarToolGroups.set - const setStatusbarItemGroup = statusbarItemGroups.set - - const { - activeSessionIdRef, - ensureSessionState, - runtimeIdByStoredSessionIdRef, - selectedStoredSessionIdRef, - sessionStateByRuntimeIdRef, - syncSessionStateToView, - updateSessionState - } = useSessionStateCache({ - activeSessionId, - busyRef, - selectedStoredSessionId, - setAwaitingResponse, - setBusy, - setMessages - }) - - const { connectionRef, gatewayRef, requestGateway } = useGatewayRequest() - - useEffect(() => { - window.hermesDesktop?.setPreviewShortcutActive?.(Boolean(chatOpen && (filePreviewTarget || previewTarget))) - }, [chatOpen, filePreviewTarget, previewTarget]) - - useEffect(() => { - startUpdatePoller() - const unsubscribe = window.hermesDesktop?.onOpenUpdatesRequested?.(() => openUpdatesWindow()) - - return () => { - unsubscribe?.() - stopUpdatePoller() - } - }, []) - - // Remember the open chat so a relaunch reopens it instead of an empty new-chat. - useEffect(() => { - if (routedSessionId) { - setRememberedSessionId(routedSessionId) - } - }, [routedSessionId]) - - // Restore that chat once, on cold start only (we're at the new-chat route and - // haven't navigated yet). A dead/deleted id self-clears via the exhausted latch - // below, so we never boot-loop into an error screen. - const restoredLastSessionRef = useRef(false) - useEffect(() => { - if (restoredLastSessionRef.current) { - return - } - - restoredLastSessionRef.current = true - const last = getRememberedSessionId() - - if (last && location.pathname === NEW_CHAT_ROUTE) { - navigate(sessionRoute(last), { replace: true }) - } - }, [location.pathname, navigate]) - - useEffect(() => { - if (resumeExhaustedSessionId && getRememberedSessionId() === resumeExhaustedSessionId) { - setRememberedSessionId(null) - } - }, [resumeExhaustedSessionId]) - - // Notification click: the main process already focused the window; jump to its - // session. Notifications are tagged with the gateway *runtime* session id, but - // the chat route is keyed by the *stored* id — navigating with the runtime id - // resumes a non-existent stored session ("session not found") and strands the - // user. Translate runtime -> stored before navigating. - useEffect(() => { - const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => { - if (sessionId) { - navigate(sessionRoute(storedSessionIdForNotification(sessionId, runtimeIdByStoredSessionIdRef.current))) - } - }) - - return () => unsubscribe?.() - }, [navigate, runtimeIdByStoredSessionIdRef]) - - // Notification action button (Approve/Reject) — resolve in place, no navigation. - useEffect(() => { - const unsubscribe = window.hermesDesktop?.onNotificationAction?.(({ actionId, sessionId }) => { - void respondToApprovalAction(sessionId ?? null, actionId) - }) - - return () => unsubscribe?.() - }, []) - - // hermes:// deep links (e.g. a docs "Send to App" button for an automation blueprint). - // Build the equivalent /blueprint slash command from the payload and drop - // it into the composer — the user reviews/edits, then sends; the agent (or - // the shared command handler) creates the job. Signal readiness so a link - // that arrived during boot is flushed exactly once. - useEffect(() => { - const unsubscribe = window.hermesDesktop?.onDeepLink?.(payload => { - if (!payload || payload.kind !== 'blueprint' || !payload.name) { - return - } - - const slots = Object.entries(payload.params || {}) - .map(([k, v]) => { - const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v - - return `${k}=${sval}` - }) - .join(' ') - - const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}` - requestComposerInsert(command, { mode: 'block', target: 'main' }) - requestComposerFocus('main') - }) - - // Tell the main process the renderer is ready to receive deep links. - void window.hermesDesktop?.signalDeepLinkReady?.() - - return () => unsubscribe?.() - }, []) - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.altKey || event.shiftKey || event.key.toLowerCase() !== 'w' || (!event.metaKey && !event.ctrlKey)) { - return - } - - // Terminal focused: ⌘W closes the active terminal. Ctrl+W is left untouched - // for the shell's werase, and nothing else may steal ⌘/Ctrl+W from a - // focused terminal (so it never closes a preview tab out from under it). - if (isFocusWithin('[data-terminal]')) { - if (event.metaKey && !event.ctrlKey) { - event.preventDefault() - event.stopPropagation() - closeActiveTerminal() - } - - return - } - - // Otherwise ⌘/Ctrl+W closes the active preview tab when one is open. - if ($filePreviewTarget.get() || $previewTarget.get()) { - event.preventDefault() - event.stopPropagation() - closeActiveRightRailTab() - } - } - - const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(closeActiveRightRailTab) - - window.addEventListener('keydown', onKeyDown, { capture: true }) - - return () => { - unsubscribe?.() - window.removeEventListener('keydown', onKeyDown, { capture: true }) - } - }, []) - - const { - loadMoreMessagingForPlatform, - loadMoreSessions, - loadMoreSessionsForProfile, - refreshCronJobs, - refreshMessagingSessions, - refreshSessions - } = useSessionListActions({ profileScope }) - - // Another window mutated the shared session list (e.g. a chat started in the - // pop-out). Re-pull so the sidebar reflects it. Pop-outs have no sidebar, so - // only real windows bother. - useEffect(() => { - if (isSecondaryWindow()) { - return - } - - return onSessionsChanged(() => void refreshSessions().catch(() => undefined)) - }, [refreshSessions]) - - const toggleSelectedPin = useCallback(() => { - const sessionId = $selectedStoredSessionId.get() - - if (!sessionId) { - return - } - - // Pin on the durable lineage-root id so the pin survives auto-compression. - const session = $sessions.get().find(s => s.id === sessionId || s._lineage_root_id === sessionId) - const pinId = session ? sessionPinId(session) : sessionId - - if ($pinnedSessionIds.get().includes(pinId)) { - unpinSession(pinId) - } else { - pinSession(pinId) - } - }, []) - - const { inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway) - - const updateActiveSessionRuntimeInfo = useCallback( - (info: { branch?: string; cwd?: string }) => { - const sessionId = activeSessionIdRef.current - - if (!sessionId) { - return - } - - updateSessionState(sessionId, state => ({ - ...state, - branch: info.branch ?? state.branch, - cwd: info.cwd ?? state.cwd - })) - }, - [activeSessionIdRef, updateSessionState] - ) - - const { refreshProjectBranch } = useCwdActions({ - activeSessionId, - activeSessionIdRef, - onSessionRuntimeInfo: updateActiveSessionRuntimeInfo, - requestGateway - }) - - const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({ - activeSessionIdRef, - refreshProjectBranch - }) - - const { refreshCurrentModel, selectModel, updateModelOptionsCache } = useModelControls({ - activeSessionId, - queryClient, - requestGateway - }) - - const openProviderSettings = useCallback(() => { - navigate(`${SETTINGS_ROUTE}?tab=providers`) - }, [navigate]) - - const modelMenuContent = useMemo( - () => - gatewayState === 'open' ? ( - - ) : null, - [gatewayRef, gatewayState, requestGateway, selectModel] - ) - - useContextSuggestions({ - activeSessionId, - activeSessionIdRef, - currentCwd, - gatewayState, - requestGateway - }) - - const hydrateFromStoredSession = useCallback( - async ( - attempts = 1, - storedSessionId = selectedStoredSessionIdRef.current, - runtimeSessionId = activeSessionIdRef.current - ) => { - if (!storedSessionId || !runtimeSessionId) { - return - } - - const storedProfile = $sessions - .get() - .find(session => session.id === storedSessionId || session._lineage_root_id === storedSessionId)?.profile - - for (let index = 0; index < Math.max(1, attempts); index += 1) { - try { - const latest = await getSessionMessages(storedSessionId, storedProfile) - const messages = toChatMessages(latest.messages) - updateSessionState( - runtimeSessionId, - state => ({ - ...state, - messages: preserveLocalAssistantErrors(messages, state.messages) - }), - storedSessionId - ) - - // Rehydration runs *after* a turn completes, so an "active" stored - // list (last `todo` still pending/in_progress) means the turn ended - // without a final update — it's stale, not in-flight. Re-seeding it - // would re-pin "Tasks N/M" above the composer and undo the turn-end - // clear (and survive restarts, since it's read back from history). - // todosForHydration restores only a *finished* list (its short linger - // shows the last checkmark); anything still active is dropped. - const restored = todosForHydration(latestSessionTodos(messages)) - - if (restored) { - setSessionTodos(runtimeSessionId, restored) - } else { - clearSessionTodos(runtimeSessionId) - } - - return - } catch { - // Best-effort fallback when live stream payloads are empty. - } - - if (index < attempts - 1) { - await new Promise(resolve => window.setTimeout(resolve, 250)) - } - } - }, - [activeSessionIdRef, selectedStoredSessionIdRef, updateSessionState] - ) - - const refreshActiveMessagingTranscript = useCallback(async () => { - const storedSessionId = selectedStoredSessionIdRef.current - const runtimeSessionId = activeSessionIdRef.current - - if (!storedSessionId || !runtimeSessionId || busyRef.current) { - return - } - - const stored = $messagingSessions.get().find(s => sessionMatchesStoredId(s, storedSessionId)) - - if (!stored || !isMessagingSource(stored.source)) { - return - } - - try { - const latest = await getSessionMessages(storedSessionId, stored.profile) - const signatureKey = `${stored.profile ?? 'default'}:${storedSessionId}` - const sig = sessionMessagesSignature(latest.messages) - - if (messagingTranscriptSignatureRef.current.get(signatureKey) === sig) { - return - } - - messagingTranscriptSignatureRef.current.set(signatureKey, sig) - const messages = toChatMessages(latest.messages) - - updateSessionState( - runtimeSessionId, - state => ({ ...state, messages: preserveLocalAssistantErrors(messages, state.messages) }), - storedSessionId - ) - } catch { - // Non-fatal: next poll or manual refresh can hydrate. - } - }, [activeSessionIdRef, busyRef, selectedStoredSessionIdRef, updateSessionState]) - - const { handleGatewayEvent } = useMessageStream({ - activeSessionIdRef, - hydrateFromStoredSession, - queryClient, - refreshHermesConfig, - refreshSessions, - sessionStateByRuntimeIdRef, - updateSessionState - }) - - const { handleDesktopGatewayEvent, restartPreviewServer } = usePreviewRouting({ - activeSessionIdRef, - baseHandleGatewayEvent: handleGatewayEvent, - currentCwd, - currentView, - requestGateway, - routedSessionId, - selectedStoredSessionId - }) - - const { - archiveAllSessions, - archiveSession, - branchCurrentSession, - branchStoredSession, - createBackendSessionForSend, - openSettings, - removeSession, - resumeSession, - selectSidebarItem, - startFreshSessionDraft - } = useSessionActions({ - activeSessionId, - activeSessionIdRef, - busyRef, - creatingSessionRef, - ensureSessionState, - getRouteToken, - navigate, - requestGateway, - runtimeIdByStoredSessionIdRef, - selectedStoredSessionId, - selectedStoredSessionIdRef, - sessionStateByRuntimeIdRef, - syncSessionStateToView, - updateSessionState - }) - - // Single global listener for every rebindable hotkey (incl. profile switching) - // plus the on-screen keybind editor's capture mode. - useKeybinds({ - startFreshSession: startFreshSessionDraft, - toggleCommandCenter, - toggleSelectedPin - }) - - // A profile switch/create drops to a fresh new-session draft so the previously - // open session doesn't bleed across contexts. Skip the initial value. - const freshSessionRequest = useStore($freshSessionRequest) - const lastFreshRef = useRef(freshSessionRequest) - - useEffect(() => { - if (freshSessionRequest === lastFreshRef.current) { - return - } - - lastFreshRef.current = freshSessionRequest - startFreshSessionDraft() - }, [freshSessionRequest, startFreshSessionDraft]) - - // Swapping the live gateway to another profile must re-pull that profile's - // global model + active-profile pill. Both are nanostores, so the blanket - // invalidateQueries() the profile store fires on swap doesn't touch them — - // without this the statusbar keeps showing the previous profile's model - // (the "forgets the LLM setting" report). gatewayState stays 'open' across a - // swap (background sockets persist), so the open→open effect won't re-run. - const activeGatewayProfile = useStore($activeGatewayProfile) - const lastGatewayProfileRef = useRef(activeGatewayProfile) - - useEffect(() => { - if (activeGatewayProfile === lastGatewayProfileRef.current) { - return - } - - lastGatewayProfileRef.current = activeGatewayProfile - // Force: the new profile has its own default, so reseed even if the composer - // already shows the previous profile's model. - void refreshCurrentModel(true) - void refreshActiveProfile() - }, [activeGatewayProfile, refreshCurrentModel]) - - const composer = useComposerActions({ - activeSessionId, - currentCwd, - requestGateway - }) - - const branchInNewChat = useCallback( - async (messageId?: string) => { - const branched = await branchCurrentSession(messageId) - - if (branched) { - await refreshSessions().catch(() => undefined) - } - - return branched - }, - [branchCurrentSession, refreshSessions] - ) - - // Clear a failed turn's red error banner from the transcript. Errors are - // renderer-local state (never persisted), so dismissing is purely a view + - // session-cache edit. A message that errored before emitting any visible - // text is a bare error placeholder → drop it entirely; one that streamed - // partial output then failed keeps its content and just sheds the error. - // Both the per-runtime cache AND the live $messages view must be updated: - // `preserveLocalAssistantErrors` re-grafts any still-errored message it - // finds in the view onto the next session.info flush, so clearing only the - // cache would let the heartbeat resurrect the banner. - const dismissError = useCallback( - (messageId: string) => { - const runtimeSessionId = activeSessionIdRef.current - - if (!runtimeSessionId) { - return - } - - const clearErrorIn = (messages: ChatMessage[]): ChatMessage[] => - messages.flatMap(message => { - if (message.id !== messageId || !message.error) { - return [message] - } - - if (!chatMessageText(message).trim() && !message.parts.some(part => part.type !== 'text')) { - return [] - } - - return [{ ...message, error: undefined, pending: false }] - }) - - // View first: the flush below reads $messages as the "current" baseline - // for error preservation, so the banner must be gone from it before the - // cache update triggers a re-sync. - setMessages(clearErrorIn($messages.get())) - - updateSessionState(runtimeSessionId, state => ({ - ...state, - messages: clearErrorIn(state.messages) - })) - }, - [activeSessionIdRef, updateSessionState] - ) - - const startSessionInWorkspace = useCallback( - (path: null | string) => { - startFreshSessionDraft() - - // A worktree lane carries its own path; the trunk "+" can be path-less (the - // main checkout is implicit), so fall back to the active project's root - // instead of no-op'ing on null — that was "+ on main does nothing". - const target = path?.trim() || resolveNewSessionCwd() - - if (!target) { - return - } - - // The next message creates the backend session in $currentCwd, so seed - // it (and the branch) from the workspace the user clicked the + on. - setCurrentCwd(target) - void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target }) - .then(info => { - const resolved = info.cwd || target - - setCurrentCwd(resolved) - setCurrentBranch(info.branch || '') - - // An EXPLICIT target (a worktree/lane path — e.g. just-created via - // "convert a branch" / "new worktree") drills the sidebar into that - // project so the new lane is visible at once. Without this, a brand-new - // worktree session is invisible from the all-projects overview (the - // live overlay skips `.worktrees` rows, and the session.info cwd-follow - // only fires on a same-session move, not a fresh session). The - // path-less trunk "+" keeps the current scope untouched. - if (path?.trim()) { - restoreWorktree(resolved) - void followActiveSessionCwd(resolved) - } - }) - .catch(() => undefined) - }, - [requestGateway, startFreshSessionDraft] - ) - - // Composer "branch off into a new worktree": the composer already created the - // worktree and cleared its draft; open a fresh session anchored to that tree, - // then prefill the task that kicked it off. startSessionInWorkspace owns the - // reset+cwd seed (it runs startFreshSessionDraft, which would otherwise stomp - // the cwd back to the default), so the prefill is dispatched right after — its - // deferred event lands once the fresh composer has remounted and rebound. - const startWorkSessionRequest = useStore($startWorkSessionRequest) - const lastStartWorkTokenRef = useRef(startWorkSessionRequest?.token ?? 0) - - useEffect(() => { - if (!startWorkSessionRequest || startWorkSessionRequest.token === lastStartWorkTokenRef.current) { - return - } - - lastStartWorkTokenRef.current = startWorkSessionRequest.token - startSessionInWorkspace(startWorkSessionRequest.path) - - if (startWorkSessionRequest.draft) { - requestComposerInsert(startWorkSessionRequest.draft, { target: 'main' }) - } - }, [startSessionInWorkspace, startWorkSessionRequest]) - - const handleSkinCommand = useSkinCommand() - - const { - cancelRun, - editMessage, - handleThreadMessagesChange, - reloadFromMessage, - restoreToMessage, - steerPrompt, - submitText, - transcribeVoiceAudio - } = usePromptActions({ - activeSessionId, - activeSessionIdRef, - branchCurrentSession: branchInNewChat, - busyRef, - createBackendSessionForSend, - handleSkinCommand, - openMemoryGraph: openStarmap, - refreshSessions, - requestGateway, - resumeStoredSession: resumeSession, - selectedStoredSessionIdRef, - startFreshSessionDraft, - sttEnabled, - updateSessionState - }) - - // The popped-out pet drives two actions back into the app: send a prompt, and - // open the most recent thread. Both are registered ONCE through refs that track - // the latest callbacks — re-registering on every `submitText`/`resumeSession` - // identity change left a brief window where the handler was nulled (cleanup - // before re-register), which could drop a submit fired from the overlay (e.g. - // creating a session from the new-session screen). The ref form keeps a stable, - // always-current handler. Primary window only — it owns the overlay. - const submitTextRef = useRef(submitText) - submitTextRef.current = submitText - const resumeSessionRef = useRef(resumeSession) - resumeSessionRef.current = resumeSession - const requestGatewayRef = useRef(requestGateway) - requestGatewayRef.current = requestGateway - - useEffect(() => { - if (isSecondaryWindow()) { - return - } - - setPetOverlaySubmitHandler(text => void submitTextRef.current(text)) - // Alt+wheel resize from the popped-out pet — persist it through this - // window's gateway (the overlay has none) so it survives restart. - setPetOverlayScaleHandler(scale => setPetScale(requestGatewayRef.current, scale)) - // Mail icon: $sessions is ordered most-recent-first; the pet is global (not - // per session) so "most recent" is the right target. main.cjs already raised - // the window before forwarding this. - setPetOverlayOpenAppHandler(() => { - const recent = $sessions.get()[0] - - if (recent?.id) { - void resumeSessionRef.current(recent.id) - } - }) - - return () => { - setPetOverlaySubmitHandler(null) - setPetOverlayOpenAppHandler(null) - setPetOverlayScaleHandler(null) - } - }, []) - - // Mirror "a session is blocked on the user" (clarify/approval) into the pet's - // awaitingInput flag so it shows the `waiting` pose. Lives on $petActivity so - // it rides the same atom the pop-out overlay mirrors — no session list needed - // there. Every window keeps its own in-window pet in sync. - useEffect(() => { - const sync = () => setPetActivity({ awaitingInput: $attentionSessionIds.get().length > 0 }) - - sync() - - return $attentionSessionIds.listen(sync) - }, []) - - useGatewayBoot({ - handleGatewayEvent: handleDesktopGatewayEvent, - onConnectionReady: c => { - connectionRef.current = c - }, - onGatewayReady: g => { - gatewayRef.current = g - }, - refreshHermesConfig, - refreshSessions - }) - - useEffect(() => { - if (gatewayState === 'open') { - void refreshCurrentModel() - void refreshActiveProfile() - void refreshSessions().catch(() => undefined) - } - }, [gatewayState, refreshCurrentModel, refreshSessions]) - - // Keep the cron jobs section live without a user action: the scheduler ticks - // in the background (advancing next-run/state and creating runs), so poll the - // job list on an interval (and on tab re-focus) while connected. - useEffect(() => { - if (gatewayState !== 'open') { - return - } - - const tick = () => { - if (document.visibilityState === 'visible') { - void refreshCronJobs() - } - } - - const intervalId = window.setInterval(tick, CRON_POLL_INTERVAL_MS) - document.addEventListener('visibilitychange', tick) - - return () => { - window.clearInterval(intervalId) - document.removeEventListener('visibilitychange', tick) - } - }, [gatewayState, refreshCronJobs]) - - // Keep messaging-platform session lists live: inbound Telegram/WeChat/Discord - // turns are written by the gateway, not the desktop websocket, so they won't - // appear without polling. - useEffect(() => { - if (gatewayState !== 'open') { - return - } - - const tick = () => { - if (document.visibilityState === 'visible') { - void refreshMessagingSessions() - } - } - - const intervalId = window.setInterval(tick, MESSAGING_POLL_INTERVAL_MS) - document.addEventListener('visibilitychange', tick) - - return () => { - window.clearInterval(intervalId) - document.removeEventListener('visibilitychange', tick) - } - }, [gatewayState, refreshMessagingSessions]) - - // Only the open messaging transcript needs a poll — local chats are already - // live over the websocket, so arming a timer for them would just no-op every - // tick. Gate on the active session actually being a messaging source. - const activeIsMessaging = - !!selectedStoredSessionId && - isMessagingSource(messagingSessions.find(s => sessionMatchesStoredId(s, selectedStoredSessionId))?.source) - - // Keep the currently-viewed messaging transcript live. - useEffect(() => { - if (gatewayState !== 'open' || !activeIsMessaging) { - return - } - - const tick = () => { - if (document.visibilityState === 'visible') { - void refreshActiveMessagingTranscript() - } - } - - const intervalId = window.setInterval(tick, ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS) - document.addEventListener('visibilitychange', tick) - tick() - - return () => { - window.clearInterval(intervalId) - document.removeEventListener('visibilitychange', tick) - } - }, [activeIsMessaging, gatewayState, refreshActiveMessagingTranscript]) - - useEffect(() => { - if (gatewayState === 'open' && !activeSessionId && freshDraftReady) { - void refreshCurrentModel() - void refreshHermesConfig() - } - }, [activeSessionId, freshDraftReady, gatewayState, refreshCurrentModel, refreshHermesConfig]) - - useRouteResume({ - activeSessionId, - activeSessionIdRef, - creatingSessionRef, - currentView, - freshDraftReady, - gatewayState, - locationPathname: location.pathname, - resumeSession, - resumeFailedSessionId, - resumeExhaustedSessionId, - routedSessionId, - runtimeIdByStoredSessionIdRef, - selectedStoredSessionId, - selectedStoredSessionIdRef, - startFreshSessionDraft - }) - - const { leftStatusbarItems, statusbarItems } = useStatusbarItems({ - agentsOpen, - chatOpen, - commandCenterOpen, - extraLeftItems: statusbarItemGroups.flat.left, - extraRightItems: statusbarItemGroups.flat.right, - gatewayState, - inferenceStatus, - openAgents, - freshDraftReady, - openCommandCenterSection, - requestGateway, - statusSnapshot, - toggleCommandCenter - }) - - const sidebar = ( - archiveAllSessions().then(() => refreshSessions())} - onArchiveSession={sessionId => void archiveSession(sessionId)} - onBranchSession={sessionId => void branchStoredSession(sessionId)} - onDeleteSession={sessionId => void removeSession(sessionId)} - onLoadMoreMessaging={loadMoreMessagingForPlatform} - onLoadMoreProfileSessions={loadMoreSessionsForProfile} - onLoadMoreSessions={loadMoreSessions} - onManageCronJob={jobId => { - setCronFocusJobId(jobId) - navigate(CRON_ROUTE) - }} - onNavigate={selectSidebarItem} - onNewSessionInWorkspace={startSessionInWorkspace} - onResumeSession={sessionId => navigate(sessionRoute(sessionId))} - onTriggerCronJob={jobId => { - void triggerCronJob(jobId) - .then(() => refreshCronJobs()) - .catch(() => undefined) - }} - /> - ) - - // The persistent xterm layer (one host per terminal tab), CSS-overlaid onto the - // pane's . Lives in main's stacking context (not the root overlay - // layer) so pane resize handles still paint above it. Terminals own their state - // (incl. a snapshotted cwd) independent of the session, so switching sessions - // never rebuilds or closes them; toggling the pane never rebuilds the shells. - const mainOverlays = - - const overlays = ( - <> - - {!isSecondaryWindow() && } - {!isSecondaryWindow() && ( - { - void refreshHermesConfig() - void refreshCurrentModel() - void queryClient.invalidateQueries({ queryKey: ['model-options'] }) - }} - requestGateway={requestGateway} - /> - )} - - - - - - - - - - - - - {settingsOpen && ( - - { - void refreshHermesConfig() - void refreshCurrentModel() - void queryClient.invalidateQueries({ queryKey: ['model-options'] }) - }} - onMainModelChanged={(provider, model) => { - setCurrentProvider(provider) - setCurrentModel(model) - updateModelOptionsCache(provider, model, true) - void refreshCurrentModel() - void queryClient.invalidateQueries({ queryKey: ['model-options'] }) - }} - /> - - )} - - {commandCenterOpen && ( - - navigate(path)} - onOpenSession={sessionId => navigate(sessionRoute(sessionId))} - /> - - )} - - {agentsOpen && ( - - - - )} - - {cronOpen && ( - - navigate(sessionRoute(sessionId))} - /> - - )} - - {profilesOpen && ( - - - - )} - - {starmapOpen && ( - - - - )} - - ) - - const chatView = ( - composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)} - onAttachDroppedItems={composer.attachDroppedItems} - onAttachImageBlob={composer.attachImageBlob} - onBranchInNewChat={branchInNewChat} - onCancel={cancelRun} - onDeleteSelectedSession={() => { - if (selectedStoredSessionId) { - void removeSession(selectedStoredSessionId) - } - }} - onDismissError={dismissError} - onEdit={editMessage} - onPasteClipboardImage={opts => composer.pasteClipboardImage(opts)} - onPickFiles={() => void composer.pickContextPaths('file')} - onPickFolders={() => void composer.pickContextPaths('folder')} - onPickImages={() => void composer.pickImages()} - onReload={reloadFromMessage} - onRemoveAttachment={id => void composer.removeAttachment(id)} - onRestoreToMessage={restoreToMessage} - onRetryResume={sessionId => void resumeSession(sessionId, true)} - onSteer={steerPrompt} - onSubmit={submitText} - onThreadMessagesChange={handleThreadMessagesChange} - onToggleSelectedPin={toggleSelectedPin} - onTranscribeAudio={transcribeVoiceAudio} - /> - ) - - // Flipped layout mirrors the default: sessions sidebar → right, file - // browser + preview rail → left. Same panes, swapped sides. - const sidebarSide = panesFlipped ? 'right' : 'left' - const railSide = panesFlipped ? 'left' : 'right' - - // Other sidebars docked as real columns on the terminal's rail. Force-collapsed - // hover-reveal overlays (narrow window) don't take a column, so they don't count. - const railColumnOpen = - (chatOpen && Boolean(previewTarget || filePreviewTarget) && previewPaneOpen) || - (chatOpen && !narrowViewport && fileBrowserOpen) || - (chatOpen && Boolean(currentCwd.trim()) && !narrowViewport && reviewOpen) - - // Once the terminal would share its rail with another sidebar, drop it to a - // full-width row beneath them rather than cramming in one more skinny column. - const terminalAsRow = terminalSidebarOpen && railColumnOpen - - const previewPane = ( - - {chatOpen ? ( - - ) : null} - - ) - - const fileBrowserPane = ( - - {/* Key on the project (cwd) so switching projects unmounts the old tree and - mounts a fresh one straight into its skeleton — no stale-then-blip. */} - composer.insertContextPathInlineRef(path)} - onActivateFolder={path => composer.insertContextPathInlineRef(path, true)} - /> - - ) - - const reviewPane = ( - - - - ) - - const terminalPane = ( - - {/* As a column the terminal clears the titlebar; as a bottom row it sits - below the rail's panes (so it fills its row edge-to-edge) and gets a - left border separating it from the chat — the column-mode separator - lives on the resize sash, which moves to the top edge as a row. */} -
- -
-
- ) - - return ( - - {!isSecondaryWindow() && ( - - {sidebar} - - )} - - - - - - - - } - path="skills" - /> - - - - } - path="messaging" - /> - - - - } - path="artifacts" - /> - - - - - - } path="new" /> - } path="sessions/:sessionId" /> - } path="*" /> - - - {/* - Order within a side maps to column order. Default (rail on the right): - main | terminal | preview | file-browser. Flipped (rail on the left): - mirror to file-browser | preview | terminal | main so terminal stays - adjacent to the chat. - */} - {panesFlipped ? fileBrowserPane : terminalPane} - {previewPane} - {reviewPane} - {panesFlipped ? terminalPane : fileBrowserPane} - - ) -} - -function LegacySessionRedirect() { - const { sessionId } = useParams() - - return -} From 5ce5ef7659b41c7af74db62aa342428f6f894503 Mon Sep 17 00:00:00 2001 From: Omar Baradei Date: Wed, 29 Jul 2026 10:38:26 -0700 Subject: [PATCH 4/4] fix: resolve conflict markers left by upstream refresh Marker blocks in use-session-actions, sidebar/index, and hermes.test were committed unresolved. Imports merged as unions; archive-all adapted to upstream's store API (workingSessionIds now lives in session-states, sessionsTotal is gone - the dialog count and the optimistic update now work off the loaded session list). Co-Authored-By: Claude Fable 5 --- apps/desktop/src/app/chat/sidebar/index.tsx | 15 +------------ .../hooks/use-session-actions/index.ts | 21 ++----------------- apps/desktop/src/hermes.test.ts | 3 --- 3 files changed, 3 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index f4987397a584..cd45172a552e 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -8,9 +8,7 @@ import { useLocation } from 'react-router-dom' import { PlatformAvatar } from '@/app/messaging/platform-icon' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' -<<<<<<< HEAD import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu' -======= import { Dialog, DialogContent, @@ -19,7 +17,6 @@ import { DialogHeader, DialogTitle } from '@/components/ui/dialog' ->>>>>>> 239cbaba6 (Refresh onto current upstream/main (no behavior change)) import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { KbdGroup } from '@/components/ui/kbd' import { SearchField } from '@/components/ui/search-field' @@ -32,12 +29,8 @@ import { SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar' -<<<<<<< HEAD import { Tip, TipKeybindLabel } from '@/components/ui/tooltip' import { useContributions } from '@/contrib/react/use-contributions' -======= -import { Tip } from '@/components/ui/tooltip' ->>>>>>> 239cbaba6 (Refresh onto current upstream/main (no behavior change)) import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes' import { useI18n } from '@/i18n' import { comboTokens } from '@/lib/keybinds/combo' @@ -1030,11 +1023,6 @@ export function ChatSidebar({ ? Object.values(sessionProfilesTruncated).some(Boolean) : Boolean(sessionProfilesTruncated[profileScope]) -<<<<<<< HEAD -======= - const hasMoreSessions = knownSessionTotal > loadedSessionCount - - const recentsMeta = countLabel(displayAgentSessions.length, knownSessionTotal) const archiveAllDisabled = sessionsLoading || agentSessions.length === 0 || archiveAllSubmitting const handleArchiveAll = async () => { @@ -1054,7 +1042,6 @@ export function ChatSidebar({ } } ->>>>>>> 239cbaba6 (Refresh onto current upstream/main (no behavior change)) const displayRecentsCountRef = useRef(0) const loadedRecentsCountRef = useRef(0) displayRecentsCountRef.current = displayAgentSessions.length @@ -1601,7 +1588,7 @@ export function ChatSidebar({ >>>>>> 239cbaba6 (Refresh onto current upstream/main (no behavior change)) import { useI18n } from '@/i18n' import { type ChatMessage, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages' import { isMissingRpcMethod } from '@/lib/gateway-rpc' @@ -17,12 +13,7 @@ import { migrateSessionDraft } from '@/store/composer' import { clearQueuedPrompts, migrateQueuedPrompts } from '@/store/composer-queue' import { $pinnedSessionIds } from '@/store/layout' import { clearNotifications, notify, notifyError } from '@/store/notifications' -<<<<<<< HEAD -import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' -======= import { $activeGatewayProfile, $newChatProfile, $profileScope, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' -import { resolveNewSessionCwd, tombstoneSessions, untombstoneSessions } from '@/store/projects' ->>>>>>> 239cbaba6 (Refresh onto current upstream/main (no behavior change)) import { beginSessionMutation, endSessionMutation, @@ -40,8 +31,6 @@ import { $messages, $newChatWorkspaceTarget, $sessions, - $sessionsTotal, - $workingSessionIds, $yoloActive, type NewChatWorkspaceTarget, resolveComposerSessionKey, @@ -69,6 +58,7 @@ import { } from '@/store/session' import { $sessionTiles, + $workingSessionIds, closeSessionTile, dropSessionState, openSessionTile, @@ -78,11 +68,7 @@ import { } from '@/store/session-states' import { broadcastSessionsChanged } from '@/store/session-sync' import { isWatchWindow } from '@/store/windows' -<<<<<<< HEAD -import type { SessionCreateResponse, SessionMessage, SessionResumeResponse, UsageStats } from '@/types/hermes' -======= -import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes' ->>>>>>> 239cbaba6 (Refresh onto current upstream/main (no behavior change)) +import type { SessionCreateResponse, SessionInfo, SessionMessage, SessionResumeResponse, UsageStats } from '@/types/hermes' import { navigateToWorkspacePage, NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../../routes' import type { ClientSessionState, SidebarNavItem } from '../../../types' @@ -1468,7 +1454,6 @@ export function useSessionActions({ clearNotifications() const previousSessions = $sessions.get() - const previousTotal = $sessionsTotal.get() const preserveIds = new Set([...$pinnedSessionIds.get(), ...$workingSessionIds.get()]) if (selectedStoredSessionId) { @@ -1490,7 +1475,6 @@ export function useSessionActions({ const keptSessions = previousSessions.filter(shouldPreserve) setSessions(keptSessions) - setSessionsTotal(keptSessions.length) try { const result = await bulkArchiveSessions([...preserveIds], $profileScope.get()) @@ -1503,7 +1487,6 @@ export function useSessionActions({ return result } catch (err) { setSessions(previousSessions) - setSessionsTotal(previousTotal) notifyError(err, 'Archive all failed') throw err } diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts index 15f435b33468..1510d6eabea1 100644 --- a/apps/desktop/src/hermes.test.ts +++ b/apps/desktop/src/hermes.test.ts @@ -1,16 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { -<<<<<<< HEAD AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS, AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS, AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS, AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS, audioSpeakRequestTimeoutMs, audioTranscribeRequestTimeoutMs, -======= bulkArchiveSessions, ->>>>>>> 239cbaba6 (Refresh onto current upstream/main (no behavior change)) getCronJobs, getGlobalModelInfo, getGlobalModelOptions,