perf(core): consolidate boot polls, silence per-snapshot log spam (#5075)

This commit is contained in:
Steven Enamakel
2026-07-21 14:00:38 +03:00
committed by GitHub
parent 09f552ce40
commit 684ad0d2f5
53 changed files with 1294 additions and 732 deletions
+1 -1
View File
@@ -1 +1 @@
5ec3d8836cbae300e36b7a9c0d302a65de92c58d
11ef51edbeadcca4517b18a037fff858a5dfae0f
+4 -1
View File
@@ -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
*.diff
+22 -12
View File
@@ -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
+41 -15
View File
@@ -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=<that>`
* - `?tab=agent|overview|tasks|network|medulla` → `?ov=<that>`
* - `?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 <Navigate to={`/brain?${next.toString()}`} replace />;
}
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). */}
<Route
path="/orchestration"
element={
<ProtectedRoute requireAuth={true}>
<OrchestrationPage />
</ProtectedRoute>
}
/>
{/* 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; `<OrchestrationRedirect>` 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. */}
<Route path="/orchestration" element={<OrchestrationRedirect />} />
<Route
path="/brain/tinyplace-orchestration"
element={<Navigate to="/orchestration" replace />}
element={<Navigate to="/brain?tab=orchestration" replace />}
/>
{/* Back-compat: /activity and /intelligence → settings notifications page. */}
@@ -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<HarnessInitSnapshot>(resolve => {
resolveFetch = resolve;
})
);
// Two concurrent overlays stand in for the effect→cleanup→effect double-mount.
renderWithProviders(<HarnessInitOverlay />);
renderWithProviders(<HarnessInitOverlay />);
// 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' }));
@@ -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<HarnessInitSnapshot | null> | null = null;
function fetchHarnessInitStatusCoalesced(): Promise<HarnessInitSnapshot | null> {
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;
}
+79 -13
View File
@@ -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<LocalAiDownloadsProgress | null>(null);
const [dismissed, setDismissed] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval>>(undefined);
const timerRef = useRef<ReturnType<typeof setTimeout>>(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 25s 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.
@@ -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(<LocalAIDownloadSnackbar />);
@@ -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(<LocalAIDownloadSnackbar />);
// 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(<LocalAIDownloadSnackbar />);
@@ -29,7 +29,6 @@ describe('CollapsedNavRail', () => {
'nav.human',
'nav.brain',
'nav.flows',
'nav.orchestration',
'nav.agentWorld',
'nav.connections',
]) {
@@ -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<string | null>(null);
const openSessionId =
controlledOpenSessionId !== undefined ? controlledOpenSessionId : localOpenSessionId;
@@ -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';
@@ -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 (
<div className="flex h-full flex-col">
{/* Top-level view switcher — the old sidebar rail, folded into an in-content
chip row so Brain keeps a single sidebar. */}
<div className="shrink-0 border-b border-line-subtle">
<ChipTabs<OrchestrationTab>
as="tab"
ariaLabel={t('nav.orchestration')}
testIdPrefix="orch-view"
items={chipItems}
value={activeTab}
onChange={setActiveTab}
/>
</div>
<div className="min-h-0 flex-1">
{activeTab === 'medulla' ? (
// Orchestration overview — the Medulla teaser / early-access landing.
<MedullaOverviewPanel />
) : activeTab === 'overview' ? (
// Interactive graph of the agent / sub-agent system — or the scale
// showcase (core → 2 devices → 120 agents) without Medulla access.
hasMedullaAccess ? (
<OverviewPanel />
) : (
<MedullaDemoGraph />
)
) : 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 ? (
<div className="flex h-full">
{contactSessions.byContact.size > 0 ? (
<aside className="hidden w-60 shrink-0 overflow-y-auto border-r border-line-subtle px-1.5 py-2 lg:block">
<ActiveSubagentsRail
byContact={contactSessions.byContact}
openSessionId={openSessionId}
isAgentTab={true}
onOpenSession={setOpenSessionId}
/>
</aside>
) : null}
<div className="min-w-0 flex-1">
<AgentChatPanel openSessionId={openSessionId} onOpenSession={setOpenSessionId} />
</div>
</div>
) : (
<MedullaDemoChat />
)
) : activeTab === 'tasks' ? (
// One global Kanban board owned by the orchestrator (not per-thread).
// Tasks predate Medulla access and must remain usable without it.
<div className="mx-auto h-full w-full max-w-3xl">
<PanelPage contentClassName="p-4">
<div className="animate-fade-up space-y-4">
<PageSectionHeader
title={t('orchPage.tasks.nav')}
description={t('orchPage.tasks.subtitle')}
/>
<OrchestratorTaskBoard />
</div>
</PanelPage>
</div>
) : hasMedullaAccess ? (
<div className="mx-auto h-full w-full max-w-3xl">
{/* 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. */}
<PanelPage contentClassName="p-4">
<div className="mx-auto max-w-3xl space-y-5 animate-fade-up">
<PageSectionHeader
title={t('orchPage.group.network')}
description={t('orchPage.network.desc')}
tabs={
<ChipTabs<NetworkSub>
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' && (
<ConnectionsPanel
onDiscover={() => setNetworkSub('discover')}
onInitializeAgent={() => setActiveTab('agent')}
/>
)}
{networkSub === 'discover' && <DiscoverPanel />}
{networkSub === 'usage' && <UsagePanel />}
</div>
</PanelPage>
</div>
) : (
// Scale showcase: fake peer-agent mesh with the preview banner.
<MedullaDemoNetwork />
)}
</div>
</div>
);
}
@@ -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: () => <div data-testid="panel-medulla" /> }));
vi.mock('../AgentChatPanel', () => ({ default: () => <div data-testid="panel-agent" /> }));
vi.mock('../ConnectionsPanel', () => ({
default: ({ onDiscover }: { onDiscover?: () => void }) => (
<button data-testid="panel-connections" onClick={onDiscover}>
connections
</button>
),
}));
vi.mock('../DiscoverPanel', () => ({ default: () => <div data-testid="panel-discover" /> }));
vi.mock('../UsagePanel', () => ({ default: () => <div data-testid="panel-usage" /> }));
vi.mock('../OverviewPanel', () => ({ default: () => <div data-testid="panel-graph" /> }));
vi.mock('../OrchestratorTaskBoard', () => ({ default: () => <div data-testid="panel-tasks" /> }));
vi.mock('../demo/MedullaDemoChat', () => ({ default: () => <div data-testid="orch-demo-chat" /> }));
vi.mock('../demo/MedullaDemoGraph', () => ({
default: () => <div data-testid="orch-demo-graph" />,
}));
vi.mock('../demo/MedullaDemoNetwork', () => ({
default: () => <div data-testid="orch-demo-network" />,
}));
const BASE = '/brain?tab=orchestration';
describe('OrchestrationView (Medulla access)', () => {
beforeEach(() => {
medullaAccess = true;
});
it('defaults to the Medulla overview panel', async () => {
await act(async () => {
renderWithProviders(<OrchestrationView />, { initialEntries: [BASE] });
});
expect(screen.getByTestId('panel-medulla')).toBeInTheDocument();
});
it('renders the agent chat panel from ?ov=agent', async () => {
await act(async () => {
renderWithProviders(<OrchestrationView />, { initialEntries: [`${BASE}&ov=agent`] });
});
expect(screen.getByTestId('panel-agent')).toBeInTheDocument();
});
it('renders the agent graph panel from ?ov=overview', async () => {
await act(async () => {
renderWithProviders(<OrchestrationView />, { initialEntries: [`${BASE}&ov=overview`] });
});
expect(screen.getByTestId('panel-graph')).toBeInTheDocument();
});
it('renders the task board from ?ov=tasks', async () => {
await act(async () => {
renderWithProviders(<OrchestrationView />, { 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(<OrchestrationView />, {
initialEntries: [`${BASE}&ov=network&sub=${sub}`],
});
});
expect(screen.getByTestId(testId)).toBeInTheDocument();
});
it('switches views via the chip nav', async () => {
await act(async () => {
renderWithProviders(<OrchestrationView />, { 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(<OrchestrationView />, {
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(<OrchestrationView />, { initialEntries: [`${BASE}&ov=${ov}`] });
});
expect(screen.getByTestId(testId)).toBeInTheDocument();
});
it('keeps the real task board available without Medulla access', async () => {
await act(async () => {
renderWithProviders(<OrchestrationView />, { initialEntries: [`${BASE}&ov=tasks`] });
});
expect(screen.getByTestId('panel-tasks')).toBeInTheDocument();
});
it('still lands on the Medulla overview by default', async () => {
await act(async () => {
renderWithProviders(<OrchestrationView />, { initialEntries: [BASE] });
});
expect(screen.getByTestId('panel-medulla')).toBeInTheDocument();
});
});
@@ -179,8 +179,8 @@ export function settingsRouteElements(): ReactNode {
element={<Navigate to="/connections?tab=llm#agent-chat" replace />}
/>
<Route path="cron-jobs" element={wrapSettingsPage(<CronJobsPanel />)} />
{/* Tasks now live on the Orchestration page's Kanban board. */}
<Route path="tasks" element={<Navigate to="/orchestration?tab=tasks" replace />} />
{/* Tasks now live on Brain's Orchestration Kanban board. */}
<Route path="tasks" element={<Navigate to="/brain?tab=orchestration&ov=tasks" replace />} />
{/* Workflows is a first-level module now — /settings/automations bounces
to /flows (the Workflows page). */}
<Route path="automations" element={<Navigate to="/flows" replace />} />
+6 -6
View File
@@ -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();
+4 -10
View File
@@ -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',
@@ -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,
@@ -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();
+3 -18
View File
@@ -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
+1 -1
View File
@@ -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': 'تحديث',
+1 -1
View File
@@ -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': 'রিফ্রেশ',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -590,7 +590,7 @@ const messages: TranslationMap = {
'brain.goals.actionError': 'Une erreur sest 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',
+1 -1
View File
@@ -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': 'रीफ़्रेश',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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': '새로 고침',
+1 -1
View File
@@ -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ż',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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': 'Обновить',
+1 -1
View File
@@ -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': '刷新',
+174 -133
View File
@@ -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<Exclude<BrainTab, 'welcome'>, { 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<BrainTab, 'welcome' | 'orchestration'>,
{ 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'
),
},
],
},
]}
/>
</div>
</SidebarContent>
<div className="mx-auto h-full w-full max-w-5xl">
{activeTab === 'welcome' ? (
<PageWelcome
testId="brain-welcome"
accent="sage"
icon="🧠"
eyebrow={t('brain.welcome.eyebrow')}
title={t('brain.welcome.title')}
description={t('brain.welcome.body')}
ctas={[
{
label: t('brain.welcome.ctaGraph'),
icon: '🕸️',
onClick: () => 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.
<div className="h-full">
<OrchestrationView />
</div>
) : (
<div className="mx-auto h-full w-full max-w-5xl">
{activeTab === 'welcome' ? (
<PageWelcome
testId="brain-welcome"
accent="sage"
icon="🧠"
eyebrow={t('brain.welcome.eyebrow')}
title={t('brain.welcome.title')}
description={t('brain.welcome.body')}
ctas={[
{
label: t('brain.welcome.ctaGraph'),
icon: '🕸️',
onClick: () => 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. */
<PanelPage contentClassName="p-4">
<div className="mx-auto max-w-3xl space-y-5">
<PageSectionHeader
title={t(BRAIN_HEADERS[activeTab as Exclude<BrainTab, 'welcome'>].titleKey)}
description={t(BRAIN_HEADERS[activeTab as Exclude<BrainTab, 'welcome'>].descKey)}
/>
{activeTab === 'graph' && (
<div className="space-y-5 animate-fade-up">
<MemoryControls
mode={mode}
onModeChange={setMode}
onRefresh={refresh}
onToast={addToast}
contentRootAbs={graph?.content_root_abs}
/>
{graph ? (
<MemoryGraph
nodes={graph.nodes}
edges={graph.edges}
<PanelPage contentClassName="p-4">
<div className="mx-auto max-w-3xl space-y-5">
<PageSectionHeader
title={t(
BRAIN_HEADERS[activeTab as Exclude<BrainTab, 'welcome' | 'orchestration'>]
.titleKey
)}
description={t(
BRAIN_HEADERS[activeTab as Exclude<BrainTab, 'welcome' | 'orchestration'>]
.descKey
)}
/>
{activeTab === 'graph' && (
<div className="space-y-5 animate-fade-up">
<MemoryControls
mode={mode}
emptyHint={t('brain.empty')}
onModeChange={setMode}
onRefresh={refresh}
onToast={addToast}
contentRootAbs={graph?.content_root_abs}
/>
) : error ? (
<div
className={`${cardClass} text-sm text-coral-600 dark:text-coral-400`}
role="alert">
{t('brain.error')}
{graph ? (
<MemoryGraph
nodes={graph.nodes}
edges={graph.edges}
mode={mode}
emptyHint={t('brain.empty')}
/>
) : error ? (
<div
className={`${cardClass} text-sm text-coral-600 dark:text-coral-400`}
role="alert">
{t('brain.error')}
</div>
) : null}
</div>
)}
{activeTab === 'goals' && <GoalsPanel />}
{activeTab === 'sources' && (
<div className="space-y-5 animate-fade-up">
<CodingSessionsCard onToast={addToast} />
<MemorySourcesRegistry onToast={addToast} />
</div>
)}
{activeTab === 'sync' && (
<div className="space-y-5 animate-fade-up">
<div className={cardClass}>
<MemoryTreeStatusPanel onToast={addToast} />
</div>
) : null}
</div>
)}
{activeTab === 'goals' && <GoalsPanel />}
{activeTab === 'sources' && (
<div className="space-y-5 animate-fade-up">
<CodingSessionsCard onToast={addToast} />
<MemorySourcesRegistry onToast={addToast} />
</div>
)}
{activeTab === 'sync' && (
<div className="space-y-5 animate-fade-up">
<div className={cardClass}>
<MemoryTreeStatusPanel onToast={addToast} />
</div>
{/* 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. */}
<div className={cardClass} data-testid="brain-sync-history">
<h3 className="mb-2 text-sm font-medium text-content-secondary">
{t('sync.auditTitle', 'Sync History')}
</h3>
<SyncAuditPanel />
<div className={cardClass} data-testid="brain-sync-history">
<h3 className="mb-2 text-sm font-medium text-content-secondary">
{t('sync.auditTitle', 'Sync History')}
</h3>
<SyncAuditPanel />
</div>
</div>
</div>
)}
)}
{activeTab === 'subconscious' && (
<div className="space-y-3 animate-fade-up">
<BetaBanner />
<div className={cardClass}>
<IntelligenceSubconsciousTab
status={sub.status}
instances={sub.instances}
mode={sub.mode}
intervalMinutes={sub.intervalMinutes}
triggerTick={sub.triggerTick}
triggering={sub.triggering}
isTriggering={sub.isTriggering}
settingMode={sub.settingMode}
setMode={sub.setMode}
setIntervalMinutes={sub.setIntervalMinutes}
/>
{activeTab === 'subconscious' && (
<div className="space-y-3 animate-fade-up">
<BetaBanner />
<div className={cardClass}>
<IntelligenceSubconsciousTab
status={sub.status}
instances={sub.instances}
mode={sub.mode}
intervalMinutes={sub.intervalMinutes}
triggerTick={sub.triggerTick}
triggering={sub.triggering}
isTriggering={sub.isTriggering}
settingMode={sub.settingMode}
setMode={sub.setMode}
setIntervalMinutes={sub.setIntervalMinutes}
/>
</div>
<SubconsciousTriggersPanel />
</div>
<SubconsciousTriggersPanel />
</div>
)}
</div>
</PanelPage>
)}
</div>
)}
</div>
</PanelPage>
)}
</div>
)}
<ToastContainer notifications={toasts} onRemove={removeToast} />
</div>
-289
View File
@@ -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) => (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={d} />
</svg>
);
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 (
<div className="h-full">
<SidebarContent>
<div className="h-full overflow-hidden">
<TwoPaneNav
ariaLabel={t('nav.orchestration')}
selected={activeTab}
onSelect={value => 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 ? (
<ActiveSubagentsRail
byContact={contactSessions.byContact}
openSessionId={openSessionId}
isAgentTab={activeTab === 'agent'}
onOpenSession={setOpenSessionId}
/>
) : undefined
}
/>
</div>
</SidebarContent>
{activeTab === 'medulla' ? (
// Orchestration overview — the Medulla teaser / early-access landing.
<MedullaOverviewPanel />
) : activeTab === 'overview' ? (
// Interactive graph of the agent / sub-agent system — or the scale
// showcase (core → 2 devices → 120 agents) without Medulla access.
hasMedullaAccess ? (
<OverviewPanel />
) : (
<MedullaDemoGraph />
)
) : 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 ? (
<div className="h-full">
<AgentChatPanel openSessionId={openSessionId} onOpenSession={setOpenSessionId} />
</div>
) : (
<MedullaDemoChat />
)
) : activeTab === 'tasks' ? (
// One global Kanban board owned by the orchestrator (not per-thread).
// Tasks predate Medulla access and must remain usable without it.
<div className="mx-auto h-full w-full max-w-3xl">
<PanelPage contentClassName="p-4">
<div className="animate-fade-up space-y-4">
<PageSectionHeader
title={t('orchPage.tasks.nav')}
description={t('orchPage.tasks.subtitle')}
/>
<OrchestratorTaskBoard />
</div>
</PanelPage>
</div>
) : hasMedullaAccess ? (
<div className="mx-auto h-full w-full max-w-3xl">
{/* 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. */}
<PanelPage contentClassName="p-4">
<div className="mx-auto max-w-3xl space-y-5 animate-fade-up">
<PageSectionHeader
title={t('orchPage.group.network')}
description={t('orchPage.network.desc')}
tabs={
<ChipTabs<NetworkSub>
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' && (
<ConnectionsPanel
onDiscover={() => setNetworkSub('discover')}
onInitializeAgent={() => setActiveTab('agent')}
/>
)}
{networkSub === 'discover' && <DiscoverPanel />}
{networkSub === 'usage' && <UsagePanel />}
</div>
</PanelPage>
</div>
) : (
// Scale showcase: fake peer-agent mesh with the preview banner.
<MedullaDemoNetwork />
)}
</div>
);
}
+17 -3
View File
@@ -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(<Brain />, { 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(<Brain />, { initialEntries: ['/?tab=tinyplace-orchestration'] });
});
await waitFor(() => {
expect(navigateSpy).toHaveBeenCalledWith('/orchestration', { replace: true });
expect(navigateSpy).toHaveBeenCalledWith('/brain?tab=orchestration', { replace: true });
});
});
});
@@ -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: () => <div data-testid="panel-medulla" />,
}));
vi.mock('../../components/orchestration/AgentChatPanel', () => ({
default: () => <div data-testid="panel-agent" />,
}));
vi.mock('../../components/orchestration/ConnectionsPanel', () => ({
default: ({ onDiscover }: { onDiscover?: () => void }) => (
<button data-testid="panel-connections" onClick={onDiscover}>
connections
</button>
),
}));
vi.mock('../../components/orchestration/DiscoverPanel', () => ({
default: () => <div data-testid="panel-discover" />,
}));
vi.mock('../../components/orchestration/UsagePanel', () => ({
default: () => <div data-testid="panel-usage" />,
}));
vi.mock('../../components/orchestration/OrchestratorTaskBoard', () => ({
default: () => <div data-testid="panel-tasks" />,
}));
describe('OrchestrationPage shell', () => {
beforeEach(() => {
medullaAccess = true;
});
it('defaults to the Medulla overview panel', async () => {
await act(async () => {
renderWithProviders(<OrchestrationPage />, { initialEntries: ['/orchestration'] });
});
expect(screen.getByTestId('panel-medulla')).toBeInTheDocument();
});
it('renders the agent chat panel from ?tab=agent', async () => {
await act(async () => {
renderWithProviders(<OrchestrationPage />, { 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(<OrchestrationPage />, { initialEntries: [`/orchestration?tab=${tab}`] });
});
expect(screen.getByTestId(testId)).toBeInTheDocument();
});
it('projects a sub-nav that switches tabs', async () => {
await act(async () => {
renderWithProviders(<OrchestrationPage />, { 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(<OrchestrationPage />, {
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(<OrchestrationPage />, { initialEntries: [`/orchestration?tab=${tab}`] });
});
expect(screen.getByTestId(testId)).toBeInTheDocument();
});
it('keeps the real task board available without Medulla access', async () => {
await act(async () => {
renderWithProviders(<OrchestrationPage />, { initialEntries: ['/orchestration?tab=tasks'] });
});
expect(screen.getByTestId('panel-tasks')).toBeInTheDocument();
});
it('still lands on the Medulla overview by default', async () => {
await act(async () => {
renderWithProviders(<OrchestrationPage />, { initialEntries: ['/orchestration'] });
});
expect(screen.getByTestId('panel-medulla')).toBeInTheDocument();
});
});
+57 -3
View File
@@ -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) {
@@ -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<string, unknown> = {}) => ({
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 3040s; 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();
});
});
+27
View File
@@ -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<String>` 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;
}
>;
}
/**
+45 -32
View File
@@ -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<typeof setTimeout> | null = null;
private readonly HEALTH_TIMEOUT_MS = 30000;
private pollingIntervalId: ReturnType<typeof setInterval> | 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 3040s. 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<unknown>({ 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;
@@ -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).
+3 -1
View File
@@ -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({
+5 -1
View File
@@ -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<number> {
+7
View File
@@ -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<RpcOutcome<AppStateSnapshot>, 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<RpcOutcome<AppStateSnapshot>, String> {
local_state,
keyring_status,
runtime,
health,
},
vec!["core app state snapshot fetched".to_string()],
))
+40 -13
View File
@@ -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={}",
+42
View File
@@ -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();
+3 -3
View File
@@ -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,
+60 -4
View File
@@ -24,14 +24,33 @@ static CONSENT_EVENT_PUBLISHED: AtomicBool = AtomicBool::new(false);
/// they never touch disk on the hot path.
static CONSENT_CACHE: RwLock<Option<ConsentPreference>> = 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<ConsentPreference>) {
/// 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<ConsentPreference>) -> 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));
}
}
+1 -1
View File
@@ -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)]
+24
View File
@@ -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();
}
View File