diff --git a/.github/tauri-cef-expected-sha b/.github/tauri-cef-expected-sha index fc80ee845..0bc836bb0 100644 --- a/.github/tauri-cef-expected-sha +++ b/.github/tauri-cef-expected-sha @@ -1 +1 @@ -5ec3d8836cbae300e36b7a9c0d302a65de92c58d +11ef51edbeadcca4517b18a037fff858a5dfae0f diff --git a/.gitignore b/.gitignore index b5dfc2b82..4ab2b5466 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +worktrees/* +!worktrees/.gitkeep + # Workflow docs (local only) workflow create_issue @@ -129,4 +132,4 @@ distribution.cer # Release note previews CHANGELOG.preview.md *.profraw -*.diff \ No newline at end of file +*.diff diff --git a/.husky/pre-push b/.husky/pre-push index d39768b34..8cbf5b3b4 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,16 +1,26 @@ #!/usr/bin/env sh -# Bail out immediately on Ctrl+C / SIGTERM. Without this trap, an interrupt -# only kills the current pnpm subprocess; the script then captures its 130 -# exit, mistakes it for a normal failure, and runs the next pnpm step. +# Bail out immediately on Ctrl+C / SIGTERM. abort() { + EXIT_CODE="$1" echo echo "Pre-push aborted." - trap - INT TERM - kill -- -$$ 2>/dev/null - exit 130 + exit "$EXIT_CODE" +} +trap 'abort 130' INT +trap 'abort 143' TERM + +# Commands run synchronously in the hook's foreground process group, so Ctrl+C +# reaches pnpm and its children. Do not mistake the resulting exit code for a +# normal check failure and continue with the next command. +run_check() { + "$@" + CHECK_EXIT=$? + case "$CHECK_EXIT" in + 130|143) abort "$CHECK_EXIT" ;; + esac + return "$CHECK_EXIT" } -trap abort INT TERM # Windows Git Bash can miss Node/Pnpm in PATH when hooks run. # Recover from common PATH drift by hydrating from where.exe. @@ -65,7 +75,7 @@ fi # Run format check first (capture exit code without breaking script) set +e -pnpm format:check +run_check pnpm format:check FORMAT_EXIT=$? set -e @@ -77,7 +87,7 @@ fi # Run lint check (capture exit code without breaking script) set +e -pnpm lint +run_check pnpm lint LINT_EXIT=$? set -e @@ -89,19 +99,19 @@ fi # Run TypeScript compile check (capture exit code without breaking script) set +e -pnpm compile +run_check pnpm compile COMPILE_EXIT=$? set -e # Run Clippy for both Rust codebases; Clippy also performs compile checks. set +e -pnpm rust:clippy +run_check pnpm rust:clippy RUST_CLIPPY_EXIT=$? set -e # Enforce scoped cmd-* tokens in components/commands/ set +e -pnpm --dir app run lint:commands-tokens +run_check pnpm --dir app run lint:commands-tokens CMD_TOKENS_EXIT=$? set -e diff --git a/app/src-tauri/vendor/tauri-cef b/app/src-tauri/vendor/tauri-cef index 5ec3d8836..11ef51edb 160000 --- a/app/src-tauri/vendor/tauri-cef +++ b/app/src-tauri/vendor/tauri-cef @@ -1 +1 @@ -Subproject commit 5ec3d8836cbae300e36b7a9c0d302a65de92c58d +Subproject commit 11ef51edbeadcca4517b18a037fff858a5dfae0f diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx index 492e4240b..a62f7bc15 100644 --- a/app/src/AppRoutes.tsx +++ b/app/src/AppRoutes.tsx @@ -1,4 +1,4 @@ -import { type Location, Navigate, Route, Routes } from 'react-router-dom'; +import { type Location, Navigate, Route, Routes, useLocation } from 'react-router-dom'; import AgentWorldShell from './agentworld/AgentWorldShell'; import AgentWorld from './agentworld/pages/AgentWorld'; @@ -18,7 +18,6 @@ import FlowsPage from './pages/FlowsPage'; import Invites from './pages/Invites'; import Notifications from './pages/Notifications'; import Onboarding from './pages/onboarding/Onboarding'; -import OrchestrationPage from './pages/OrchestrationPage'; import { PttOverlayPage } from './pages/PttOverlayPage'; import Rewards from './pages/Rewards'; import Skills from './pages/Skills'; @@ -36,6 +35,38 @@ interface AppRoutesProps { location?: Location | string; } +/** + * Redirects the retired `/orchestration` route to its new home under Brain + * (`/brain?tab=orchestration`), mapping the legacy `?tab=`/`?sub=` query onto + * Brain's `?ov=`/`?sub=` scheme so old deep links land on the same view: + * - `?tab=connections|discover|usage` → `?ov=network&sub=` + * - `?tab=agent|overview|tasks|network|medulla` → `?ov=` + * - `?session=` is preserved for the agent chat. + */ +const NETWORK_SUBS = ['connections', 'discover', 'usage']; +const ORCH_VIEWS = ['medulla', 'agent', 'overview', 'tasks', 'network']; + +function OrchestrationRedirect() { + const { search } = useLocation(); + const legacy = new URLSearchParams(search); + const tab = legacy.get('tab'); + + const next = new URLSearchParams(); + next.set('tab', 'orchestration'); + if (tab && NETWORK_SUBS.includes(tab)) { + next.set('ov', 'network'); + next.set('sub', tab); + } else { + if (tab && ORCH_VIEWS.includes(tab)) next.set('ov', tab); + const sub = legacy.get('sub'); + if (sub && NETWORK_SUBS.includes(sub)) next.set('sub', sub); + } + const session = legacy.get('session'); + if (session) next.set('session', session); + + return ; +} + const AppRoutes = ({ location }: AppRoutesProps = {}) => { // Mobile target (iOS or Android): pair → Human/Chat/Settings only. // Desktop routes are not rendered. @@ -137,21 +168,16 @@ const AppRoutes = ({ location }: AppRoutesProps = {}) => { } /> - {/* Orchestration — TinyPlace multi-agent coordination surface, promoted - from a Brain sub-tab into a first-class sidebar destination (sits - right after Workflows). */} - - - - } - /> - {/* Back-compat: the old Brain deep link → the promoted top-level tab. */} + {/* Orchestration folded back under Brain (`/brain?tab=orchestration`). + The old first-class `/orchestration` route and the even older Brain + deep link both redirect there; `` maps the + legacy `?tab=`/`?sub=` query onto Brain's `?ov=`/`?sub=` scheme so + deep links (e.g. `/orchestration?tab=tasks`) keep landing on the same + view. */} + } /> } + element={} /> {/* Back-compat: /activity and /intelligence → settings notifications page. */} diff --git a/app/src/components/InitProgressScreen/HarnessInitOverlay.test.tsx b/app/src/components/InitProgressScreen/HarnessInitOverlay.test.tsx index 9e214e6e6..454b1c4a3 100644 --- a/app/src/components/InitProgressScreen/HarnessInitOverlay.test.tsx +++ b/app/src/components/InitProgressScreen/HarnessInitOverlay.test.tsx @@ -82,6 +82,27 @@ describe('HarnessInitOverlay', () => { expect(second.container).toBeEmptyDOMElement(); }); + it('coalesces overlapping status polls into one RPC (StrictMode double-mount)', async () => { + // Hold the fetch pending so both mounts' immediate polls overlap. + let resolveFetch: (snap: HarnessInitSnapshot) => void = () => {}; + fetchHarnessInitStatus.mockImplementation( + () => + new Promise(resolve => { + resolveFetch = resolve; + }) + ); + + // Two concurrent overlays stand in for the effect→cleanup→effect double-mount. + renderWithProviders(); + renderWithProviders(); + + // Both immediate polls should share a single in-flight request. + expect(fetchHarnessInitStatus).toHaveBeenCalledTimes(1); + + resolveFetch(snapshot({ overall: 'done', startedAt: 'warm-run' })); + await waitFor(() => expect(screen.queryByText('Run in background')).not.toBeInTheDocument()); + }); + it('reopens for a genuinely new provisioning run after a prior dismissal', async () => { // Dismiss the first run. fetchHarnessInitStatus.mockResolvedValue(snapshot({ startedAt: 'run-1' })); diff --git a/app/src/components/InitProgressScreen/HarnessInitOverlay.tsx b/app/src/components/InitProgressScreen/HarnessInitOverlay.tsx index 0b6b0836d..bd8b79faf 100644 --- a/app/src/components/InitProgressScreen/HarnessInitOverlay.tsx +++ b/app/src/components/InitProgressScreen/HarnessInitOverlay.tsx @@ -25,6 +25,30 @@ const UNKEYED_RUN = 'pending'; let dismissedRunMirror: string | null = null; +// Coalesce overlapping status polls onto a single in-flight request. React +// StrictMode double-mounts this overlay in dev (effect → cleanup → effect), +// and each setup fires an immediate poll — without this that boots two +// `harness_init_status` RPCs at the same instant. Concurrent callers share the +// in-flight promise; it clears once settled, so the ongoing (sequential) poll +// loop is unaffected. Also guards any genuine remount during the boot window. +let inflightStatusFetch: Promise | null = null; + +function fetchHarnessInitStatusCoalesced(): Promise { + if (inflightStatusFetch) { + log('status poll: joining in-flight request (coalesced)'); + return inflightStatusFetch; + } + log('status poll: dispatching harness_init_status'); + const pending = fetchHarnessInitStatus().finally(() => { + if (inflightStatusFetch === pending) { + inflightStatusFetch = null; + log('status poll: in-flight request settled, cache cleared'); + } + }); + inflightStatusFetch = pending; + return pending; +} + function runKey(snapshot: HarnessInitSnapshot | null): string { return snapshot?.startedAt ?? UNKEYED_RUN; } @@ -85,7 +109,7 @@ export default function HarnessInitOverlay() { const poll = async () => { try { - const next = await fetchHarnessInitStatus(); + const next = await fetchHarnessInitStatusCoalesced(); if (cancelledRef.current || dismissedRef.current) { return; } diff --git a/app/src/components/LocalAIDownloadSnackbar.tsx b/app/src/components/LocalAIDownloadSnackbar.tsx index 419c4b560..3ac116e97 100644 --- a/app/src/components/LocalAIDownloadSnackbar.tsx +++ b/app/src/components/LocalAIDownloadSnackbar.tsx @@ -1,7 +1,9 @@ +import debugFactory from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useT } from '../lib/i18n/I18nContext'; +import { useCoreState } from '../providers/CoreStateProvider'; import { formatBytes, formatEta, @@ -18,7 +20,35 @@ import { } from '../utils/tauriCommands'; import Button from './ui/Button'; -const POLL_INTERVAL = 2000; +const log = debugFactory('local-ai-download'); + +const ACTIVE_POLL_INTERVAL = 2000; + +const IN_FLIGHT_STATES = new Set(['loading', 'downloading', 'installing']); + +/** Whether a `LocalAiStatus.state` string denotes an active download/bootstrap. */ +const isInFlightState = (state: string | undefined): boolean => + state != null && IN_FLIGHT_STATES.has(state); + +/** + * Pure predicate deciding whether a download/bootstrap is currently in flight, + * mirroring the `isDownloading` derivation used for rendering. Drives the fast + * poll's continuation: keep polling `inference_downloads_progress` + + * `inference_status` only while a download is genuinely in flight. + */ +const isDownloadInFlight = ( + status: LocalAiStatus | null, + downloads: LocalAiDownloadsProgress | null +): boolean => { + const downloadState = downloads?.state; + const currentState = isInFlightState(downloadState) + ? downloadState + : (status?.state ?? downloadState ?? 'idle'); + return ( + isInFlightState(currentState) || + (downloads?.progress != null && downloads.progress > 0 && downloads.progress < 1) + ); +}; /** * Persistent snackbar that shows local AI download progress. @@ -31,7 +61,7 @@ const LocalAIDownloadSnackbar = () => { const [downloads, setDownloads] = useState(null); const [dismissed, setDismissed] = useState(false); const [collapsed, setCollapsed] = useState(false); - const timerRef = useRef>(undefined); + const timerRef = useRef>(undefined); // Track previous isDownloading in state so we can reset the dismiss flag on a // not-downloading → downloading transition during render (render-phase update, // the officially recommended React pattern for adjusting state on derived-value changes). @@ -46,11 +76,26 @@ const LocalAIDownloadSnackbar = () => { } })(); - // Poll download status + // Detect an active download from the folded local-AI state in the app-state + // snapshot (polled by CoreStateProvider) instead of a dedicated idle poll of + // the inference RPCs. When idle this component issues ZERO inference calls; + // the snapshot's `runtime.localAi.state` is what flips us into the fast poll. + const { snapshot: coreSnapshot } = useCoreState(); + const coreDownloadActive = isInFlightState(coreSnapshot.runtime.localAi?.state ?? undefined); + + // While a download is in flight, poll the inference RPCs fast for smooth, + // granular progress/speed/ETA (the 2–5s app-state cadence is too coarse and + // carries no downloads-progress detail). The poll starts when core state + // reports activity and keeps going as long as the download itself is in + // flight, then stops — so there is no steady-state inference polling. useEffect(() => { - if (!tauriAvailable) return; + if (!tauriAvailable || !coreDownloadActive) return; + + let cancelled = false; + log('fast poll: starting (core reports download active)'); const poll = async () => { + let settled = false; try { const [statusRes, downloadsRes] = await Promise.all([ openhumanLocalAiStatus(), @@ -58,26 +103,47 @@ const LocalAIDownloadSnackbar = () => { ]); if (statusRes.result) setStatus(statusRes.result); if (downloadsRes.result) setDownloads(downloadsRes.result); - } catch { - // Silently ignore — core may not be ready + // The download reached a terminal state — stop the fast poll early + // rather than waiting for core state to catch up (it lags up to ~5s). + settled = !isDownloadInFlight(statusRes.result ?? null, downloadsRes.result ?? null); + } catch (err) { + // Transient RPC failure (core may be busy). Do NOT treat this as + // settled: keep polling while core state still reports the download + // active, otherwise one blip would permanently stop progress updates + // for the rest of the download (the effect won't re-run until + // `coreDownloadActive` flips). + log('fast poll: transient error, will retry: %O', err); + } + if (!cancelled && !settled) { + timerRef.current = setTimeout(poll, ACTIVE_POLL_INTERVAL); + } else if (settled) { + log('fast poll: download settled, stopping'); } }; void poll(); - timerRef.current = setInterval(poll, POLL_INTERVAL); - return () => clearInterval(timerRef.current); - }, [tauriAvailable]); + return () => { + cancelled = true; + if (timerRef.current) clearTimeout(timerRef.current); + log('fast poll: stopped (unmount or core reports inactive)'); + }; + }, [tauriAvailable, coreDownloadActive]); const downloadState = downloads?.state; const currentState = downloadState === 'loading' || downloadState === 'downloading' || downloadState === 'installing' ? downloadState : (status?.state ?? downloadState ?? 'idle'); + // Gate on `coreDownloadActive` so a download that the core no longer reports + // active can't leave the snackbar stuck: when the folded state flips inactive, + // polling stops and any still-"downloading" local status/downloads must not + // keep the overlay visible. const isDownloading = - currentState === 'loading' || - currentState === 'downloading' || - currentState === 'installing' || - (downloads?.progress != null && downloads.progress > 0 && downloads.progress < 1); + coreDownloadActive && + (currentState === 'loading' || + currentState === 'downloading' || + currentState === 'installing' || + (downloads?.progress != null && downloads.progress > 0 && downloads.progress < 1)); // Render-phase update: when a new download cycle starts (not-downloading → downloading), // reset the dismiss/collapsed flags so the snackbar reappears automatically. diff --git a/app/src/components/__tests__/LocalAIDownloadSnackbar.test.tsx b/app/src/components/__tests__/LocalAIDownloadSnackbar.test.tsx index 8caf2bde8..1904cf565 100644 --- a/app/src/components/__tests__/LocalAIDownloadSnackbar.test.tsx +++ b/app/src/components/__tests__/LocalAIDownloadSnackbar.test.tsx @@ -1,6 +1,7 @@ import { screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getCoreStateSnapshot, setCoreStateSnapshot } from '../../lib/coreState/store'; import { renderWithProviders } from '../../test/test-utils'; import LocalAIDownloadSnackbar from '../LocalAIDownloadSnackbar'; @@ -11,6 +12,29 @@ vi.mock('../../utils/tauriCommands', () => ({ openhumanLocalAiDownloadsProgress: vi.fn().mockResolvedValue({ result: null }), })); +/** + * Seed the folded `runtime.localAi.state` in core state. The snackbar reads this + * (instead of an idle inference poll) to decide whether to run its fast poll, so + * tests that expect it to poll must mark a download active here first. + */ +function seedLocalAiState(state: string | null): void { + const current = getCoreStateSnapshot(); + setCoreStateSnapshot({ + ...current, + snapshot: { + ...current.snapshot, + runtime: { + ...current.snapshot.runtime, + localAi: state === null ? null : ({ state } as never), + }, + }, + }); +} + +afterEach(() => { + seedLocalAiState(null); +}); + describe('LocalAIDownloadSnackbar', () => { it('does not render when not in Tauri environment', () => { renderWithProviders(); @@ -19,30 +43,26 @@ describe('LocalAIDownloadSnackbar', () => { expect(screen.queryByLabelText('Dismiss download notification')).not.toBeInTheDocument(); }); - it('does not render when no download is active', async () => { + it('does not poll or render when core state reports no active download', async () => { const tauriCommands = await import('../../utils/tauriCommands'); vi.mocked(tauriCommands.isTauri).mockReturnValue(true); - vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({ - result: { state: 'ready' } as never, - logs: [], - }); - vi.mocked(tauriCommands.openhumanLocalAiDownloadsProgress).mockResolvedValue({ - result: { state: 'idle', progress: null } as never, - logs: [], - }); + vi.mocked(tauriCommands.openhumanLocalAiStatus).mockClear(); + vi.mocked(tauriCommands.openhumanLocalAiDownloadsProgress).mockClear(); + seedLocalAiState('ready'); renderWithProviders(); - // Wait for poll cycle await vi.waitFor(() => { expect(screen.queryByText('Downloading')).not.toBeInTheDocument(); }); + // Idle → zero inference polls (the fold's whole point). + expect(tauriCommands.openhumanLocalAiStatus).not.toHaveBeenCalled(); + expect(tauriCommands.openhumanLocalAiDownloadsProgress).not.toHaveBeenCalled(); - // Reset mock vi.mocked(tauriCommands.isTauri).mockReturnValue(false); }); - it('renders immediately when status reports bootstrap activity before downloads progress catches up', async () => { + it('polls and renders when core state reports an active download', async () => { const tauriCommands = await import('../../utils/tauriCommands'); vi.mocked(tauriCommands.isTauri).mockReturnValue(true); vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({ @@ -59,6 +79,7 @@ describe('LocalAIDownloadSnackbar', () => { result: { state: 'idle', progress: null } as never, logs: [], }); + seedLocalAiState('loading'); renderWithProviders(); diff --git a/app/src/components/layout/shell/CollapsedNavRail.test.tsx b/app/src/components/layout/shell/CollapsedNavRail.test.tsx index 7b4984f1b..b9d10b612 100644 --- a/app/src/components/layout/shell/CollapsedNavRail.test.tsx +++ b/app/src/components/layout/shell/CollapsedNavRail.test.tsx @@ -29,7 +29,6 @@ describe('CollapsedNavRail', () => { 'nav.human', 'nav.brain', 'nav.flows', - 'nav.orchestration', 'nav.agentWorld', 'nav.connections', ]) { diff --git a/app/src/components/orchestration/AgentChatPanel.tsx b/app/src/components/orchestration/AgentChatPanel.tsx index 0d12a7c0e..805c7ff11 100644 --- a/app/src/components/orchestration/AgentChatPanel.tsx +++ b/app/src/components/orchestration/AgentChatPanel.tsx @@ -367,8 +367,8 @@ function SessionChatView({ session }: { session: SessionSummary }) { export interface AgentChatPanelProps { /** * Controlled open peer-session id (the full-page session subpage). When - * `onOpenSession` is provided the parent owns this (OrchestrationPage drives - * it from the `?session=` query param + the sidebar's active sub-agents list); + * `onOpenSession` is provided the parent owns this (OrchestrationView drives + * it from the `?session=` query param + the active sub-agents rail); * otherwise the panel falls back to its own local state. */ openSessionId?: string | null; @@ -405,8 +405,8 @@ export default function AgentChatPanel({ const [composerBody, setComposerBody] = useState(''); const [sending, setSending] = useState(false); const [runningReview, setRunningReview] = useState(false); - // Controlled by the parent when `onOpenSession` is wired (OrchestrationPage - // drives it from the URL + sidebar session list); local state otherwise. + // Controlled by the parent when `onOpenSession` is wired (OrchestrationView + // drives it from the URL + active sub-agents rail); local state otherwise. const [localOpenSessionId, setLocalOpenSessionId] = useState(null); const openSessionId = controlledOpenSessionId !== undefined ? controlledOpenSessionId : localOpenSessionId; diff --git a/app/src/components/orchestration/ConnectionsPanel.tsx b/app/src/components/orchestration/ConnectionsPanel.tsx index 35c44e7c9..94ecae191 100644 --- a/app/src/components/orchestration/ConnectionsPanel.tsx +++ b/app/src/components/orchestration/ConnectionsPanel.tsx @@ -8,7 +8,7 @@ * composer. Pending requests + a summary sit on top. Linking new agents still * lives in the sibling DiscoverPanel. * - * Renders inside the same single `max-w-3xl` column OrchestrationPage gives it. + * Renders inside the same single `max-w-3xl` column OrchestrationView gives it. */ import debugFactory from 'debug'; import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; diff --git a/app/src/components/orchestration/OrchestrationView.tsx b/app/src/components/orchestration/OrchestrationView.tsx new file mode 100644 index 000000000..c527e743e --- /dev/null +++ b/app/src/components/orchestration/OrchestrationView.tsx @@ -0,0 +1,251 @@ +/** + * OrchestrationView — the TinyPlace multi-agent orchestration surface, embedded + * as the Brain page's **Orchestration** sub-tab (`/brain?tab=orchestration`). + * + * It was previously a first-class sidebar destination (`/orchestration`) with + * its own projected sidebar. Folding it back under Brain means the top-level + * views — **Overview** (Medulla), **Chat**, **Agent graph**, **Tasks**, + * **Network** — become an in-content chip row instead of a left rail, so Brain + * keeps a single sidebar. State travels in query params that don't collide with + * Brain's own `?tab=`: + * + * - `?ov=` — the orchestration view (medulla | agent | overview | tasks | network) + * - `?sub=` — the network sub-view (connections | discover | usage) + * - `?session=` — the open peer session (read by {@link AgentChatPanel}) + * + * The live list of active peer sessions (the old sidebar "Active sub-agents" + * rail) moves into the **Chat** view as a left column, since clicking a session + * opens it in that chat anyway. + */ +import { useCallback, useMemo } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { useMedullaAccess } from '../../lib/orchestration/useMedullaAccess'; +import { useContactSessions } from '../../lib/orchestration/useOrchestrationSessions'; +import ChipTabs from '../layout/ChipTabs'; +import PageSectionHeader from '../layout/PageSectionHeader'; +import PanelPage from '../layout/PanelPage'; +import ActiveSubagentsRail from './ActiveSubagentsRail'; +import AgentChatPanel from './AgentChatPanel'; +import ConnectionsPanel from './ConnectionsPanel'; +import MedullaDemoChat from './demo/MedullaDemoChat'; +import MedullaDemoGraph from './demo/MedullaDemoGraph'; +import MedullaDemoNetwork from './demo/MedullaDemoNetwork'; +import DiscoverPanel from './DiscoverPanel'; +import MedullaOverviewPanel from './MedullaOverviewPanel'; +import OrchestratorTaskBoard from './OrchestratorTaskBoard'; +import OverviewPanel from './OverviewPanel'; +import UsagePanel from './UsagePanel'; + +type OrchestrationTab = 'medulla' | 'overview' | 'agent' | 'tasks' | 'network'; +type NetworkSub = 'connections' | 'discover' | 'usage'; + +const ORCH_TABS: readonly OrchestrationTab[] = ['medulla', 'agent', 'overview', 'tasks', 'network']; +const NETWORK_SUBS: readonly NetworkSub[] = ['connections', 'discover', 'usage']; + +export default function OrchestrationView() { + const { t } = useT(); + const location = useLocation(); + const navigate = useNavigate(); + const contactSessions = useContactSessions(); + // Without Medulla access, Medulla-specific live surfaces are replaced by a + // scale showcase. The global task board remains available to every user. + const hasMedullaAccess = useMedullaAccess(); + + const params = useMemo(() => new URLSearchParams(location.search), [location.search]); + const rawOv = params.get('ov'); + const rawSub = params.get('sub'); + + // Default landing is the Medulla overview (the orchestration overview page). + const activeTab: OrchestrationTab = (ORCH_TABS as readonly string[]).includes(rawOv ?? '') + ? (rawOv as OrchestrationTab) + : 'medulla'; + + const networkSub: NetworkSub = NETWORK_SUBS.includes(rawSub as NetworkSub) + ? (rawSub as NetworkSub) + : 'connections'; + + const openSessionId = params.get('session'); + + const updateParams = useCallback( + (mut: (p: URLSearchParams) => void) => { + const next = new URLSearchParams(location.search); + mut(next); + navigate({ pathname: location.pathname, search: `?${next.toString()}` }); + }, + [location.pathname, location.search, navigate] + ); + + const setActiveTab = useCallback( + (tab: OrchestrationTab) => { + updateParams(p => { + // Preserve the hosting Brain sub-tab; only the orchestration view moves. + p.set('tab', 'orchestration'); + p.set('ov', tab); + // Selecting Chat returns to the master chat — drop any open session so + // the session subpage closes (there's no in-view back button). + if (tab === 'agent') p.delete('session'); + // Landing on the network page needs a valid sub selected. + if (tab === 'network' && !NETWORK_SUBS.includes(p.get('sub') as NetworkSub)) { + p.set('sub', 'connections'); + } + }); + }, + [updateParams] + ); + + const setNetworkSub = useCallback( + (sub: NetworkSub) => { + updateParams(p => { + p.set('tab', 'orchestration'); + p.set('ov', 'network'); + p.set('sub', sub); + }); + }, + [updateParams] + ); + + // Open (or close, when null) a peer session in the agent chat. Always lands on + // the agent tab so the session's transcript is actually visible. + const setOpenSessionId = useCallback( + (sessionId: string | null) => { + updateParams(p => { + p.set('tab', 'orchestration'); + p.set('ov', 'agent'); + if (sessionId) p.set('session', sessionId); + else p.delete('session'); + }); + }, + [updateParams] + ); + + console.debug( + '[orchestration] view mount ov=%s sub=%s session=%s medullaAccess=%s', + activeTab, + networkSub, + openSessionId, + hasMedullaAccess + ); + + const chipItems = useMemo( + () => [ + { id: 'medulla' as const, label: t('orchPage.medulla.nav') }, + { id: 'agent' as const, label: t('orchPage.agent.nav') }, + { id: 'overview' as const, label: t('orchPage.overview.nav') }, + { id: 'tasks' as const, label: t('orchPage.tasks.nav') }, + { id: 'network' as const, label: t('orchPage.group.network') }, + ], + [t] + ); + + return ( +
+ {/* Top-level view switcher — the old sidebar rail, folded into an in-content + chip row so Brain keeps a single sidebar. */} +
+ + as="tab" + ariaLabel={t('nav.orchestration')} + testIdPrefix="orch-view" + items={chipItems} + value={activeTab} + onChange={setActiveTab} + /> +
+ +
+ {activeTab === 'medulla' ? ( + // Orchestration overview — the Medulla teaser / early-access landing. + + ) : activeTab === 'overview' ? ( + // Interactive graph of the agent / sub-agent system — or the scale + // showcase (core → 2 devices → 120 agents) without Medulla access. + hasMedullaAccess ? ( + + ) : ( + + ) + ) : activeTab === 'agent' ? ( + // Full-bleed so it reads exactly like the normal chat page — or a + // read-only demo conversation (composer disabled) without Medulla + // access. The active peer-session list sits in a left column (moved + // from the former sidebar rail) when there are live sessions. + hasMedullaAccess ? ( +
+ {contactSessions.byContact.size > 0 ? ( + + ) : null} +
+ +
+
+ ) : ( + + ) + ) : activeTab === 'tasks' ? ( + // One global Kanban board owned by the orchestrator (not per-thread). + // Tasks predate Medulla access and must remain usable without it. +
+ +
+ + +
+
+
+ ) : hasMedullaAccess ? ( +
+ {/* Network: one page with a Brain-style chip sub-nav (flush pills, no + header background) over connections/discover/usage, aligned to the + same content column. */} + +
+ + as="tab" + ariaLabel={t('orchPage.group.network')} + testIdPrefix="orch-network" + className="inline-flex flex-wrap items-center gap-1.5" + items={[ + { id: 'connections', label: t('orchPage.connections.nav') }, + { id: 'discover', label: t('orchPage.discover.nav') }, + { id: 'usage', label: t('orchPage.usage.nav') }, + ]} + value={networkSub} + onChange={setNetworkSub} + /> + } + /> + {networkSub === 'connections' && ( + setNetworkSub('discover')} + onInitializeAgent={() => setActiveTab('agent')} + /> + )} + {networkSub === 'discover' && } + {networkSub === 'usage' && } +
+
+
+ ) : ( + // Scale showcase: fake peer-agent mesh with the preview banner. + + )} +
+
+ ); +} diff --git a/app/src/components/orchestration/__tests__/OrchestrationView.test.tsx b/app/src/components/orchestration/__tests__/OrchestrationView.test.tsx new file mode 100644 index 000000000..25c5fd562 --- /dev/null +++ b/app/src/components/orchestration/__tests__/OrchestrationView.test.tsx @@ -0,0 +1,144 @@ +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../../../test/test-utils'; +import OrchestrationView from '../OrchestrationView'; + +vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); + +// Medulla access is toggled per-test; default to granted so the view-routing +// tests exercise the live panels. +let medullaAccess = true; +vi.mock('../../../lib/orchestration/useMedullaAccess', () => ({ + useMedullaAccess: () => medullaAccess, +})); + +// No live peer sessions by default, so the agent view's left rail stays hidden. +vi.mock('../../../lib/orchestration/useOrchestrationSessions', () => ({ + useContactSessions: () => ({ byContact: new Map() }), +})); + +// Stub the data-backed panels so the view's tab routing is tested in isolation +// (the panels have their own unit tests). +vi.mock('../MedullaOverviewPanel', () => ({ default: () =>
})); +vi.mock('../AgentChatPanel', () => ({ default: () =>
})); +vi.mock('../ConnectionsPanel', () => ({ + default: ({ onDiscover }: { onDiscover?: () => void }) => ( + + ), +})); +vi.mock('../DiscoverPanel', () => ({ default: () =>
})); +vi.mock('../UsagePanel', () => ({ default: () =>
})); +vi.mock('../OverviewPanel', () => ({ default: () =>
})); +vi.mock('../OrchestratorTaskBoard', () => ({ default: () =>
})); +vi.mock('../demo/MedullaDemoChat', () => ({ default: () =>
})); +vi.mock('../demo/MedullaDemoGraph', () => ({ + default: () =>
, +})); +vi.mock('../demo/MedullaDemoNetwork', () => ({ + default: () =>
, +})); + +const BASE = '/brain?tab=orchestration'; + +describe('OrchestrationView (Medulla access)', () => { + beforeEach(() => { + medullaAccess = true; + }); + + it('defaults to the Medulla overview panel', async () => { + await act(async () => { + renderWithProviders(, { initialEntries: [BASE] }); + }); + expect(screen.getByTestId('panel-medulla')).toBeInTheDocument(); + }); + + it('renders the agent chat panel from ?ov=agent', async () => { + await act(async () => { + renderWithProviders(, { initialEntries: [`${BASE}&ov=agent`] }); + }); + expect(screen.getByTestId('panel-agent')).toBeInTheDocument(); + }); + + it('renders the agent graph panel from ?ov=overview', async () => { + await act(async () => { + renderWithProviders(, { initialEntries: [`${BASE}&ov=overview`] }); + }); + expect(screen.getByTestId('panel-graph')).toBeInTheDocument(); + }); + + it('renders the task board from ?ov=tasks', async () => { + await act(async () => { + renderWithProviders(, { initialEntries: [`${BASE}&ov=tasks`] }); + }); + expect(screen.getByTestId('panel-tasks')).toBeInTheDocument(); + }); + + it.each([ + ['connections', 'panel-connections'], + ['discover', 'panel-discover'], + ['usage', 'panel-usage'], + ])('renders the %s network sub from ?ov=network&sub=%s', async (sub, testId) => { + await act(async () => { + renderWithProviders(, { + initialEntries: [`${BASE}&ov=network&sub=${sub}`], + }); + }); + expect(screen.getByTestId(testId)).toBeInTheDocument(); + }); + + it('switches views via the chip nav', async () => { + await act(async () => { + renderWithProviders(, { initialEntries: [BASE] }); + }); + await act(async () => { + fireEvent.click(screen.getByTestId('orch-view-network')); + }); + await waitFor(() => expect(screen.getByTestId('panel-connections')).toBeInTheDocument()); + }); + + it('lets the connections panel jump to discover via its callback', async () => { + await act(async () => { + renderWithProviders(, { + initialEntries: [`${BASE}&ov=network&sub=connections`], + }); + }); + await act(async () => { + fireEvent.click(screen.getByTestId('panel-connections')); + }); + await waitFor(() => expect(screen.getByTestId('panel-discover')).toBeInTheDocument()); + }); +}); + +describe('OrchestrationView scale showcase (no Medulla access)', () => { + beforeEach(() => { + medullaAccess = false; + }); + + it.each([ + ['agent', 'orch-demo-chat'], + ['overview', 'orch-demo-graph'], + ['network', 'orch-demo-network'], + ])('renders the demo surface for ?ov=%s', async (ov, testId) => { + await act(async () => { + renderWithProviders(, { initialEntries: [`${BASE}&ov=${ov}`] }); + }); + expect(screen.getByTestId(testId)).toBeInTheDocument(); + }); + + it('keeps the real task board available without Medulla access', async () => { + await act(async () => { + renderWithProviders(, { initialEntries: [`${BASE}&ov=tasks`] }); + }); + expect(screen.getByTestId('panel-tasks')).toBeInTheDocument(); + }); + + it('still lands on the Medulla overview by default', async () => { + await act(async () => { + renderWithProviders(, { initialEntries: [BASE] }); + }); + expect(screen.getByTestId('panel-medulla')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/settings/settingsRouteElements.tsx b/app/src/components/settings/settingsRouteElements.tsx index 69d3be1bb..e18300e14 100644 --- a/app/src/components/settings/settingsRouteElements.tsx +++ b/app/src/components/settings/settingsRouteElements.tsx @@ -179,8 +179,8 @@ export function settingsRouteElements(): ReactNode { element={} /> )} /> - {/* Tasks now live on the Orchestration page's Kanban board. */} - } /> + {/* Tasks now live on Brain's Orchestration Kanban board. */} + } /> {/* Workflows is a first-level module now — /settings/automations bounces to /flows (the Workflows page). */} } /> diff --git a/app/src/config/__tests__/navConfig.test.ts b/app/src/config/__tests__/navConfig.test.ts index aded4ceef..862870f4b 100644 --- a/app/src/config/__tests__/navConfig.test.ts +++ b/app/src/config/__tests__/navConfig.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it } from 'vitest'; import { AVATAR_MENU_ITEMS, NAV_TABS } from '../navConfig'; describe('NAV_TABS', () => { - it('has exactly 7 entries', () => { - expect(NAV_TABS).toHaveLength(7); + it('has exactly 6 entries', () => { + expect(NAV_TABS).toHaveLength(6); }); it('has the correct ids in order', () => { @@ -13,7 +13,6 @@ describe('NAV_TABS', () => { 'human', 'brain', 'flows', - 'orchestration', 'agent-world', 'connections', ]); @@ -25,7 +24,6 @@ describe('NAV_TABS', () => { '/human', '/brain', '/flows', - '/orchestration', '/agent-world', '/connections', ]); @@ -37,7 +35,6 @@ describe('NAV_TABS', () => { 'nav.human', 'nav.brain', 'nav.flows', - 'nav.orchestration', 'nav.agentWorld', 'nav.connections', ]); @@ -49,12 +46,15 @@ describe('NAV_TABS', () => { 'tab-human', 'tab-brain', 'tab-flows', - 'tab-orchestration', 'tab-agent-world', 'tab-connections', ]); }); + it('no longer contains a top-level orchestration tab (folded under Brain)', () => { + expect(NAV_TABS.find(t => t.id === 'orchestration')).toBeUndefined(); + }); + it('no longer contains home or settings tabs (moved to the sidebar header)', () => { expect(NAV_TABS.find(t => t.id === 'home')).toBeUndefined(); expect(NAV_TABS.find(t => t.id === 'settings')).toBeUndefined(); diff --git a/app/src/config/navConfig.ts b/app/src/config/navConfig.ts index 97a2c4fb7..d5b5915d1 100644 --- a/app/src/config/navConfig.ts +++ b/app/src/config/navConfig.ts @@ -22,11 +22,11 @@ export interface NavTab { /** * Ordered list of sidebar nav entries: - * chat → human → brain → flows → orchestration → agent-world → connections + * chat → human → brain → flows → agent-world → connections * - * The Orchestration tab (TinyPlace multi-agent coordination) sits right after - * Workflows; it was promoted out of the Brain sub-tab drawer into a first-class - * destination at `/orchestration`. + * Orchestration (TinyPlace multi-agent coordination) is no longer a top-level + * tab — it was folded back under Brain as the `/brain?tab=orchestration` + * sub-tab, so the sidebar stays lean. * * Settings has no primary tab — it's reached via the gear icon in the sidebar * header. Chat is the default landing and the merged Home surface: its empty @@ -42,12 +42,6 @@ export const NAV_TABS: NavTab[] = [ { id: 'human', labelKey: 'nav.human', path: '/human', walkthroughAttr: 'tab-human' }, { id: 'brain', labelKey: 'nav.brain', path: '/brain', walkthroughAttr: 'tab-brain' }, { id: 'flows', labelKey: 'nav.flows', path: '/flows', walkthroughAttr: 'tab-flows' }, - { - id: 'orchestration', - labelKey: 'nav.orchestration', - path: '/orchestration', - walkthroughAttr: 'tab-orchestration', - }, { id: 'agent-world', labelKey: 'nav.agentWorld', diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index 243f234dc..b5a74e1dd 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -37,6 +37,8 @@ import { CitationChips, type MessageCitation, } from '../../features/conversations/components/CitationChips'; +import { InterruptedAnswer } from '../../features/conversations/components/InterruptedAnswer'; +import { PastTurnInsights } from '../../features/conversations/components/PastTurnInsights'; import { PlanReviewCard } from '../../features/conversations/components/PlanReviewCard'; import { SubagentDrawer } from '../../features/conversations/components/SubagentDrawer'; import { @@ -46,8 +48,6 @@ import { } from '../../features/conversations/components/ThreadGoalChip'; import { ThreadTodoStrip } from '../../features/conversations/components/ThreadTodoStrip'; import { ToolTimelineBlock } from '../../features/conversations/components/ToolTimelineBlock'; -import { InterruptedAnswer } from '../../features/conversations/components/InterruptedAnswer'; -import { PastTurnInsights } from '../../features/conversations/components/PastTurnInsights'; import { evaluateComposerSend, getComposerBlockedSendFeedback, diff --git a/app/src/features/conversations/components/InterruptedAnswer.tsx b/app/src/features/conversations/components/InterruptedAnswer.tsx index 5989bf81b..bbcbce56d 100644 --- a/app/src/features/conversations/components/InterruptedAnswer.tsx +++ b/app/src/features/conversations/components/InterruptedAnswer.tsx @@ -13,13 +13,7 @@ import { BubbleMarkdown } from './AgentMessageBubble'; * list — it is a restore-time surfacing of what the agent had produced, so the * user sees the partial work instead of a blank turn. */ -export function InterruptedAnswer({ - content, - thinking, -}: { - content: string; - thinking: string; -}) { +export function InterruptedAnswer({ content, thinking }: { content: string; thinking: string }) { const { t } = useT(); const trimmedContent = content.trim(); const trimmedThinking = thinking.trim(); diff --git a/app/src/hooks/useDaemonHealth.ts b/app/src/hooks/useDaemonHealth.ts index 1b015dcf5..eb2804a10 100644 --- a/app/src/hooks/useDaemonHealth.ts +++ b/app/src/hooks/useDaemonHealth.ts @@ -13,7 +13,6 @@ import { setIsRecovering, useDaemonUserState, } from '../features/daemon/store'; -import { daemonHealthService } from '../services/daemonHealthService'; import { type CommandResponse, openhumanAgentServerStatus, @@ -165,23 +164,9 @@ export const useDaemonHealth = (userId?: string) => { void probeAgentStatus(); }, [probeAgentStatus]); - useEffect(() => { - let cleanup: (() => void) | null = null; - let cancelled = false; - - void daemonHealthService.setupHealthListener().then(result => { - if (cancelled) { - result?.(); - } else { - cleanup = result; - } - }); - - return () => { - cancelled = true; - cleanup?.(); - }; - }, []); + // Health is no longer polled here — CoreStateProvider feeds each + // app_state_snapshot's health payload to daemonHealthService.ingestHealthSnapshot. + // This hook only reads the resulting daemon store state. return { // State diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 86ccd740d..58cefa507 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -552,7 +552,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': 'حدث خطأ ما. يرجى المحاولة مرة أخرى.', 'brain.tabs.sources': 'المصادر', 'brain.tabs.sync': 'المزامنة', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': 'التنسيق', 'tinyplaceOrchestration.title': 'مرحّل TinyPlace', 'tinyplaceOrchestration.subtitle': 'قنوات الوكلاء المثبتة ودردشات جلسات التطبيق', 'tinyplaceOrchestration.refresh': 'تحديث', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index d67bba8a7..330052021 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -570,7 +570,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': 'কিছু ভুল হয়েছে। অনুগ্রহ করে আবার চেষ্টা করুন।', 'brain.tabs.sources': 'উৎস', 'brain.tabs.sync': 'সিঙ্ক', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': 'অর্কেস্ট্রেশন', 'tinyplaceOrchestration.title': 'TinyPlace রিলে', 'tinyplaceOrchestration.subtitle': 'পিন করা এজেন্ট চ্যানেল এবং অ্যাপ সেশন চ্যাট', 'tinyplaceOrchestration.refresh': 'রিফ্রেশ', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 0cf85fef8..5b83faec0 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -598,7 +598,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': 'Etwas ist schiefgelaufen. Bitte versuche es erneut.', 'brain.tabs.sources': 'Quellen', 'brain.tabs.sync': 'Synchronisierung', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': 'Orchestrierung', 'tinyplaceOrchestration.title': 'TinyPlace-Relay', 'tinyplaceOrchestration.subtitle': 'Angepinnte Agentenkanäle und App-Sitzungs-Chats', 'tinyplaceOrchestration.refresh': 'Aktualisieren', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 54342cc03..3368ea52c 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -321,7 +321,7 @@ const en: TranslationMap = { 'brain.tabs.goals': 'Goals', 'brain.tabs.sources': 'Sources', 'brain.tabs.sync': 'Sync', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': 'Orchestration', 'brain.empty': 'Your brain is empty for now: connect a source to start building memory.', 'brain.error': "Couldn't load your brain. Please try again.", 'brain.goals.title': 'Long-term Goals', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index d9773b9e7..870fc00a0 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -581,7 +581,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': 'Algo salió mal. Inténtalo de nuevo.', 'brain.tabs.sources': 'Fuentes', 'brain.tabs.sync': 'Sincronización', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': 'Orquestación', 'tinyplaceOrchestration.title': 'Relay de TinyPlace', 'tinyplaceOrchestration.subtitle': 'Canales de agentes fijados y chats de sesiones de app', 'tinyplaceOrchestration.refresh': 'Actualizar', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 3ad118d95..2a4b49ee0 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -590,7 +590,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': 'Une erreur s’est produite. Veuillez réessayer.', 'brain.tabs.sources': 'Sources', 'brain.tabs.sync': 'Synchronisation', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': 'Orchestration', 'tinyplaceOrchestration.title': 'Relais TinyPlace', 'tinyplaceOrchestration.subtitle': "Canaux d'agents épinglés et chats de sessions app", 'tinyplaceOrchestration.refresh': 'Actualiser', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 12facfb7a..be21c6c87 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -570,7 +570,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': 'कुछ गलत हो गया। कृपया पुनः प्रयास करें।', 'brain.tabs.sources': 'स्रोत', 'brain.tabs.sync': 'सिंक', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': 'ऑर्केस्ट्रेशन', 'tinyplaceOrchestration.title': 'TinyPlace रिले', 'tinyplaceOrchestration.subtitle': 'पिन किए गए एजेंट चैनल और ऐप-सत्र चैट', 'tinyplaceOrchestration.refresh': 'रीफ़्रेश', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index a2a7ce85b..1a4a83723 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -576,7 +576,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': 'Terjadi kesalahan. Silakan coba lagi.', 'brain.tabs.sources': 'Sumber', 'brain.tabs.sync': 'Sinkronisasi', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': 'Orkestrasi', 'tinyplaceOrchestration.title': 'Relay TinyPlace', 'tinyplaceOrchestration.subtitle': 'Kanal agen tersemat dan chat sesi aplikasi', 'tinyplaceOrchestration.refresh': 'Segarkan', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index e92f9c2be..d0e6c1bdf 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -584,7 +584,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': 'Qualcosa è andato storto. Riprova.', 'brain.tabs.sources': 'Fonti', 'brain.tabs.sync': 'Sincronizzazione', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': 'Orchestrazione', 'tinyplaceOrchestration.title': 'Relay TinyPlace', 'tinyplaceOrchestration.subtitle': 'Canali agente fissati e chat delle sessioni app', 'tinyplaceOrchestration.refresh': 'Aggiorna', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 6485cc380..315bf4b4d 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -563,7 +563,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': '문제가 발생했습니다. 다시 시도해 주세요.', 'brain.tabs.sources': '소스', 'brain.tabs.sync': '동기화', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': '오케스트레이션', 'tinyplaceOrchestration.title': 'TinyPlace 릴레이', 'tinyplaceOrchestration.subtitle': '고정된 에이전트 채널과 앱 세션 채팅', 'tinyplaceOrchestration.refresh': '새로 고침', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index df2555176..51838b6e9 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -583,7 +583,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': 'Coś poszło nie tak. Spróbuj ponownie.', 'brain.tabs.sources': 'Źródła', 'brain.tabs.sync': 'Synchronizacja', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': 'Orkiestracja', 'tinyplaceOrchestration.title': 'Przekaźnik TinyPlace', 'tinyplaceOrchestration.subtitle': 'Przypięte kanały agentów i czaty sesji aplikacji', 'tinyplaceOrchestration.refresh': 'Odśwież', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 16707e454..4faab0c69 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -576,7 +576,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': 'Algo deu errado. Tente novamente.', 'brain.tabs.sources': 'Fontes', 'brain.tabs.sync': 'Sincronização', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': 'Orquestração', 'tinyplaceOrchestration.title': 'Relay TinyPlace', 'tinyplaceOrchestration.subtitle': 'Canais de agentes fixados e chats de sessões do app', 'tinyplaceOrchestration.refresh': 'Atualizar', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 762abc76e..5aa939b2c 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -576,7 +576,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': 'Что-то пошло не так. Пожалуйста, попробуйте снова.', 'brain.tabs.sources': 'Источники', 'brain.tabs.sync': 'Синхронизация', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': 'Оркестрация', 'tinyplaceOrchestration.title': 'Ретранслятор TinyPlace', 'tinyplaceOrchestration.subtitle': 'Закрепленные каналы агентов и чаты сессий приложения', 'tinyplaceOrchestration.refresh': 'Обновить', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 344bd1a70..e86dfac2a 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -536,7 +536,7 @@ const messages: TranslationMap = { 'brain.goals.actionError': '出了点问题。请重试。', 'brain.tabs.sources': '来源', 'brain.tabs.sync': '同步', - 'brain.tabs.tinyplaceOrchestration': 'TinyPlace', + 'brain.tabs.orchestration': '编排', 'tinyplaceOrchestration.title': 'TinyPlace 中继', 'tinyplaceOrchestration.subtitle': '固定的代理频道和应用会话聊天', 'tinyplaceOrchestration.refresh': '刷新', diff --git a/app/src/pages/Brain.tsx b/app/src/pages/Brain.tsx index 291493ad5..344f30f15 100644 --- a/app/src/pages/Brain.tsx +++ b/app/src/pages/Brain.tsx @@ -1,9 +1,9 @@ /** * Brain — the centerpiece memory + subconscious surface. * - * Two sub-tabs: - * - **Memory**: knowledge graph, tree status, and connected sources. - * - **Subconscious**: background thinking engine controls. + * Sub-tabs: Welcome, Graph, Goals, Sources, Sync, Subconscious, and + * **Orchestration** (the TinyPlace multi-agent surface, folded back in from the + * former top-level `/orchestration` tab — see {@link OrchestrationView}). */ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; @@ -23,6 +23,7 @@ import PageWelcome from '../components/layout/PageWelcome'; import PanelPage from '../components/layout/PanelPage'; import { SidebarContent } from '../components/layout/shell/SidebarSlot'; import TwoPaneNav from '../components/layout/TwoPaneNav'; +import OrchestrationView from '../components/orchestration/OrchestrationView'; import BetaBanner from '../components/ui/BetaBanner'; import { useSubconscious } from '../hooks/useSubconscious'; import { useT } from '../lib/i18n/I18nContext'; @@ -34,7 +35,14 @@ import { memoryTreeGraphExport, } from '../utils/tauriCommands'; -type BrainTab = 'welcome' | 'graph' | 'goals' | 'sources' | 'sync' | 'subconscious'; +type BrainTab = + | 'welcome' + | 'graph' + | 'goals' + | 'sources' + | 'sync' + | 'subconscious' + | 'orchestration'; /** Small inline icon helper for the Brain sidebar nav. */ const navIcon = (d: string) => ( @@ -50,10 +58,18 @@ const BRAIN_TABS: readonly BrainTab[] = [ 'sources', 'sync', 'subconscious', + 'orchestration', ]; -/** Canonical text header (title + one-line description) per functional tab. */ -const BRAIN_HEADERS: Record, { titleKey: string; descKey: string }> = { +/** + * Canonical text header (title + one-line description) per functional tab. + * Orchestration is excluded — it renders its own full-bleed surface + * ({@link OrchestrationView}) with its own chip nav, not the shared scaffold. + */ +const BRAIN_HEADERS: Record< + Exclude, + { titleKey: string; descKey: string } +> = { graph: { titleKey: 'brain.tabs.graph', descKey: 'brain.header.graph' }, goals: { titleKey: 'brain.tabs.goals', descKey: 'brain.header.goals' }, sources: { titleKey: 'brain.tabs.sources', descKey: 'brain.header.sources' }, @@ -79,12 +95,13 @@ export default function Brain() { }, [location.pathname, location.search, navigate] ); - // Back-compat: TinyPlace Orchestration was promoted out of Brain into the - // top-level `/orchestration` tab. Bounce the old deep link there. + // Back-compat: the old `?tab=tinyplace-orchestration` slug (from when + // Orchestration was briefly a top-level tab) now maps to the folded-in + // Orchestration sub-tab. useEffect(() => { if (new URLSearchParams(location.search).get('tab') === 'tinyplace-orchestration') { - console.debug('[brain] legacy tinyplace-orchestration deep link → /orchestration'); - navigate('/orchestration', { replace: true }); + console.debug('[brain] legacy tinyplace-orchestration deep link → ?tab=orchestration'); + navigate('/brain?tab=orchestration', { replace: true }); } }, [location.search, navigate]); @@ -201,144 +218,168 @@ export default function Brain() { 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z' ), }, + { + // TinyPlace multi-agent orchestration, folded back under + // Brain from the former top-level `/orchestration` tab. + value: 'orchestration', + label: t('brain.tabs.orchestration'), + icon: navIcon( + 'M12 7v3m0 0l-5.5 6M12 10l5.5 6M12 5a2 2 0 100 0M5 19a2 2 0 100 0M19 19a2 2 0 100 0' + ), + }, ], }, ]} />
-
- {activeTab === 'welcome' ? ( - setActiveTab('graph'), - testId: 'brain-welcome-cta-graph', - }, - { - label: t('brain.welcome.ctaGoals'), - icon: '🎯', - onClick: () => setActiveTab('goals'), - }, - { - label: t('brain.welcome.ctaSources'), - icon: '🔗', - onClick: () => setActiveTab('sources'), - }, - ]} - featuresHeading={t('brain.welcome.featsLabel')} - features={[ - { - icon: '🕸️', - title: t('brain.welcome.feat1Title'), - description: t('brain.welcome.feat1Body'), - }, - { - icon: '🎯', - title: t('brain.welcome.feat2Title'), - description: t('brain.welcome.feat2Body'), - }, - { - icon: '🔄', - title: t('brain.welcome.feat3Title'), - description: t('brain.welcome.feat3Body'), - }, - ]} - /> - ) : ( - /* All tabs share the standard scaffold: a single scrolling body, + {activeTab === 'orchestration' ? ( + // Full-bleed: OrchestrationView renders its own chip nav + surfaces + // (chat, graph, task board), which need the full content width — so it + // sits outside the shared max-w scaffold the other tabs use. +
+ +
+ ) : ( +
+ {activeTab === 'welcome' ? ( + setActiveTab('graph'), + testId: 'brain-welcome-cta-graph', + }, + { + label: t('brain.welcome.ctaGoals'), + icon: '🎯', + onClick: () => setActiveTab('goals'), + }, + { + label: t('brain.welcome.ctaSources'), + icon: '🔗', + onClick: () => setActiveTab('sources'), + }, + ]} + featuresHeading={t('brain.welcome.featsLabel')} + features={[ + { + icon: '🕸️', + title: t('brain.welcome.feat1Title'), + description: t('brain.welcome.feat1Body'), + }, + { + icon: '🎯', + title: t('brain.welcome.feat2Title'), + description: t('brain.welcome.feat2Body'), + }, + { + icon: '🔄', + title: t('brain.welcome.feat3Title'), + description: t('brain.welcome.feat3Body'), + }, + ]} + /> + ) : ( + /* All tabs share the standard scaffold: a single scrolling body, all custom controls live inside it. Each tab opens with the canonical header card (title + one-line description), aligned to the content. */ - -
- ].titleKey)} - description={t(BRAIN_HEADERS[activeTab as Exclude].descKey)} - /> - {activeTab === 'graph' && ( -
- - - {graph ? ( - +
+ ] + .titleKey + )} + description={t( + BRAIN_HEADERS[activeTab as Exclude] + .descKey + )} + /> + {activeTab === 'graph' && ( +
+ - ) : error ? ( -
- {t('brain.error')} + + {graph ? ( + + ) : error ? ( +
+ {t('brain.error')} +
+ ) : null} +
+ )} + + {activeTab === 'goals' && } + + {activeTab === 'sources' && ( +
+ + +
+ )} + + {activeTab === 'sync' && ( +
+
+
- ) : null} -
- )} - - {activeTab === 'goals' && } - - {activeTab === 'sources' && ( -
- - -
- )} - - {activeTab === 'sync' && ( -
-
- -
- {/* Sync history relocated from the Memory Inspection panel so + {/* Sync history relocated from the Memory Inspection panel so the Sync tab is the single sync surface. */} -
-

- {t('sync.auditTitle', 'Sync History')} -

- +
+

+ {t('sync.auditTitle', 'Sync History')} +

+ +
-
- )} + )} - {activeTab === 'subconscious' && ( -
- -
- + {activeTab === 'subconscious' && ( +
+ +
+ +
+
- -
- )} -
- - )} -
+ )} +
+ + )} +
+ )}
diff --git a/app/src/pages/OrchestrationPage.tsx b/app/src/pages/OrchestrationPage.tsx deleted file mode 100644 index 30650257f..000000000 --- a/app/src/pages/OrchestrationPage.tsx +++ /dev/null @@ -1,289 +0,0 @@ -/** - * OrchestrationPage — the TinyPlace multi-agent orchestration surface. - * - * Promoted out of Brain into a first-class sidebar destination (`/orchestration`), - * it splits into two sidebar destinations projected into the shell's dynamic - * sidebar region, driven by `?tab=`: - * - * - **agent** — chat with the main agent + its subconscious steering loop - * - **network** — one page with a chip sub-nav (`?sub=`) over the peer-network - * views: **connections**, **discover**, **usage** - * - * The sidebar lists the two destinations flat (no category headers), then a - * separator, then a live list of the agent's active peer sessions — mirroring - * how the chat window lists active threads. Clicking a session opens it in the - * agent chat (via the `session` query param, read by {@link AgentChatPanel}). - */ -import { useCallback, useMemo } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; - -import ChipTabs from '../components/layout/ChipTabs'; -import PageSectionHeader from '../components/layout/PageSectionHeader'; -import PanelPage from '../components/layout/PanelPage'; -import { SidebarContent } from '../components/layout/shell/SidebarSlot'; -import TwoPaneNav from '../components/layout/TwoPaneNav'; -import ActiveSubagentsRail from '../components/orchestration/ActiveSubagentsRail'; -import AgentChatPanel from '../components/orchestration/AgentChatPanel'; -import ConnectionsPanel from '../components/orchestration/ConnectionsPanel'; -import MedullaDemoChat from '../components/orchestration/demo/MedullaDemoChat'; -import MedullaDemoGraph from '../components/orchestration/demo/MedullaDemoGraph'; -import MedullaDemoNetwork from '../components/orchestration/demo/MedullaDemoNetwork'; -import DiscoverPanel from '../components/orchestration/DiscoverPanel'; -import MedullaOverviewPanel from '../components/orchestration/MedullaOverviewPanel'; -import OrchestratorTaskBoard from '../components/orchestration/OrchestratorTaskBoard'; -import OverviewPanel from '../components/orchestration/OverviewPanel'; -import UsagePanel from '../components/orchestration/UsagePanel'; -import { useT } from '../lib/i18n/I18nContext'; -import { useMedullaAccess } from '../lib/orchestration/useMedullaAccess'; -import { useContactSessions } from '../lib/orchestration/useOrchestrationSessions'; - -type OrchestrationTab = 'medulla' | 'overview' | 'agent' | 'tasks' | 'network'; -type NetworkSub = 'connections' | 'discover' | 'usage'; - -const NETWORK_SUBS: readonly NetworkSub[] = ['connections', 'discover', 'usage']; - -/** Small inline icon helper for the Orchestration sub-nav (matches Brain). */ -const navIcon = (d: string) => ( - - - -); - -export default function OrchestrationPage() { - const { t } = useT(); - const location = useLocation(); - const navigate = useNavigate(); - const contactSessions = useContactSessions(); - // Without Medulla access, Medulla-specific live surfaces are replaced by a - // scale showcase. The global task board remains available to every user. - const hasMedullaAccess = useMedullaAccess(); - - const params = useMemo(() => new URLSearchParams(location.search), [location.search]); - const rawTab = params.get('tab'); - const rawSub = params.get('sub'); - - // `?tab=connections|discover|usage` is a legacy deep link → the network page - // with that sub selected. New links use `?tab=network&sub=…`. - // Default landing is the Medulla overview (the orchestration overview page). - const activeTab: OrchestrationTab = - rawTab === 'overview' - ? 'overview' - : rawTab === 'agent' - ? 'agent' - : rawTab === 'tasks' - ? 'tasks' - : rawTab === 'network' || NETWORK_SUBS.includes(rawTab as NetworkSub) - ? 'network' - : 'medulla'; - - const networkSub: NetworkSub = NETWORK_SUBS.includes(rawTab as NetworkSub) - ? (rawTab as NetworkSub) - : NETWORK_SUBS.includes(rawSub as NetworkSub) - ? (rawSub as NetworkSub) - : 'connections'; - - const openSessionId = params.get('session'); - - const updateParams = useCallback( - (mut: (p: URLSearchParams) => void) => { - const next = new URLSearchParams(location.search); - mut(next); - navigate({ pathname: location.pathname, search: `?${next.toString()}` }); - }, - [location.pathname, location.search, navigate] - ); - - const setActiveTab = useCallback( - (tab: OrchestrationTab) => { - updateParams(p => { - p.set('tab', tab); - // Selecting Chat returns to the master chat — drop any open session so - // the session subpage closes (there's no in-view back button). - if (tab === 'agent') p.delete('session'); - // Landing on the network page needs a valid sub selected. - if (tab === 'network' && !NETWORK_SUBS.includes(p.get('sub') as NetworkSub)) { - p.set('sub', 'connections'); - } - }); - }, - [updateParams] - ); - - const setNetworkSub = useCallback( - (sub: NetworkSub) => { - updateParams(p => { - p.set('tab', 'network'); - p.set('sub', sub); - }); - }, - [updateParams] - ); - - // Open (or close, when null) a peer session in the agent chat. Always lands on - // the agent tab so the session's transcript is actually visible. - const setOpenSessionId = useCallback( - (sessionId: string | null) => { - updateParams(p => { - p.set('tab', 'agent'); - if (sessionId) p.set('session', sessionId); - else p.delete('session'); - }); - }, - [updateParams] - ); - - console.debug( - '[orchestration] page mount tab=%s sub=%s session=%s medullaAccess=%s', - activeTab, - networkSub, - openSessionId, - hasMedullaAccess - ); - - return ( -
- -
- setActiveTab(value as OrchestrationTab)} - groups={[ - { - // Flat list — no category headers: Overview · Chat · Agent - // graph · Tasks · Network. Overview (Medulla) is the landing. - items: [ - { - value: 'medulla', - label: t('orchPage.medulla.nav'), - icon: navIcon( - 'M12 3v2m0 14v2m9-9h-2M5 12H3m14.657-6.657l-1.414 1.414M7.757 16.243l-1.414 1.414m0-11.314l1.414 1.414m8.486 8.486l1.414 1.414M15 12a3 3 0 11-6 0 3 3 0 016 0z' - ), - }, - { - value: 'agent', - label: t('orchPage.agent.nav'), - icon: navIcon( - 'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z' - ), - }, - { - value: 'overview', - label: t('orchPage.overview.nav'), - icon: navIcon( - 'M4 5a2 2 0 012-2h12a2 2 0 012 2M9 12a2 2 0 11-4 0 2 2 0 014 0zm10 4a2 2 0 11-4 0 2 2 0 014 0zM7 12l7 4' - ), - }, - { - value: 'tasks', - label: t('orchPage.tasks.nav'), - icon: navIcon( - 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4' - ), - }, - { - value: 'network', - label: t('orchPage.group.network'), - icon: navIcon('M13 10V3L4 14h7v7l9-11h-7z M17 8a3 3 0 100-6 3 3 0 000 6z'), - }, - ], - }, - ]} - footer={ - // Active sub-agents, grouped by instance (contact) with a - // connection-status dot. Clicking a sub-agent opens its chat. - // Driven by live peer sessions, so it's hidden in showcase mode - // (no live sub-agents without Medulla access). - hasMedullaAccess ? ( - - ) : undefined - } - /> -
-
- - {activeTab === 'medulla' ? ( - // Orchestration overview — the Medulla teaser / early-access landing. - - ) : activeTab === 'overview' ? ( - // Interactive graph of the agent / sub-agent system — or the scale - // showcase (core → 2 devices → 120 agents) without Medulla access. - hasMedullaAccess ? ( - - ) : ( - - ) - ) : activeTab === 'agent' ? ( - // Full-bleed so it reads exactly like the normal chat page (dark - // background, floating composer, one vertical scroll) — or a read-only - // demo conversation (composer disabled) without Medulla access. - hasMedullaAccess ? ( -
- -
- ) : ( - - ) - ) : activeTab === 'tasks' ? ( - // One global Kanban board owned by the orchestrator (not per-thread). - // Tasks predate Medulla access and must remain usable without it. -
- -
- - -
-
-
- ) : hasMedullaAccess ? ( -
- {/* Network: one page with a Brain-style chip sub-nav (flush pills, no - header background) over connections/discover/usage, aligned to the - same content column. */} - -
- - as="tab" - ariaLabel={t('orchPage.group.network')} - testIdPrefix="orch-network" - className="inline-flex flex-wrap items-center gap-1.5" - items={[ - { id: 'connections', label: t('orchPage.connections.nav') }, - { id: 'discover', label: t('orchPage.discover.nav') }, - { id: 'usage', label: t('orchPage.usage.nav') }, - ]} - value={networkSub} - onChange={setNetworkSub} - /> - } - /> - {networkSub === 'connections' && ( - setNetworkSub('discover')} - onInitializeAgent={() => setActiveTab('agent')} - /> - )} - {networkSub === 'discover' && } - {networkSub === 'usage' && } -
-
-
- ) : ( - // Scale showcase: fake peer-agent mesh with the preview banner. - - )} -
- ); -} diff --git a/app/src/pages/__tests__/Brain.test.tsx b/app/src/pages/__tests__/Brain.test.tsx index 540eb66bc..2a7de73e2 100644 --- a/app/src/pages/__tests__/Brain.test.tsx +++ b/app/src/pages/__tests__/Brain.test.tsx @@ -9,7 +9,7 @@ const graphExportMock = vi.hoisted(() => vi.fn()); // (userId null → set) and assert the graph reloads (#4149). const coreAuthRef = vi.hoisted(() => ({ current: 'user-A' as string | null })); // Captures navigate() calls so we can assert the legacy TinyPlace-orchestration -// deep link bounces to the promoted top-level /orchestration tab. +// deep link bounces to the folded-in Orchestration sub-tab. const navigateSpy = vi.hoisted(() => vi.fn()); vi.mock('react-router-dom', async importOriginal => { @@ -62,6 +62,10 @@ vi.mock('../../components/layout/ChipTabs', async () => { }; }); vi.mock('../../components/ui/BetaBanner', () => ({ default: () => null })); +vi.mock('../../components/orchestration/OrchestrationView', async () => { + const React = await import('react'); + return { default: () => React.createElement('div', { 'data-testid': 'brain-orchestration' }) }; +}); vi.mock('../../components/intelligence/MemoryControls', () => ({ MemoryControls: () => null })); vi.mock('../../components/intelligence/MemoryTreeStatusPanel', async () => { @@ -181,13 +185,23 @@ describe('Brain page', () => { }); }); - it('redirects the legacy tinyplace-orchestration deep link to /orchestration', async () => { + it('renders the folded-in Orchestration view on the orchestration tab', async () => { + graphExportMock.mockResolvedValue(makeGraph(0)); + await act(async () => { + renderWithProviders(, { initialEntries: ['/?tab=orchestration'] }); + }); + await waitFor(() => { + expect(screen.getByTestId('brain-orchestration')).toBeInTheDocument(); + }); + }); + + it('redirects the legacy tinyplace-orchestration deep link to the orchestration tab', async () => { graphExportMock.mockResolvedValue(makeGraph(0)); await act(async () => { renderWithProviders(, { initialEntries: ['/?tab=tinyplace-orchestration'] }); }); await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith('/orchestration', { replace: true }); + expect(navigateSpy).toHaveBeenCalledWith('/brain?tab=orchestration', { replace: true }); }); }); }); diff --git a/app/src/pages/__tests__/OrchestrationPage.test.tsx b/app/src/pages/__tests__/OrchestrationPage.test.tsx deleted file mode 100644 index 8843ddf5c..000000000 --- a/app/src/pages/__tests__/OrchestrationPage.test.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { act, fireEvent, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { renderWithProviders } from '../../test/test-utils'; -import OrchestrationPage from '../OrchestrationPage'; - -vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); - -// Medulla access is toggled per-test; default to granted so the shell-routing -// tests exercise the live panels. -let medullaAccess = true; -vi.mock('../../lib/orchestration/useMedullaAccess', () => ({ - useMedullaAccess: () => medullaAccess, -})); - -// Stub the data-backed panels so the shell's tab routing is tested in -// isolation (the panels have their own unit tests). -vi.mock('../../components/orchestration/MedullaOverviewPanel', () => ({ - default: () =>
, -})); -vi.mock('../../components/orchestration/AgentChatPanel', () => ({ - default: () =>
, -})); -vi.mock('../../components/orchestration/ConnectionsPanel', () => ({ - default: ({ onDiscover }: { onDiscover?: () => void }) => ( - - ), -})); -vi.mock('../../components/orchestration/DiscoverPanel', () => ({ - default: () =>
, -})); -vi.mock('../../components/orchestration/UsagePanel', () => ({ - default: () =>
, -})); -vi.mock('../../components/orchestration/OrchestratorTaskBoard', () => ({ - default: () =>
, -})); - -describe('OrchestrationPage shell', () => { - beforeEach(() => { - medullaAccess = true; - }); - - it('defaults to the Medulla overview panel', async () => { - await act(async () => { - renderWithProviders(, { initialEntries: ['/orchestration'] }); - }); - expect(screen.getByTestId('panel-medulla')).toBeInTheDocument(); - }); - - it('renders the agent chat panel from ?tab=agent', async () => { - await act(async () => { - renderWithProviders(, { initialEntries: ['/orchestration?tab=agent'] }); - }); - expect(screen.getByTestId('panel-agent')).toBeInTheDocument(); - }); - - it.each([ - ['connections', 'panel-connections'], - ['discover', 'panel-discover'], - ['usage', 'panel-usage'], - ])('renders the %s panel from ?tab=%s', async (tab, testId) => { - await act(async () => { - renderWithProviders(, { initialEntries: [`/orchestration?tab=${tab}`] }); - }); - expect(screen.getByTestId(testId)).toBeInTheDocument(); - }); - - it('projects a sub-nav that switches tabs', async () => { - await act(async () => { - renderWithProviders(, { initialEntries: ['/orchestration'] }); - }); - // Sub-nav renders via the sidebar portal once the outlet mounts. `usage` is - // now a chip sub of the `network` tab, so the top-level nav exposes `network`. - const networkNav = await screen.findByTestId('two-pane-nav-network'); - await act(async () => { - fireEvent.click(networkNav); - }); - await waitFor(() => expect(screen.getByTestId('panel-connections')).toBeInTheDocument()); - }); - - it('lets the connections panel jump to discover via its callback', async () => { - await act(async () => { - renderWithProviders(, { - initialEntries: ['/orchestration?tab=connections'], - }); - }); - await act(async () => { - fireEvent.click(screen.getByTestId('panel-connections')); - }); - await waitFor(() => expect(screen.getByTestId('panel-discover')).toBeInTheDocument()); - }); -}); - -describe('OrchestrationPage scale showcase (no Medulla access)', () => { - beforeEach(() => { - medullaAccess = false; - }); - - it.each([ - ['agent', 'orch-demo-chat'], - ['overview', 'orch-demo-graph'], - ['network', 'orch-demo-network'], - ])('renders the demo surface for ?tab=%s', async (tab, testId) => { - await act(async () => { - renderWithProviders(, { initialEntries: [`/orchestration?tab=${tab}`] }); - }); - expect(screen.getByTestId(testId)).toBeInTheDocument(); - }); - - it('keeps the real task board available without Medulla access', async () => { - await act(async () => { - renderWithProviders(, { initialEntries: ['/orchestration?tab=tasks'] }); - }); - expect(screen.getByTestId('panel-tasks')).toBeInTheDocument(); - }); - - it('still lands on the Medulla overview by default', async () => { - await act(async () => { - renderWithProviders(, { initialEntries: ['/orchestration'] }); - }); - expect(screen.getByTestId('panel-medulla')).toBeInTheDocument(); - }); -}); diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx index 1b54499be..e6274ac59 100644 --- a/app/src/providers/CoreStateProvider.tsx +++ b/app/src/providers/CoreStateProvider.tsx @@ -24,6 +24,7 @@ import { listTeams, updateCoreLocalState, } from '../services/coreStateApi'; +import { daemonHealthService } from '../services/daemonHealthService'; import { socketService } from '../services/socketService'; import { store } from '../store'; import { resetUserScopedState } from '../store/resetActions'; @@ -48,6 +49,14 @@ const POLL_MS = 2000; const MAX_BOOTSTRAP_RETRIES = 5; const SUPPRESS_POLL_WARNING_AT = MAX_BOOTSTRAP_RETRIES + 1; const BACKOFF_POLL_MS = 10_000; +// Once the app has finished bootstrapping and is authenticated, the snapshot +// (auth, onboarding, service/local-AI state) changes rarely and mostly through +// event-driven refreshes (deep-link, settings toggles) that fire immediately +// regardless of this cadence. `app_state_snapshot` is expensive server-side +// (rebuilds the runtime snapshot, reloads config + local state), so steady-state +// polling backs off from POLL_MS to this slower cadence rather than hammering +// every 2s for the life of the session. +const STABLE_POLL_MS = 5000; /** Extract only non-sensitive fields from an RPC/fetch error. */ function sanitizeError(error: unknown): { message?: string; code?: string; status?: number } { @@ -283,7 +292,8 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) const refreshCore = useCallback(async () => { const requestId = ++snapshotRequestIdRef.current; - const snapshot = normalizeSnapshot(await fetchCoreAppSnapshot()); + const rawSnapshot = await fetchCoreAppSnapshot(); + const snapshot = normalizeSnapshot(rawSnapshot); if (!isMountedRef.current) { return; } @@ -341,6 +351,30 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) }; }); + // Feed the folded health payload to the daemon-health store (replaces the + // former standalone health_snapshot poll). Done AFTER the commit and only + // when this refresh is still current, so `daemonHealthService` resolves the + // freshly-committed identity — not a stale/pre-commit or superseded token, + // which during a login/identity flip would write health under the prior or + // `__pending__` user. + if (requestId === snapshotRequestIdRef.current) { + // Privacy-safe: log presence/shape only, never the payload/tokens. + log( + 'health ingest: requestId=%d current=%d hasHealth=%s components=%d', + requestId, + snapshotRequestIdRef.current, + rawSnapshot.health != null, + rawSnapshot.health ? Object.keys(rawSnapshot.health.components ?? {}).length : 0 + ); + daemonHealthService.ingestHealthSnapshot(rawSnapshot.health); + } else { + log( + 'health ingest skipped: superseded refresh requestId=%d current=%d', + requestId, + snapshotRequestIdRef.current + ); + } + // When the authenticated identity changes without a full restart-driven // flip (e.g. same-process session attach or web where `restartApp` is a // no-op), the thread slice can still hold rows from the pre-login @@ -521,11 +555,31 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) } }; + // Arm a baseline disconnect watchdog before the first snapshot lands, so a + // core whose snapshots never succeed still falls back to `disconnected` + // instead of sticking at a probe-set `running`. Each successful ingest + // re-arms it. + daemonHealthService.ensureWatchdogArmed(); + void load(); let timeoutId: number | null = null; + const computePollDelay = (): { delay: number; reason: string } => { + // Repeated bootstrap failures → slowest cadence to avoid log/CPU churn. + if (bootstrapFailCountRef.current >= MAX_BOOTSTRAP_RETRIES) { + return { delay: BACKOFF_POLL_MS, reason: 'failure-backoff' }; + } + // Booted and authenticated → steady state; back off the expensive snapshot + // poll. Still-bootstrapping or unauthenticated stays fast so login / boot + // transitions surface promptly. + const state = getCoreStateSnapshot(); + if (!state.isBootstrapping && state.snapshot.auth.isAuthenticated) { + return { delay: STABLE_POLL_MS, reason: 'authenticated' }; + } + return { delay: POLL_MS, reason: 'bootstrap' }; + }; const scheduleNext = () => { - const delay = - bootstrapFailCountRef.current >= MAX_BOOTSTRAP_RETRIES ? BACKOFF_POLL_MS : POLL_MS; + const { delay, reason } = computePollDelay(); + log('poll scheduled: delay=%dms reason=%s', delay, reason); timeoutId = window.setTimeout(async () => { await doRefresh(); if (!cancelled) { diff --git a/app/src/services/__tests__/daemonHealthService.test.ts b/app/src/services/__tests__/daemonHealthService.test.ts new file mode 100644 index 000000000..2f50a6991 --- /dev/null +++ b/app/src/services/__tests__/daemonHealthService.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { setDaemonStatus, updateHealthSnapshot } from '../../features/daemon/store'; +import { DaemonHealthService } from '../daemonHealthService'; + +vi.mock('../../features/daemon/store', () => ({ + setDaemonStatus: vi.fn(), + updateHealthSnapshot: vi.fn(), +})); + +vi.mock('../../lib/coreState/store', () => ({ + getCoreStateSnapshot: () => ({ snapshot: { sessionToken: null } }), +})); + +const mockedUpdate = vi.mocked(updateHealthSnapshot); +const mockedSetStatus = vi.mocked(setDaemonStatus); + +const healthPayload = (overrides: Record = {}) => ({ + pid: 123, + updated_at: '2026-07-21T00:00:00Z', + uptime_seconds: 10, + components: { gateway: { status: 'ok', updated_at: '2026-07-21T00:00:00Z', restart_count: 0 } }, + ...overrides, +}); + +describe('DaemonHealthService.ingestHealthSnapshot', () => { + beforeEach(() => { + vi.useFakeTimers(); + mockedUpdate.mockReset(); + mockedSetStatus.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('parses a valid payload and updates the daemon store', () => { + const service = new DaemonHealthService(); + service.ingestHealthSnapshot(healthPayload()); + + expect(mockedUpdate).toHaveBeenCalledTimes(1); + const [, snapshot] = mockedUpdate.mock.calls[0]; + expect(snapshot.pid).toBe(123); + expect(snapshot.components.gateway.status).toBe('ok'); + + service.cleanup(); + }); + + it('ignores a missing or unparseable payload but keeps the core connected (older core)', () => { + const service = new DaemonHealthService(); + service.ingestHealthSnapshot(undefined); + service.ingestHealthSnapshot(null); + service.ingestHealthSnapshot({ not: 'a health snapshot' }); + + // Store is not updated with health... + expect(mockedUpdate).not.toHaveBeenCalled(); + // ...but the arriving (health-less) snapshot is proof of liveness, so the + // watchdog is re-armed and the core is NOT marked disconnected while these + // snapshots keep succeeding. + vi.advanceTimersByTime(60000); + service.ingestHealthSnapshot(null); + vi.advanceTimersByTime(60000); + expect(mockedSetStatus).not.toHaveBeenCalled(); + service.cleanup(); + }); + + it('arms a baseline watchdog so a core whose snapshots never arrive disconnects', () => { + const service = new DaemonHealthService(); + // No ingest ever (snapshots keep timing out), but the baseline watchdog is + // armed at startup, so status still falls back to disconnected. + service.ensureWatchdogArmed(); + vi.advanceTimersByTime(120000); + expect(mockedSetStatus).toHaveBeenCalledWith(expect.any(String), 'disconnected'); + service.cleanup(); + }); + + it('ensureWatchdogArmed is idempotent and does not reset an in-flight watchdog', () => { + const service = new DaemonHealthService(); + service.ensureWatchdogArmed(); + vi.advanceTimersByTime(90000); + // A second call must NOT restart the timer (would delay disconnect detection). + service.ensureWatchdogArmed(); + vi.advanceTimersByTime(30000); + expect(mockedSetStatus).toHaveBeenCalledWith(expect.any(String), 'disconnected'); + service.cleanup(); + }); + + it('does not mark disconnected during a slow-but-alive snapshot window', () => { + const service = new DaemonHealthService(); + service.ingestHealthSnapshot(healthPayload()); + + // A first-launch app_state_snapshot can legitimately take 30–40s; the + // watchdog must NOT false-fire while one slow snapshot is in flight. + vi.advanceTimersByTime(60000); + expect(mockedSetStatus).not.toHaveBeenCalled(); + + service.cleanup(); + }); + + it('marks the daemon disconnected when no snapshot arrives within the timeout', () => { + const service = new DaemonHealthService(); + service.ingestHealthSnapshot(healthPayload()); + expect(mockedSetStatus).not.toHaveBeenCalled(); + + // No further ingest for the full watchdog window → disconnected. + vi.advanceTimersByTime(120000); + expect(mockedSetStatus).toHaveBeenCalledWith(expect.any(String), 'disconnected'); + + service.cleanup(); + }); + + it('re-arms the disconnect watchdog on each ingest', () => { + const service = new DaemonHealthService(); + service.ingestHealthSnapshot(healthPayload()); + + // A fresh snapshot before the deadline pushes it out. + vi.advanceTimersByTime(100000); + service.ingestHealthSnapshot(healthPayload()); + vi.advanceTimersByTime(100000); + expect(mockedSetStatus).not.toHaveBeenCalled(); + + // Then go quiet past the window → disconnected. + vi.advanceTimersByTime(120000); + expect(mockedSetStatus).toHaveBeenCalledWith(expect.any(String), 'disconnected'); + + service.cleanup(); + }); +}); diff --git a/app/src/services/coreStateApi.ts b/app/src/services/coreStateApi.ts index c124772cb..8be2a5c56 100644 --- a/app/src/services/coreStateApi.ts +++ b/app/src/services/coreStateApi.ts @@ -64,6 +64,33 @@ interface AppStateSnapshotResult { autocomplete: AutocompleteStatus; service: ServiceStatus; }; + /** + * Process + component health, folded into this snapshot (#daemon-poll-fold) + * so the daemon-health store hydrates from the same poll instead of a second + * `health_snapshot` poller. Fields are snake_case on the wire (the core type + * has no camelCase rename). Optional so older cores that omit it degrade + * gracefully — the daemon store simply isn't refreshed from those. + */ + health?: RawHealthSnapshot; +} + +/** Raw (snake_case) health payload embedded in the app-state snapshot. */ +export interface RawHealthSnapshot { + pid: number; + updated_at: string; + uptime_seconds: number; + components: Record< + string, + { + status: string; + updated_at: string; + // Rust serializes absent `Option` as `null` (no skip attribute), + // so match `src/openhuman/health/core.rs` — not `string | undefined`. + last_ok?: string | null; + last_error?: string | null; + restart_count: number; + } + >; } /** diff --git a/app/src/services/daemonHealthService.ts b/app/src/services/daemonHealthService.ts index e61fc235f..f1d0be0ff 100644 --- a/app/src/services/daemonHealthService.ts +++ b/app/src/services/daemonHealthService.ts @@ -1,7 +1,15 @@ /** * Daemon Health Service * - * Polls the Rust core health snapshot and keeps the frontend daemon store in sync. + * Keeps the frontend daemon store in sync with the Rust core's component health. + * + * Health is no longer polled on its own timer: the core folds its health + * snapshot into `app_state_snapshot`, and `CoreStateProvider` feeds each + * snapshot's `health` payload here via {@link ingestHealthSnapshot}. That + * collapses the former separate `health_snapshot` poll into the one app-state + * poll. This service now owns only the parse + store update + the + * disconnect-timeout watchdog (no data yet after {@link HEALTH_TIMEOUT_MS} → + * mark the daemon disconnected). */ import { type ComponentHealth, @@ -10,47 +18,52 @@ import { updateHealthSnapshot, } from '../features/daemon/store'; import { getCoreStateSnapshot } from '../lib/coreState/store'; -import { callCoreRpc } from './coreRpcClient'; export class DaemonHealthService { private healthTimeoutId: ReturnType | null = null; - private readonly HEALTH_TIMEOUT_MS = 30000; - private pollingIntervalId: ReturnType | null = null; - private readonly POLL_MS = 2000; + // Health now arrives folded into `app_state_snapshot`, which is allowed to run + // for up to `SNAPSHOT_TIMEOUT_MS` (90s) — first-launch snapshots legitimately + // take 30–40s. The disconnect watchdog must therefore tolerate one worst-case + // slow snapshot (plus the poll cadence) between successful ingests, or a merely + // slow-but-alive core would be marked `disconnected`. 120s covers the 90s cap + // with margin; genuine disconnection (snapshots stop succeeding entirely) is + // still detected, just less aggressively than the old dedicated 2s poll. + private readonly HEALTH_TIMEOUT_MS = 120000; - async setupHealthListener(): Promise<(() => void) | null> { - if (this.pollingIntervalId) { - return () => this.cleanup(); + /** + * Arm the disconnect watchdog once when daemon-health tracking starts, if it + * isn't already armed. Without this, a core whose `app_state_snapshot`s never + * succeed (repeated timeouts) — after `useDaemonHealth`'s one-shot agent probe + * has set the status to `running` — would never arm a watchdog and stick at + * `running` forever. The baseline watchdog guarantees a fallback to + * `disconnected` if no snapshot ever arrives, and is re-armed by each ingest. + */ + ensureWatchdogArmed(): void { + if (this.healthTimeoutId === null) { + this.startHealthTimeout(); } + } - const pollOnce = async () => { - try { - const payload = await callCoreRpc({ method: 'openhuman.health_snapshot' }); - const healthSnapshot = this.parseHealthSnapshot(payload); - if (healthSnapshot) { - this.updateDaemonStoreFromHealth(healthSnapshot); - this.startHealthTimeout(); - } - } catch { - // The health endpoint can fail while the sidecar is starting. - } - }; - - await pollOnce(); - this.pollingIntervalId = setInterval(() => { - void pollOnce(); - }, this.POLL_MS); + /** + * Ingest a health payload carried by an `app_state_snapshot` refresh. + * + * The snapshot arriving at all is proof the core is alive, so the disconnect + * watchdog is re-armed unconditionally — even when the payload is missing or + * unparseable (an older core that doesn't fold health, or a partial payload) — + * otherwise a live-but-health-less core would eventually be marked + * `disconnected`. The daemon store is only updated when a valid health + * snapshot is present; otherwise it keeps its last-known state. + */ + ingestHealthSnapshot(payload: unknown): void { + // Called by CoreStateProvider only after a successful snapshot → liveness. this.startHealthTimeout(); - - return () => this.cleanup(); + const healthSnapshot = this.parseHealthSnapshot(payload); + if (healthSnapshot) { + this.updateDaemonStoreFromHealth(healthSnapshot); + } } cleanup(): void { - if (this.pollingIntervalId) { - clearInterval(this.pollingIntervalId); - this.pollingIntervalId = null; - } - if (this.healthTimeoutId) { clearTimeout(this.healthTimeoutId); this.healthTimeoutId = null; diff --git a/app/src/store/__tests__/chatRuntimeSlice.thunk.test.ts b/app/src/store/__tests__/chatRuntimeSlice.thunk.test.ts index fb1cbc449..3d36b6eb6 100644 --- a/app/src/store/__tests__/chatRuntimeSlice.thunk.test.ts +++ b/app/src/store/__tests__/chatRuntimeSlice.thunk.test.ts @@ -89,7 +89,7 @@ describe('fetchAndHydrateTurnHistory', () => { expect(timelines['req-0']).toBeUndefined(); }); - it('keeps each past turn\'s reasoning/narration transcript (fix 1), ordered by seq (fix 5)', async () => { + it("keeps each past turn's reasoning/narration transcript (fix 1), ordered by seq (fix 5)", async () => { const store = configureStore({ reducer }); mockThreadApi.getTurnStateHistory.mockResolvedValueOnce([ // req-latest is skipped (index 0). diff --git a/app/src/store/chatRuntimeSlice.test.ts b/app/src/store/chatRuntimeSlice.test.ts index 396ab8d67..99a2224d3 100644 --- a/app/src/store/chatRuntimeSlice.test.ts +++ b/app/src/store/chatRuntimeSlice.test.ts @@ -932,7 +932,9 @@ describe('hydrateRuntimeFromSnapshot — interrupted partial answer (fix 2)', () it('surfaces the persisted partial reply + thinking as a settled buffer', () => { const store = makeStore(); - store.dispatch(hydrateRuntimeFromSnapshot({ snapshot: makeInterruptedPartialSnapshot('t-int') })); + store.dispatch( + hydrateRuntimeFromSnapshot({ snapshot: makeInterruptedPartialSnapshot('t-int') }) + ); const state = store.getState().chatRuntime; expect(state.interruptedAssistantByThread['t-int']).toEqual({ diff --git a/app/test/e2e/specs/navigation.spec.ts b/app/test/e2e/specs/navigation.spec.ts index 9ea0ac4f6..4d9a2ddc3 100644 --- a/app/test/e2e/specs/navigation.spec.ts +++ b/app/test/e2e/specs/navigation.spec.ts @@ -45,7 +45,11 @@ const ROUTES: Route[] = [ { hash: '/settings' }, { hash: '/agent-world' }, { hash: '/flows' }, - { hash: '/orchestration' }, + // Orchestration folded under Brain; `/orchestration` now redirects to + // `/brain?tab=orchestration`, so we assert the Brain destination instead + // (the bare `/orchestration` hash would settle on the redirect target and + // fail the `^#/orchestration` match, same reasoning as /home above). + { hash: '/brain' }, ]; async function rootTextLength(): Promise { diff --git a/src/openhuman/app_state/ops.rs b/src/openhuman/app_state/ops.rs index b8f12bc6c..23e155451 100644 --- a/src/openhuman/app_state/ops.rs +++ b/src/openhuman/app_state/ops.rs @@ -168,6 +168,11 @@ pub struct AppStateSnapshot { pub local_state: StoredAppState, pub keyring_status: crate::openhuman::keyring_consent::KeyringStatus, pub runtime: RuntimeSnapshot, + /// Process + component health, folded into this snapshot so the frontend + /// hydrates the daemon-health store from the same poll instead of running a + /// second `health_snapshot` poller. Fields stay snake_case (the type has no + /// camelCase rename) to match the frontend's existing health parser. + pub health: crate::openhuman::health::HealthSnapshot, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1220,6 +1225,7 @@ pub async fn snapshot() -> Result, String> { ); let keyring_status = crate::openhuman::keyring_consent::policy::current_status(); + let health = crate::openhuman::health::snapshot(); Ok(RpcOutcome::new( AppStateSnapshot { @@ -1233,6 +1239,7 @@ pub async fn snapshot() -> Result, String> { local_state, keyring_status, runtime, + health, }, vec!["core app state snapshot fetched".to_string()], )) diff --git a/src/openhuman/config/schema/load/env_overlay.rs b/src/openhuman/config/schema/load/env_overlay.rs index e45471000..52b5e3450 100644 --- a/src/openhuman/config/schema/load/env_overlay.rs +++ b/src/openhuman/config/schema/load/env_overlay.rs @@ -7,6 +7,31 @@ use super::dirs::MEMORY_SYNC_INTERVAL_SECS_ENV_VAR; use super::env::parse_env_bool; use std::path::PathBuf; +/// Classification of an `OPENHUMAN_SHELL_HIDE_WINDOW` env value. Split out from +/// the apply site (where the three cases differ only by log level) so the +/// empty-vs-unrecognized distinction is unit-testable without capturing tracing +/// output — a bare `VAR=` must classify as `Unset` (silent no-op), not +/// `Unrecognized` (which warns). +#[derive(Debug, PartialEq, Eq)] +pub(super) enum ShellHideWindowParse { + /// Empty / whitespace-only — the var is present but has no value; treat as + /// absent (no change, no warning). + Unset, + /// A recognized boolean value. + Set(bool), + /// A non-empty value that isn't a recognized boolean — warn and ignore. + Unrecognized, +} + +pub(super) fn classify_shell_hide_window(raw: &str) -> ShellHideWindowParse { + match raw.trim().to_ascii_lowercase().as_str() { + "" => ShellHideWindowParse::Unset, + "1" | "true" | "yes" | "on" => ShellHideWindowParse::Set(true), + "0" | "false" | "no" | "off" => ShellHideWindowParse::Set(false), + _ => ShellHideWindowParse::Unrecognized, + } +} + impl Config { pub fn apply_env_overrides(&mut self) { use super::env::ProcessEnv; @@ -119,23 +144,25 @@ impl Config { } if let Some(flag) = env.get_any(&["OPENHUMAN_SHELL_HIDE_WINDOW", "SHELL_HIDE_WINDOW"]) { - let normalized = flag.trim().to_ascii_lowercase(); - match normalized.as_str() { - "1" | "true" | "yes" | "on" => { - self.shell.hide_window = true; + match classify_shell_hide_window(&flag) { + // An empty / whitespace-only value means the var is present but + // unset (common when a `.env` or launcher exports `VAR=`). Treat + // it as absent — keep the current value rather than warning on + // every boot. Trace-level so the no-op stays diagnosable without + // the INFO/WARN noise this change exists to remove. + ShellHideWindowParse::Unset => tracing::trace!( + "[config][shell] OPENHUMAN_SHELL_HIDE_WINDOW empty value treated as unset; \ + keeping hide_window={}", + self.shell.hide_window + ), + ShellHideWindowParse::Set(value) => { + self.shell.hide_window = value; tracing::debug!( value = %flag, - "[config][shell] OPENHUMAN_SHELL_HIDE_WINDOW applied: hide_window=true" + "[config][shell] OPENHUMAN_SHELL_HIDE_WINDOW applied: hide_window={value}" ); } - "0" | "false" | "no" | "off" => { - self.shell.hide_window = false; - tracing::debug!( - value = %flag, - "[config][shell] OPENHUMAN_SHELL_HIDE_WINDOW applied: hide_window=false" - ); - } - _ => tracing::warn!( + ShellHideWindowParse::Unrecognized => tracing::warn!( value = %flag, "[config][shell] OPENHUMAN_SHELL_HIDE_WINDOW unrecognized value ignored; \ keeping current hide_window={}", diff --git a/src/openhuman/config/schema/load_tests.rs b/src/openhuman/config/schema/load_tests.rs index 9e87902ef..78322fd5c 100644 --- a/src/openhuman/config/schema/load_tests.rs +++ b/src/openhuman/config/schema/load_tests.rs @@ -235,11 +235,53 @@ fn apply_env_overrides_shell_hide_window_parses_truthy_falsy() { } cfg.apply_env_overrides(); assert!(cfg.shell.hide_window); + + // An empty / whitespace-only value is treated as unset: the field is left + // unchanged and it must NOT hit the "unrecognized value" warn path (a bare + // `OPENHUMAN_SHELL_HIDE_WINDOW=` in the environment previously warned on + // every boot). + cfg.shell.hide_window = true; + unsafe { + std::env::set_var("OPENHUMAN_SHELL_HIDE_WINDOW", ""); + } + cfg.apply_env_overrides(); + assert!( + cfg.shell.hide_window, + "empty value should leave hide_window=true" + ); + + cfg.shell.hide_window = false; + unsafe { + std::env::set_var("OPENHUMAN_SHELL_HIDE_WINDOW", " "); + } + cfg.apply_env_overrides(); + assert!( + !cfg.shell.hide_window, + "whitespace-only value should leave hide_window=false" + ); + unsafe { std::env::remove_var("OPENHUMAN_SHELL_HIDE_WINDOW"); } } +#[test] +fn classify_shell_hide_window_distinguishes_unset_from_unrecognized() { + use super::env_overlay::{classify_shell_hide_window, ShellHideWindowParse as P}; + // The key distinction the change relies on: an empty / whitespace-only value + // is `Unset` (silent no-op), NOT `Unrecognized` (which warns on every boot). + // Testing the classifier directly proves this — the field-unchanged assertion + // above holds for BOTH branches and so can't catch a regression here. + assert_eq!(classify_shell_hide_window(""), P::Unset); + assert_eq!(classify_shell_hide_window(" "), P::Unset); + assert_eq!(classify_shell_hide_window("\t"), P::Unset); + assert_eq!(classify_shell_hide_window("on"), P::Set(true)); + assert_eq!(classify_shell_hide_window("FALSE"), P::Set(false)); + assert_eq!(classify_shell_hide_window(" yes "), P::Set(true)); + assert_eq!(classify_shell_hide_window("maybe"), P::Unrecognized); + assert_eq!(classify_shell_hide_window("2"), P::Unrecognized); +} + #[test] fn apply_env_overrides_web_search_limits_only() { let _g = env_lock(); diff --git a/src/openhuman/health/core.rs b/src/openhuman/health/core.rs index d6a920603..45c52b12a 100644 --- a/src/openhuman/health/core.rs +++ b/src/openhuman/health/core.rs @@ -1,11 +1,11 @@ use chrono::Utc; use parking_lot::Mutex; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::sync::OnceLock; use std::time::Instant; -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ComponentHealth { pub status: String, pub updated_at: String, @@ -14,7 +14,7 @@ pub struct ComponentHealth { pub restart_count: u64, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct HealthSnapshot { pub pid: u32, pub updated_at: String, diff --git a/src/openhuman/keyring_consent/policy.rs b/src/openhuman/keyring_consent/policy.rs index ca4e46ed2..2a83bfefb 100644 --- a/src/openhuman/keyring_consent/policy.rs +++ b/src/openhuman/keyring_consent/policy.rs @@ -24,14 +24,33 @@ static CONSENT_EVENT_PUBLISHED: AtomicBool = AtomicBool::new(false); /// they never touch disk on the hot path. static CONSENT_CACHE: RwLock> = RwLock::new(None); -/// Pre-populate the consent cache from persisted app state. Call once at core -/// startup after config is loadable. -pub fn initialize(consent: Option) { +/// Populate the consent cache from persisted app state. +/// +/// Called from the per-request `app_state_snapshot` path, so it runs many times +/// over a session — not once at startup as the name suggests. It is therefore +/// change-gated: it writes and logs only when the persisted consent actually +/// differs from what is already cached. A repeat call with the same value (the +/// common case on every snapshot) is a silent no-op, keeping boot logs clean. +/// +/// Returns `true` when the cache was updated (the INFO log fired) and `false` +/// on the no-op path — this lets callers/tests observe the suppressed side +/// effect directly rather than only the (identical) resulting cache value. +pub fn initialize(consent: Option) -> bool { + // Hold the write lock across the compare + set so concurrent snapshots + // can't both observe a change and double-log / double-write. + let mut cache = CONSENT_CACHE.write(); + if *cache == consent { + // No-op path (every app_state_snapshot with unchanged consent). Trace so + // it stays diagnosable without the INFO noise this change removes. + log::trace!("{LOG_PREFIX} initialize no-op: cached consent unchanged"); + return false; + } info!( "{LOG_PREFIX} initialize cached_consent={}", consent.as_ref().map_or("none", |p| p.storage_mode.as_str()), ); - *CONSENT_CACHE.write() = consent; + *cache = consent; + true } /// Check whether the caller is allowed to proceed with secret storage. @@ -242,6 +261,7 @@ mod tests { #[test] fn initialize_populates_cache() { let _lock = cache_test_lock(); + *CONSENT_CACHE.write() = None; let pref = ConsentPreference { storage_mode: "declined".to_string(), consented_at_ms: Some(12345), @@ -250,4 +270,40 @@ mod tests { let cached = CONSENT_CACHE.read().clone(); assert_eq!(cached.unwrap().storage_mode, "declined"); } + + #[test] + fn initialize_is_change_gated() { + let _lock = cache_test_lock(); + *CONSENT_CACHE.write() = None; + + // First real value populates the cache and reports it applied (the INFO + // log + write happened). + let pref = ConsentPreference { + storage_mode: "local_encrypted".to_string(), + consented_at_ms: Some(111), + }; + assert!(initialize(Some(pref.clone())), "first value should apply"); + assert_eq!(CONSENT_CACHE.read().clone(), Some(pref.clone())); + + // Repeat with the identical value — the no-op path: returns false (no + // write, no INFO log), which is what every app_state_snapshot hits. + // Asserting the return value proves the side effect is suppressed, not + // merely that the resulting cache value is unchanged. + assert!( + !initialize(Some(pref.clone())), + "identical value must be a no-op (no re-log / re-write)" + ); + assert_eq!(CONSENT_CACHE.read().clone(), Some(pref)); + + // A genuine change is still applied (returns true). + let changed = ConsentPreference { + storage_mode: "declined".to_string(), + consented_at_ms: Some(222), + }; + assert!( + initialize(Some(changed.clone())), + "a genuine change should apply" + ); + assert_eq!(CONSENT_CACHE.read().clone(), Some(changed)); + } } diff --git a/src/openhuman/keyring_consent/types.rs b/src/openhuman/keyring_consent/types.rs index a0cde4671..2d896eb72 100644 --- a/src/openhuman/keyring_consent/types.rs +++ b/src/openhuman/keyring_consent/types.rs @@ -54,7 +54,7 @@ pub struct KeyringStatus { pub backend_name: String, } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct ConsentPreference { #[serde(default)] diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index eba600df1..bba393e0e 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -6075,6 +6075,30 @@ async fn json_rpc_app_state_snapshot_returns_runtime_shape() { "expected runtime.service object: {runtime}" ); + // Component health is folded into this snapshot so the frontend hydrates the + // daemon-health store from the same poll (no separate health_snapshot poll). + // Its fields stay snake_case (the core HealthSnapshot type has no camelCase + // rename), matching the frontend health parser. + let health = body.get("health").expect("expected health object"); + assert!( + health.get("pid").and_then(Value::as_u64).is_some(), + "expected health.pid: {health}" + ); + assert!( + health + .get("uptime_seconds") + .and_then(Value::as_u64) + .is_some(), + "expected health.uptime_seconds (snake_case): {health}" + ); + assert!( + health + .get("components") + .and_then(Value::as_object) + .is_some(), + "expected health.components object: {health}" + ); + mock_join.abort(); rpc_join.abort(); } diff --git a/worktrees/.gitkeep b/worktrees/.gitkeep new file mode 100644 index 000000000..e69de29bb