mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
perf(core): consolidate boot polls, silence per-snapshot log spam (#5075)
This commit is contained in:
@@ -1 +1 @@
|
||||
5ec3d8836cbae300e36b7a9c0d302a65de92c58d
|
||||
11ef51edbeadcca4517b18a037fff858a5dfae0f
|
||||
|
||||
+4
-1
@@ -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
@@ -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
|
||||
|
||||
|
||||
Vendored
+1
-1
Submodule app/src-tauri/vendor/tauri-cef updated: 5ec3d8836c...11ef51edbe
+41
-15
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 2–5s app-state cadence is too coarse and
|
||||
// carries no downloads-progress detail). The poll starts when core state
|
||||
// reports activity and keeps going as long as the download itself is in
|
||||
// flight, then stops — so there is no steady-state inference polling.
|
||||
useEffect(() => {
|
||||
if (!tauriAvailable) return;
|
||||
if (!tauriAvailable || !coreDownloadActive) return;
|
||||
|
||||
let cancelled = false;
|
||||
log('fast poll: starting (core reports download active)');
|
||||
|
||||
const poll = async () => {
|
||||
let settled = false;
|
||||
try {
|
||||
const [statusRes, downloadsRes] = await Promise.all([
|
||||
openhumanLocalAiStatus(),
|
||||
@@ -58,26 +103,47 @@ const LocalAIDownloadSnackbar = () => {
|
||||
]);
|
||||
if (statusRes.result) setStatus(statusRes.result);
|
||||
if (downloadsRes.result) setDownloads(downloadsRes.result);
|
||||
} catch {
|
||||
// Silently ignore — core may not be ready
|
||||
// The download reached a terminal state — stop the fast poll early
|
||||
// rather than waiting for core state to catch up (it lags up to ~5s).
|
||||
settled = !isDownloadInFlight(statusRes.result ?? null, downloadsRes.result ?? null);
|
||||
} catch (err) {
|
||||
// Transient RPC failure (core may be busy). Do NOT treat this as
|
||||
// settled: keep polling while core state still reports the download
|
||||
// active, otherwise one blip would permanently stop progress updates
|
||||
// for the rest of the download (the effect won't re-run until
|
||||
// `coreDownloadActive` flips).
|
||||
log('fast poll: transient error, will retry: %O', err);
|
||||
}
|
||||
if (!cancelled && !settled) {
|
||||
timerRef.current = setTimeout(poll, ACTIVE_POLL_INTERVAL);
|
||||
} else if (settled) {
|
||||
log('fast poll: download settled, stopping');
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
timerRef.current = setInterval(poll, POLL_INTERVAL);
|
||||
return () => clearInterval(timerRef.current);
|
||||
}, [tauriAvailable]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
log('fast poll: stopped (unmount or core reports inactive)');
|
||||
};
|
||||
}, [tauriAvailable, coreDownloadActive]);
|
||||
|
||||
const downloadState = downloads?.state;
|
||||
const currentState =
|
||||
downloadState === 'loading' || downloadState === 'downloading' || downloadState === 'installing'
|
||||
? downloadState
|
||||
: (status?.state ?? downloadState ?? 'idle');
|
||||
// Gate on `coreDownloadActive` so a download that the core no longer reports
|
||||
// active can't leave the snackbar stuck: when the folded state flips inactive,
|
||||
// polling stops and any still-"downloading" local status/downloads must not
|
||||
// keep the overlay visible.
|
||||
const isDownloading =
|
||||
currentState === 'loading' ||
|
||||
currentState === 'downloading' ||
|
||||
currentState === 'installing' ||
|
||||
(downloads?.progress != null && downloads.progress > 0 && downloads.progress < 1);
|
||||
coreDownloadActive &&
|
||||
(currentState === 'loading' ||
|
||||
currentState === 'downloading' ||
|
||||
currentState === 'installing' ||
|
||||
(downloads?.progress != null && downloads.progress > 0 && downloads.progress < 1));
|
||||
|
||||
// Render-phase update: when a new download cycle starts (not-downloading → downloading),
|
||||
// reset the dismiss/collapsed flags so the snackbar reappears automatically.
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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': 'تحديث',
|
||||
|
||||
@@ -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': 'রিফ্রেশ',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -590,7 +590,7 @@ const messages: TranslationMap = {
|
||||
'brain.goals.actionError': 'Une erreur s’est produite. Veuillez réessayer.',
|
||||
'brain.tabs.sources': 'Sources',
|
||||
'brain.tabs.sync': 'Synchronisation',
|
||||
'brain.tabs.tinyplaceOrchestration': 'TinyPlace',
|
||||
'brain.tabs.orchestration': 'Orchestration',
|
||||
'tinyplaceOrchestration.title': 'Relais TinyPlace',
|
||||
'tinyplaceOrchestration.subtitle': "Canaux d'agents épinglés et chats de sessions app",
|
||||
'tinyplaceOrchestration.refresh': 'Actualiser',
|
||||
|
||||
@@ -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': 'रीफ़्रेश',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '새로 고침',
|
||||
|
||||
@@ -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ż',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Обновить',
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 30–40s; the
|
||||
// watchdog must NOT false-fire while one slow snapshot is in flight.
|
||||
vi.advanceTimersByTime(60000);
|
||||
expect(mockedSetStatus).not.toHaveBeenCalled();
|
||||
|
||||
service.cleanup();
|
||||
});
|
||||
|
||||
it('marks the daemon disconnected when no snapshot arrives within the timeout', () => {
|
||||
const service = new DaemonHealthService();
|
||||
service.ingestHealthSnapshot(healthPayload());
|
||||
expect(mockedSetStatus).not.toHaveBeenCalled();
|
||||
|
||||
// No further ingest for the full watchdog window → disconnected.
|
||||
vi.advanceTimersByTime(120000);
|
||||
expect(mockedSetStatus).toHaveBeenCalledWith(expect.any(String), 'disconnected');
|
||||
|
||||
service.cleanup();
|
||||
});
|
||||
|
||||
it('re-arms the disconnect watchdog on each ingest', () => {
|
||||
const service = new DaemonHealthService();
|
||||
service.ingestHealthSnapshot(healthPayload());
|
||||
|
||||
// A fresh snapshot before the deadline pushes it out.
|
||||
vi.advanceTimersByTime(100000);
|
||||
service.ingestHealthSnapshot(healthPayload());
|
||||
vi.advanceTimersByTime(100000);
|
||||
expect(mockedSetStatus).not.toHaveBeenCalled();
|
||||
|
||||
// Then go quiet past the window → disconnected.
|
||||
vi.advanceTimersByTime(120000);
|
||||
expect(mockedSetStatus).toHaveBeenCalledWith(expect.any(String), 'disconnected');
|
||||
|
||||
service.cleanup();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 30–40s. The disconnect watchdog must therefore tolerate one worst-case
|
||||
// slow snapshot (plus the poll cadence) between successful ingests, or a merely
|
||||
// slow-but-alive core would be marked `disconnected`. 120s covers the 90s cap
|
||||
// with margin; genuine disconnection (snapshots stop succeeding entirely) is
|
||||
// still detected, just less aggressively than the old dedicated 2s poll.
|
||||
private readonly HEALTH_TIMEOUT_MS = 120000;
|
||||
|
||||
async setupHealthListener(): Promise<(() => void) | null> {
|
||||
if (this.pollingIntervalId) {
|
||||
return () => this.cleanup();
|
||||
/**
|
||||
* Arm the disconnect watchdog once when daemon-health tracking starts, if it
|
||||
* isn't already armed. Without this, a core whose `app_state_snapshot`s never
|
||||
* succeed (repeated timeouts) — after `useDaemonHealth`'s one-shot agent probe
|
||||
* has set the status to `running` — would never arm a watchdog and stick at
|
||||
* `running` forever. The baseline watchdog guarantees a fallback to
|
||||
* `disconnected` if no snapshot ever arrives, and is re-armed by each ingest.
|
||||
*/
|
||||
ensureWatchdogArmed(): void {
|
||||
if (this.healthTimeoutId === null) {
|
||||
this.startHealthTimeout();
|
||||
}
|
||||
}
|
||||
|
||||
const pollOnce = async () => {
|
||||
try {
|
||||
const payload = await callCoreRpc<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).
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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()],
|
||||
))
|
||||
|
||||
@@ -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={}",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user