diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index 2e9c65d5f5a1..52869d0f15e4 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -54,8 +54,8 @@ import { } from '../overlays/panel' import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' -import { jobState, jobTitle, STATE_DOT } from './job-state' import { cronEditorUpdates, jobIsScriptOnly, validateCronEditor } from './cron-job-model' +import { jobState, jobTitle, STATE_DOT } from './job-state' const DEFAULT_DELIVER = 'local' @@ -398,6 +398,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt notify({ kind: 'success', title: c.created, message: truncate(jobTitle(created), 60) }) } else if (editor.mode === 'edit') { const scriptOnlyJob = jobIsScriptOnly(editor.job) + const updated = await updateCronJob( editor.job.id, cronEditorUpdates(values, { scriptOnlyJob }) @@ -756,6 +757,7 @@ function CronEditorDialog({ async function handleSubmit(event: React.FormEvent) { event.preventDefault() + const validationError = validateCronEditor({ prompt, schedule, diff --git a/apps/desktop/src/app/desktop-controller-utils.test.ts b/apps/desktop/src/app/desktop-controller-utils.test.ts deleted file mode 100644 index ac39395db09e..000000000000 --- a/apps/desktop/src/app/desktop-controller-utils.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import type { SessionInfo } from '@/hermes' - -import { sameCronSignature } from './desktop-controller-utils' - -const session = (id: string, title: string | null): SessionInfo => ({ id, title }) as SessionInfo - -describe('sameCronSignature', () => { - it('is false when the lengths differ', () => { - expect(sameCronSignature([session('a', 't')], [])).toBe(false) - }) - - it('is true when ids and titles match in order', () => { - const a = [session('a', 'one'), session('b', 'two')] - const b = [session('a', 'one'), session('b', 'two')] - expect(sameCronSignature(a, b)).toBe(true) - }) - - it('is false when a title changed', () => { - const a = [session('a', 'one')] - const b = [session('a', 'renamed')] - expect(sameCronSignature(a, b)).toBe(false) - }) - - it('is false when order differs', () => { - const a = [session('a', 't'), session('b', 't')] - const b = [session('b', 't'), session('a', 't')] - expect(sameCronSignature(a, b)).toBe(false) - }) -}) diff --git a/apps/desktop/src/app/desktop-controller-utils.ts b/apps/desktop/src/app/desktop-controller-utils.ts deleted file mode 100644 index 5754d69ef81a..000000000000 --- a/apps/desktop/src/app/desktop-controller-utils.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { SessionInfo } from '@/hermes' - -// Cheap signature compare so a poll only swaps the atom (and re-renders the -// sidebar) when the visible rows actually changed. -export function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean { - if (a.length !== b.length) { - return false - } - - return a.every((session, i) => { - const other = b[i] - - return ( - other != null && - session.id === other.id && - session._lineage_root_id === other._lineage_root_id && - session.title === other.title && - session.source === other.source && - session.profile === other.profile && - session.preview === other.preview && - session.message_count === other.message_count && - session.last_active === other.last_active && - session.ended_at === other.ended_at - ) - }) -} diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx deleted file mode 100644 index 9f06facef266..000000000000 --- a/apps/desktop/src/app/desktop-controller.tsx +++ /dev/null @@ -1,1344 +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 } 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, - 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 { startWorkspaceSession } from './session/workspace-session-target' -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, - resetViewSync, - 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 { - archiveSession, - branchCurrentSession, - branchStoredSession, - createBackendSessionForSend, - openSettings, - removeSession, - resumeSession, - selectSidebarItem, - startFreshSessionDraft - } = useSessionActions({ - activeSessionId, - activeSessionIdRef, - busyRef, - creatingSessionRef, - ensureSessionState, - getRouteToken, - navigate, - requestGateway, - resetViewSync, - 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) => { - startWorkspaceSession({ - activeSessionIdRef, - followActiveSessionCwd, - onExplicitWorkspace: restoreWorktree, - path, - requestGateway, - startFreshSessionDraft - }) - }, - [activeSessionIdRef, 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, - getRouteToken, - 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.ts 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 = ( - 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/overlays/overlay-view.tsx b/apps/desktop/src/app/overlays/overlay-view.tsx index 61efdabb5a9b..4f05170d2d1b 100644 --- a/apps/desktop/src/app/overlays/overlay-view.tsx +++ b/apps/desktop/src/app/overlays/overlay-view.tsx @@ -1,8 +1,10 @@ -import { type ReactNode, useEffect } from 'react' +import { type CSSProperties, type ReactNode, useEffect } from 'react' +import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { translateNow } from '@/i18n' +import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' @@ -32,8 +34,10 @@ export function OverlayView({ // stop propagation themselves, so opening (e.g.) the model picker inside // Settings still closes the picker first instead of the underlying overlay. useEffect(() => { + const releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.overlay) + const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== 'Escape' || event.defaultPrevented) { + if (event.key !== 'Escape' || event.defaultPrevented || !isTopEscapeLayer(ESCAPE_PRIORITY.overlay)) { return } @@ -44,7 +48,10 @@ export function OverlayView({ window.addEventListener('keydown', onKeyDown) - return () => window.removeEventListener('keydown', onKeyDown) + return () => { + window.removeEventListener('keydown', onKeyDown) + releaseLayer() + } }, [onClose]) return ( @@ -64,6 +71,12 @@ export function OverlayView({ } }} role="presentation" + // Window-level chrome: overlays always clear the real titlebar. The + // contrib shell zeroes --titlebar-height for CONTENT areas (panes sit + // below its in-flow title bar), and CSS vars inherit through the DOM — + // so a fixed overlay mounted inside a zone would read 0 and bleed to + // the edges. Re-pin the real height at the overlay root. + style={{ '--titlebar-height': `${TITLEBAR_HEIGHT}px` } as CSSProperties} >
- {c.review} + {/* Pure self-naming label — redundant under a zone tab that already + says "review", so the zone header hides it (styles.css). */} + {c.review}