mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
feat(orchestration): promote TinyPlace Orchestration to its own tab + teach the orchestrator to manage a fleet (#4693)
This commit is contained in:
@@ -17,6 +17,7 @@ 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';
|
||||
@@ -134,6 +135,23 @@ 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. */}
|
||||
<Route
|
||||
path="/brain/tinyplace-orchestration"
|
||||
element={<Navigate to="/orchestration" replace />}
|
||||
/>
|
||||
|
||||
{/* Back-compat: /activity and /intelligence → settings notifications page. */}
|
||||
<Route path="/activity" element={<Navigate to="/settings/notifications" replace />} />
|
||||
<Route path="/intelligence" element={<Navigate to="/settings/notifications" replace />} />
|
||||
|
||||
@@ -29,6 +29,7 @@ describe('CollapsedNavRail', () => {
|
||||
'nav.human',
|
||||
'nav.brain',
|
||||
'nav.flows',
|
||||
'nav.orchestration',
|
||||
'nav.agentWorld',
|
||||
'nav.connections',
|
||||
]) {
|
||||
|
||||
@@ -118,6 +118,23 @@ export function NavIcon({ id, className = 'w-5 h-5' }: NavIconProps) {
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'orchestration':
|
||||
// Conductor/hub glyph — one root node branching to two workers,
|
||||
// representing multi-agent orchestration (distinct from the horizontal
|
||||
// `flows` automation graph).
|
||||
return (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="5" r="2" strokeWidth={1.8} />
|
||||
<circle cx="5" cy="19" r="2" strokeWidth={1.8} />
|
||||
<circle cx="19" cy="19" r="2" strokeWidth={1.8} />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
d="M12 7v3m0 0l-5.5 6M12 10l5.5 6"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'agent-world':
|
||||
// Globe/network glyph — represents the A2A agent social network.
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* AgentChatPanel — "chat with the main agent and see its subconscious".
|
||||
*
|
||||
* The focused conversation surface of the Orchestration tab: a two-way toggle
|
||||
* between the Master chat (you ↔ the main agent) and the Subconscious chat (the
|
||||
* background steering loop), rendered through the shared {@link OrchestrationFocusPane}.
|
||||
* Connections/discovery moved to their own sub-pages, so this panel deliberately
|
||||
* drops the contact rail and keeps just the agent dialogue + steering controls.
|
||||
*/
|
||||
import debugFactory from 'debug';
|
||||
import { type FormEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import {
|
||||
MASTER_CHAT_KEY,
|
||||
SUBCONSCIOUS_CHAT_KEY,
|
||||
useOrchestrationChats,
|
||||
} from '../../lib/orchestration/useOrchestrationChats';
|
||||
import { subconsciousTrigger } from '../../utils/tauriCommands/subconscious';
|
||||
import OrchestrationFocusPane from '../intelligence/OrchestrationFocusPane';
|
||||
|
||||
const debug = debugFactory('orchestration:agent-chat');
|
||||
|
||||
export default function AgentChatPanel() {
|
||||
const { t } = useT();
|
||||
const {
|
||||
sessionsState,
|
||||
messagesState,
|
||||
selectedId,
|
||||
selected,
|
||||
status,
|
||||
masterError,
|
||||
selectChat,
|
||||
refresh,
|
||||
sendMessage,
|
||||
} = useOrchestrationChats(t);
|
||||
|
||||
const [composerBody, setComposerBody] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [runningReview, setRunningReview] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const submitComposer = useCallback(
|
||||
(event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const body = composerBody.trim();
|
||||
if (!body || sending) return;
|
||||
setSending(true);
|
||||
void sendMessage(selected, body).then(ok => {
|
||||
if (!mountedRef.current) return;
|
||||
if (ok) setComposerBody('');
|
||||
setSending(false);
|
||||
});
|
||||
},
|
||||
[composerBody, sending, sendMessage, selected]
|
||||
);
|
||||
|
||||
const runSteeringReview = useCallback(async () => {
|
||||
debug('steering review: trigger');
|
||||
setRunningReview(true);
|
||||
try {
|
||||
await subconsciousTrigger('tinyplace');
|
||||
} catch (err) {
|
||||
debug('steering review trigger failed: %o', err);
|
||||
} finally {
|
||||
setRunningReview(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const steeringText = status?.steering?.text?.trim() || null;
|
||||
const isMasterSelected = selectedId === MASTER_CHAT_KEY;
|
||||
|
||||
const tabs: Array<{ key: string; label: string }> = [
|
||||
{ key: MASTER_CHAT_KEY, label: t('orchPage.agent.mainTab') },
|
||||
{ key: SUBCONSCIOUS_CHAT_KEY, label: t('orchPage.agent.subconsciousTab') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-[520px] flex-col overflow-hidden rounded-xl border border-line bg-surface shadow-soft">
|
||||
{/* Master ↔ Subconscious switch. */}
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-1 border-b border-line px-3 py-2"
|
||||
role="tablist"
|
||||
aria-label={t('orchPage.agent.description')}>
|
||||
{tabs.map(tab => {
|
||||
const active = selectedId === tab.key;
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
data-testid={`orch-agent-tab-${tab.key}`}
|
||||
onClick={() => selectChat(tab.key)}
|
||||
className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
|
||||
active
|
||||
? 'bg-surface-subtle font-medium text-content'
|
||||
: 'text-content-secondary hover:bg-surface-hover hover:text-content'
|
||||
}`}>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<OrchestrationFocusPane
|
||||
selected={selected}
|
||||
sessionsState={sessionsState}
|
||||
messagesState={messagesState}
|
||||
status={status}
|
||||
masterError={masterError}
|
||||
refresh={refresh}
|
||||
steeringText={steeringText}
|
||||
runningReview={runningReview}
|
||||
onRunSteeringReview={() => void runSteeringReview()}
|
||||
canCompose={isMasterSelected}
|
||||
composerBody={composerBody}
|
||||
onComposerChange={setComposerBody}
|
||||
sending={sending}
|
||||
onSubmitComposer={submitComposer}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* ConnectionsPanel — "manage connections".
|
||||
*
|
||||
* Lists the agent's accepted tiny.place contacts (the peers the main agent can
|
||||
* coordinate with), with a per-contact block action and a count summary. Adding
|
||||
* new links + accepting requests lives in the sibling {@link DiscoverPanel};
|
||||
* this panel is the steady-state management view.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { apiClient } from '../../agentworld/AgentWorldShell';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { usePairing } from '../../lib/orchestration/usePairing';
|
||||
import { contactAddress, extractHandle } from '../intelligence/orchestrationTabHelpers';
|
||||
import Button from '../ui/Button';
|
||||
import { SectionCard, StatTile } from './primitives';
|
||||
|
||||
export default function ConnectionsPanel({ onDiscover }: { onDiscover?: () => void }) {
|
||||
const { t } = useT();
|
||||
const { state, runAction, pendingAction, actionError } = usePairing();
|
||||
const [handles, setHandles] = useState<Record<string, string | null>>({});
|
||||
|
||||
const snapshot = state.status === 'ok' ? state.snapshot : null;
|
||||
const accepted = useMemo(
|
||||
() => (snapshot?.contacts.contacts ?? []).filter(c => c.status === 'accepted'),
|
||||
[snapshot?.contacts.contacts]
|
||||
);
|
||||
const stats = snapshot?.stats ?? null;
|
||||
|
||||
// Best-effort @handle resolution for the listed contacts (address always shown).
|
||||
const addressesKey = accepted.map(contactAddress).filter(Boolean).join(',');
|
||||
useEffect(() => {
|
||||
const ids = addressesKey ? Array.from(new Set(addressesKey.split(','))) : [];
|
||||
if (ids.length === 0) return;
|
||||
let cancelled = false;
|
||||
void Promise.all(
|
||||
ids.map(async id => {
|
||||
try {
|
||||
return [id, extractHandle(await apiClient.directory.reverse(id))] as const;
|
||||
} catch {
|
||||
return [id, null] as const;
|
||||
}
|
||||
})
|
||||
).then(entries => {
|
||||
if (cancelled) return;
|
||||
setHandles(prev => {
|
||||
const next = { ...prev };
|
||||
for (const [id, handle] of entries) if (!(id in next)) next[id] = handle;
|
||||
return next;
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [addressesKey]);
|
||||
|
||||
if (state.status === 'loading') {
|
||||
return (
|
||||
<p
|
||||
className="py-8 text-center text-sm text-content-muted"
|
||||
data-testid="orch-connections-loading">
|
||||
{t('tinyplaceOrchestration.loading')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state.status === 'payment_required') {
|
||||
return (
|
||||
<p className="py-8 text-center text-sm text-amber-600 dark:text-amber-300">
|
||||
{t('tinyplaceOrchestration.paymentRequired')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state.status === 'error') {
|
||||
return (
|
||||
<p className="py-8 text-center text-sm text-coral-600 dark:text-coral-300">
|
||||
{t('tinyplaceOrchestration.failedToLoad')}: {state.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5" data-testid="orch-connections-panel">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<StatTile
|
||||
label={t('orchPage.connections.statContacts')}
|
||||
value={stats?.contactCount ?? accepted.length}
|
||||
testId="orch-connections-stat-contacts"
|
||||
/>
|
||||
<StatTile
|
||||
label={t('orchPage.connections.statPending')}
|
||||
value={(stats?.pendingIncoming ?? 0) + (stats?.pendingOutgoing ?? 0)}
|
||||
hint={t('orchPage.connections.pendingHint')}
|
||||
testId="orch-connections-stat-pending"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{actionError && (
|
||||
<p className="rounded-md bg-coral-50 px-3 py-2 text-xs text-coral-700 dark:bg-coral-500/10 dark:text-coral-300">
|
||||
{actionError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<SectionCard
|
||||
title={t('orchPage.connections.title')}
|
||||
description={t('orchPage.connections.description')}
|
||||
action={
|
||||
onDiscover && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onDiscover}
|
||||
data-testid="orch-connections-add">
|
||||
{t('orchPage.discover.linkAction')}
|
||||
</Button>
|
||||
)
|
||||
}>
|
||||
{accepted.length === 0 ? (
|
||||
<div className="py-6 text-center" data-testid="orch-connections-empty">
|
||||
<p className="text-sm text-content-muted">{t('orchPage.connections.empty')}</p>
|
||||
{onDiscover && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={onDiscover}
|
||||
data-testid="orch-connections-empty-cta">
|
||||
{t('orchPage.connections.emptyCta')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-line">
|
||||
{accepted.map(contact => {
|
||||
const address = contactAddress(contact);
|
||||
const handle = handles[address];
|
||||
const actionId = `block:${address}`;
|
||||
return (
|
||||
<li
|
||||
key={address}
|
||||
className="flex items-center justify-between gap-3 py-2.5"
|
||||
data-testid="orch-connection-row">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-content">
|
||||
{handle ? `@${handle}` : t('tinyplaceOrchestration.unknownSender')}
|
||||
</p>
|
||||
<p className="truncate font-mono text-[11px] text-content-faint">{address}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
disabled={pendingAction === actionId}
|
||||
onClick={() =>
|
||||
void runAction(actionId, () =>
|
||||
apiClient.orchestrationPairing.blockRequest(address)
|
||||
)
|
||||
}
|
||||
data-testid="orch-connection-block">
|
||||
{t('tinyplaceOrchestration.pairing.block')}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* DiscoverPanel — "handle connections and guide the user to add more".
|
||||
*
|
||||
* The growth surface: shows this agent's own tiny.place discoverability (so the
|
||||
* user knows whether peers can even reach them), a link-a-new-agent form, and
|
||||
* the inbound contact-request queue (accept / decline / block). Steady-state
|
||||
* management of already-linked peers lives in the sibling {@link ConnectionsPanel}.
|
||||
*/
|
||||
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { apiClient } from '../../agentworld/AgentWorldShell';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import {
|
||||
orchestrationClient,
|
||||
type RelayInfo,
|
||||
type SelfIdentity,
|
||||
} from '../../lib/orchestration/orchestrationClient';
|
||||
import { usePairing } from '../../lib/orchestration/usePairing';
|
||||
import { contactAddress, extractHandle } from '../intelligence/orchestrationTabHelpers';
|
||||
import Button from '../ui/Button';
|
||||
import { SectionCard } from './primitives';
|
||||
|
||||
export default function DiscoverPanel() {
|
||||
const { t } = useT();
|
||||
const { state, runAction, pendingAction, actionError } = usePairing();
|
||||
const [linkAgentId, setLinkAgentId] = useState('');
|
||||
const [identity, setIdentity] = useState<SelfIdentity | null>(null);
|
||||
const [relay, setRelay] = useState<RelayInfo | null>(null);
|
||||
const [identityLoading, setIdentityLoading] = useState(true);
|
||||
const [handles, setHandles] = useState<Record<string, string | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void Promise.allSettled([
|
||||
orchestrationClient.selfIdentity(),
|
||||
orchestrationClient.relayInfo(),
|
||||
]).then(([id, rel]) => {
|
||||
if (cancelled) return;
|
||||
if (id.status === 'fulfilled') setIdentity(id.value);
|
||||
if (rel.status === 'fulfilled') setRelay(rel.value);
|
||||
setIdentityLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const snapshot = state.status === 'ok' ? state.snapshot : null;
|
||||
const incoming = useMemo(() => snapshot?.requests.incoming ?? [], [snapshot?.requests.incoming]);
|
||||
|
||||
// Resolve @handles for the inbound requests (best-effort; address always shown).
|
||||
const addressesKey = incoming.map(contactAddress).filter(Boolean).join(',');
|
||||
useEffect(() => {
|
||||
const ids = addressesKey ? Array.from(new Set(addressesKey.split(','))) : [];
|
||||
if (ids.length === 0) return;
|
||||
let cancelled = false;
|
||||
void Promise.all(
|
||||
ids.map(async id => {
|
||||
try {
|
||||
return [id, extractHandle(await apiClient.directory.reverse(id))] as const;
|
||||
} catch {
|
||||
return [id, null] as const;
|
||||
}
|
||||
})
|
||||
).then(entries => {
|
||||
if (cancelled) return;
|
||||
setHandles(prev => {
|
||||
const next = { ...prev };
|
||||
for (const [id, handle] of entries) if (!(id in next)) next[id] = handle;
|
||||
return next;
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [addressesKey]);
|
||||
|
||||
const submitLink = useCallback(
|
||||
(event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const agentId = linkAgentId.trim();
|
||||
if (!agentId) return;
|
||||
void runAction(`link:${agentId}`, async () => {
|
||||
await apiClient.orchestrationPairing.linkSession(agentId);
|
||||
setLinkAgentId('');
|
||||
});
|
||||
},
|
||||
[linkAgentId, runAction]
|
||||
);
|
||||
|
||||
const discoverable = identity?.discoverable ?? false;
|
||||
|
||||
return (
|
||||
<div className="space-y-5" data-testid="orch-discover-panel">
|
||||
{/* Own identity / discoverability. */}
|
||||
<SectionCard title={t('orchPage.discover.identityTitle')} testId="orch-discover-identity">
|
||||
{identityLoading ? (
|
||||
<p className="text-sm text-content-muted">
|
||||
{t('tinyplaceOrchestration.identity.loading')}
|
||||
</p>
|
||||
) : identity ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
discoverable
|
||||
? 'bg-sage-100 text-sage-700 dark:bg-sage-500/15 dark:text-sage-300'
|
||||
: 'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300'
|
||||
}`}
|
||||
data-testid="orch-discover-discoverable">
|
||||
{discoverable
|
||||
? t('tinyplaceOrchestration.identity.discoverable')
|
||||
: t('tinyplaceOrchestration.identity.undiscoverable')}
|
||||
</span>
|
||||
{relay && (
|
||||
<span className="text-xs text-content-faint">
|
||||
{relay.network === 'prod'
|
||||
? t('tinyplaceOrchestration.relay.prod')
|
||||
: t('tinyplaceOrchestration.relay.staging')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-content-secondary">
|
||||
{identity.primaryHandle
|
||||
? `@${identity.primaryHandle.replace(/^@+/, '')}`
|
||||
: t('tinyplaceOrchestration.identity.noHandle')}
|
||||
</p>
|
||||
{!discoverable && (
|
||||
<p className="text-xs text-content-muted" data-testid="orch-discover-guide">
|
||||
{t('orchPage.discover.notDiscoverableGuide')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-content-muted">
|
||||
{t('tinyplaceOrchestration.identity.noHandle')}
|
||||
</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{/* Link a new agent. */}
|
||||
<SectionCard
|
||||
title={t('orchPage.discover.linkTitle')}
|
||||
description={t('orchPage.discover.linkDescription')}
|
||||
testId="orch-discover-link">
|
||||
<form className="flex gap-2" onSubmit={submitLink}>
|
||||
<input
|
||||
value={linkAgentId}
|
||||
onChange={e => setLinkAgentId(e.target.value)}
|
||||
placeholder={t('tinyplaceOrchestration.pairing.linkPlaceholder')}
|
||||
className="min-w-0 flex-1 rounded-md border border-line bg-surface px-3 py-2 text-sm text-content outline-none transition focus:border-ocean-500 focus:ring-2 focus:ring-ocean-500/20"
|
||||
data-testid="orch-discover-link-input"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={!linkAgentId.trim() || pendingAction === `link:${linkAgentId.trim()}`}
|
||||
data-testid="orch-discover-link-submit">
|
||||
{t('tinyplaceOrchestration.pairing.linkAction')}
|
||||
</Button>
|
||||
</form>
|
||||
{actionError && (
|
||||
<p className="mt-2 text-xs text-coral-600 dark:text-coral-300">{actionError}</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{/* Inbound requests. */}
|
||||
<SectionCard
|
||||
title={t('tinyplaceOrchestration.pairing.requests')}
|
||||
testId="orch-discover-requests">
|
||||
{state.status === 'loading' ? (
|
||||
<p className="text-sm text-content-muted">{t('tinyplaceOrchestration.loading')}</p>
|
||||
) : incoming.length === 0 ? (
|
||||
<p className="py-2 text-sm text-content-muted" data-testid="orch-discover-no-requests">
|
||||
{t('orchPage.discover.noRequests')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-line">
|
||||
{incoming.map(request => {
|
||||
const address = contactAddress(request);
|
||||
const handle = handles[address];
|
||||
return (
|
||||
<li
|
||||
key={address}
|
||||
className="flex items-center justify-between gap-3 py-2.5"
|
||||
data-testid="orch-discover-request-row">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-content">
|
||||
{handle ? `@${handle}` : t('tinyplaceOrchestration.unknownSender')}
|
||||
</p>
|
||||
<p className="truncate font-mono text-[11px] text-content-faint">{address}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1.5">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={pendingAction === `accept:${address}`}
|
||||
onClick={() =>
|
||||
void runAction(`accept:${address}`, () =>
|
||||
apiClient.orchestrationPairing.acceptRequest(address)
|
||||
)
|
||||
}
|
||||
data-testid="orch-discover-accept">
|
||||
{t('tinyplaceOrchestration.pairing.accept')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
disabled={pendingAction === `decline:${address}`}
|
||||
onClick={() =>
|
||||
void runAction(`decline:${address}`, () =>
|
||||
apiClient.orchestrationPairing.declineRequest(address)
|
||||
)
|
||||
}
|
||||
data-testid="orch-discover-decline">
|
||||
{t('tinyplaceOrchestration.pairing.decline')}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* UsagePanel — "stats on how much tokens, connections etc. have been spent".
|
||||
*
|
||||
* A read-only dashboard of the orchestration surface's footprint: credit
|
||||
* balance + cycle spend (billing), inference/integration call counts (team
|
||||
* usage), TokenJuice compaction savings, and the live connection count. Every
|
||||
* source is loaded independently (`Promise.allSettled`) so a single unavailable
|
||||
* backend (e.g. billing offline in a local build) degrades one tile to "—"
|
||||
* rather than blanking the page.
|
||||
*/
|
||||
import debugFactory from 'debug';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { apiClient } from '../../agentworld/AgentWorldShell';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { type CreditBalance, creditsApi, type TeamUsage } from '../../services/api/creditsApi';
|
||||
import { getTokenjuiceSavings, type SavingsStats } from '../../utils/tauriCommands/tokenjuice';
|
||||
import { StatTile } from './primitives';
|
||||
|
||||
const debug = debugFactory('orchestration:usage');
|
||||
|
||||
function usd(value: number | undefined): string {
|
||||
if (value == null || !Number.isFinite(value)) return '—';
|
||||
return `$${value.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function count(value: number | undefined): string {
|
||||
if (value == null || !Number.isFinite(value)) return '—';
|
||||
return new Intl.NumberFormat().format(value);
|
||||
}
|
||||
|
||||
interface UsageData {
|
||||
balance: CreditBalance | null;
|
||||
team: TeamUsage | null;
|
||||
savings: SavingsStats | null;
|
||||
connections: number | null;
|
||||
}
|
||||
|
||||
export default function UsagePanel() {
|
||||
const { t } = useT();
|
||||
const [data, setData] = useState<UsageData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
debug('usage load: entry');
|
||||
void Promise.allSettled([
|
||||
creditsApi.getBalance(),
|
||||
creditsApi.getTeamUsage(),
|
||||
getTokenjuiceSavings(),
|
||||
apiClient.orchestrationPairing.list(),
|
||||
]).then(([balance, team, savings, pairing]) => {
|
||||
if (cancelled) return;
|
||||
debug(
|
||||
'usage load: exit balance=%s team=%s savings=%s pairing=%s',
|
||||
balance.status,
|
||||
team.status,
|
||||
savings.status,
|
||||
pairing.status
|
||||
);
|
||||
setData({
|
||||
balance: balance.status === 'fulfilled' ? balance.value : null,
|
||||
team: team.status === 'fulfilled' ? team.value : null,
|
||||
savings: savings.status === 'fulfilled' ? savings.value : null,
|
||||
connections:
|
||||
pairing.status === 'fulfilled'
|
||||
? (pairing.value.stats?.contactCount ??
|
||||
pairing.value.contacts.contacts.filter(c => c.status === 'accepted').length)
|
||||
: null,
|
||||
});
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<p className="py-8 text-center text-sm text-content-muted" data-testid="orch-usage-loading">
|
||||
{t('tinyplaceOrchestration.loading')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const balanceUsd =
|
||||
data?.balance != null
|
||||
? data.balance.promotionBalanceUsd + data.balance.teamTopupUsd
|
||||
: undefined;
|
||||
const totals = data?.team?.insights.totals;
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-testid="orch-usage-panel">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<StatTile
|
||||
label={t('orchPage.usage.connections')}
|
||||
value={count(data?.connections ?? undefined)}
|
||||
testId="orch-usage-connections"
|
||||
/>
|
||||
<StatTile
|
||||
label={t('orchPage.usage.balance')}
|
||||
value={usd(balanceUsd)}
|
||||
hint={t('orchPage.usage.balanceHint')}
|
||||
testId="orch-usage-balance"
|
||||
/>
|
||||
<StatTile
|
||||
label={t('orchPage.usage.cycleSpend')}
|
||||
value={usd(data?.team?.cycleSpentUsd)}
|
||||
hint={
|
||||
data?.team
|
||||
? `${t('orchPage.usage.ofBudget')} ${usd(data.team.cycleBudgetUsd)}`
|
||||
: undefined
|
||||
}
|
||||
testId="orch-usage-cycle-spend"
|
||||
/>
|
||||
<StatTile
|
||||
label={t('orchPage.usage.inferenceCalls')}
|
||||
value={count(totals?.inferenceCalls)}
|
||||
testId="orch-usage-inference-calls"
|
||||
/>
|
||||
<StatTile
|
||||
label={t('orchPage.usage.integrationCalls')}
|
||||
value={count(totals?.integrationCalls)}
|
||||
testId="orch-usage-integration-calls"
|
||||
/>
|
||||
<StatTile
|
||||
label={t('orchPage.usage.tokensSaved')}
|
||||
value={count(data?.savings?.total.tokensSaved)}
|
||||
hint={
|
||||
data?.savings
|
||||
? `${usd(data.savings.total.costSavedUsd)} ${t('orchPage.usage.saved')}`
|
||||
: undefined
|
||||
}
|
||||
testId="orch-usage-tokens-saved"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-content-faint">{t('orchPage.usage.footnote')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import AgentChatPanel from '../AgentChatPanel';
|
||||
|
||||
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
|
||||
|
||||
const selectChat = vi.hoisted(() => vi.fn());
|
||||
const chatsApi = vi.hoisted(() => ({
|
||||
current: {
|
||||
sessionsState: { status: 'ok' },
|
||||
messagesState: { status: 'ok' },
|
||||
selectedId: 'master',
|
||||
selected: { id: 'master', title: 'Master', messages: [] },
|
||||
status: null,
|
||||
masterError: null,
|
||||
selectChat,
|
||||
refresh: vi.fn(),
|
||||
sendMessage: vi.fn().mockResolvedValue(true),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../lib/orchestration/useOrchestrationChats', () => ({
|
||||
MASTER_CHAT_KEY: 'master',
|
||||
SUBCONSCIOUS_CHAT_KEY: 'subconscious',
|
||||
useOrchestrationChats: () => chatsApi.current,
|
||||
}));
|
||||
|
||||
// Surface the props we care about from the focus pane stub, and expose controls
|
||||
// that invoke the container's composer + steering-review handlers.
|
||||
interface FocusStubProps {
|
||||
canCompose: boolean;
|
||||
selected?: { id: string };
|
||||
composerBody: string;
|
||||
onComposerChange: (v: string) => void;
|
||||
onSubmitComposer: (e: { preventDefault: () => void }) => void;
|
||||
onRunSteeringReview: () => void;
|
||||
}
|
||||
vi.mock('../../intelligence/OrchestrationFocusPane', () => ({
|
||||
default: ({
|
||||
canCompose,
|
||||
selected,
|
||||
composerBody,
|
||||
onComposerChange,
|
||||
onSubmitComposer,
|
||||
onRunSteeringReview,
|
||||
}: FocusStubProps) => (
|
||||
<div
|
||||
data-testid="focus-pane"
|
||||
data-can-compose={String(canCompose)}
|
||||
data-selected={selected?.id}>
|
||||
<form data-testid="focus-form" onSubmit={onSubmitComposer}>
|
||||
<input
|
||||
data-testid="focus-input"
|
||||
value={composerBody}
|
||||
onChange={e => onComposerChange(e.target.value)}
|
||||
/>
|
||||
</form>
|
||||
<button data-testid="focus-steering" onClick={() => onRunSteeringReview()}>
|
||||
review
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const subconsciousTrigger = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
vi.mock('../../../utils/tauriCommands/subconscious', () => ({ subconsciousTrigger }));
|
||||
|
||||
describe('AgentChatPanel', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('renders the master/subconscious toggle and the focus pane', () => {
|
||||
render(<AgentChatPanel />);
|
||||
expect(screen.getByTestId('orch-agent-tab-master')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('orch-agent-tab-subconscious')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('focus-pane')).toHaveAttribute('data-can-compose', 'true');
|
||||
});
|
||||
|
||||
it('switches to the subconscious chat when its tab is clicked', () => {
|
||||
render(<AgentChatPanel />);
|
||||
fireEvent.click(screen.getByTestId('orch-agent-tab-subconscious'));
|
||||
expect(selectChat).toHaveBeenCalledWith('subconscious');
|
||||
});
|
||||
|
||||
it('disables composing when the subconscious chat is selected', () => {
|
||||
chatsApi.current = { ...chatsApi.current, selectedId: 'subconscious', selectChat };
|
||||
render(<AgentChatPanel />);
|
||||
expect(screen.getByTestId('focus-pane')).toHaveAttribute('data-can-compose', 'false');
|
||||
});
|
||||
|
||||
it('sends a composed message through the chats api', async () => {
|
||||
const sendMessage = vi.fn().mockResolvedValue(true);
|
||||
chatsApi.current = { ...chatsApi.current, selectedId: 'master', selectChat, sendMessage };
|
||||
render(<AgentChatPanel />);
|
||||
fireEvent.change(screen.getByTestId('focus-input'), { target: { value: 'hello' } });
|
||||
fireEvent.submit(screen.getByTestId('focus-form'));
|
||||
await waitFor(() => expect(sendMessage).toHaveBeenCalledWith(expect.anything(), 'hello'));
|
||||
});
|
||||
|
||||
it('triggers a subconscious steering review', async () => {
|
||||
chatsApi.current = { ...chatsApi.current, selectChat };
|
||||
render(<AgentChatPanel />);
|
||||
fireEvent.click(screen.getByTestId('focus-steering'));
|
||||
await waitFor(() => expect(subconsciousTrigger).toHaveBeenCalledWith('tinyplace'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import ConnectionsPanel from '../ConnectionsPanel';
|
||||
|
||||
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
|
||||
|
||||
const runAction = vi.hoisted(() => vi.fn());
|
||||
const pairing = vi.hoisted(() => ({
|
||||
current: {
|
||||
state: { status: 'ok' as const, snapshot: {} as Record<string, unknown> },
|
||||
reload: vi.fn(),
|
||||
runAction,
|
||||
pendingAction: null as string | null,
|
||||
actionError: null as string | null,
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../lib/orchestration/usePairing', () => ({ usePairing: () => pairing.current }));
|
||||
|
||||
const blockRequest = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
const reverse = vi.hoisted(() => vi.fn().mockResolvedValue({ agents: [{ username: 'alice' }] }));
|
||||
vi.mock('../../../agentworld/AgentWorldShell', () => ({
|
||||
apiClient: { orchestrationPairing: { blockRequest }, directory: { reverse } },
|
||||
}));
|
||||
|
||||
function okState(accepted: unknown[], stats?: unknown) {
|
||||
return {
|
||||
status: 'ok' as const,
|
||||
snapshot: {
|
||||
contacts: { contacts: accepted },
|
||||
requests: { incoming: [], outgoing: [] },
|
||||
stats: stats ?? {
|
||||
agentId: 'me',
|
||||
contactCount: accepted.length,
|
||||
pendingIncoming: 0,
|
||||
pendingOutgoing: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('ConnectionsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pairing.current = { ...pairing.current, pendingAction: null, actionError: null };
|
||||
});
|
||||
|
||||
it('shows a loading state', () => {
|
||||
pairing.current = { ...pairing.current, state: { status: 'loading' } as never };
|
||||
render(<ConnectionsPanel />);
|
||||
expect(screen.getByTestId('orch-connections-loading')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an empty state with a discover CTA', () => {
|
||||
pairing.current = { ...pairing.current, state: okState([]) as never };
|
||||
const onDiscover = vi.fn();
|
||||
render(<ConnectionsPanel onDiscover={onDiscover} />);
|
||||
expect(screen.getByTestId('orch-connections-empty')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('orch-connections-empty-cta'));
|
||||
expect(onDiscover).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists accepted contacts and blocks one', async () => {
|
||||
pairing.current = {
|
||||
...pairing.current,
|
||||
state: okState([{ status: 'accepted', agentId: 'agent-1', contact: {} }]) as never,
|
||||
};
|
||||
render(<ConnectionsPanel />);
|
||||
expect(screen.getByTestId('orch-connection-row')).toBeInTheDocument();
|
||||
// handle resolves from the directory reverse lookup
|
||||
await waitFor(() => expect(screen.getByText('@alice')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByTestId('orch-connection-block'));
|
||||
expect(runAction).toHaveBeenCalledWith('block:agent-1', expect.any(Function));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DiscoverPanel from '../DiscoverPanel';
|
||||
|
||||
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
|
||||
|
||||
const runAction = vi.hoisted(() => vi.fn());
|
||||
const pairing = vi.hoisted(() => ({
|
||||
current: {
|
||||
state: { status: 'ok' as const, snapshot: { requests: { incoming: [], outgoing: [] } } },
|
||||
reload: vi.fn(),
|
||||
runAction,
|
||||
pendingAction: null as string | null,
|
||||
actionError: null as string | null,
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../lib/orchestration/usePairing', () => ({ usePairing: () => pairing.current }));
|
||||
|
||||
const selfIdentity = vi.hoisted(() => vi.fn());
|
||||
const relayInfo = vi.hoisted(() => vi.fn().mockResolvedValue({ baseUrl: 'x', network: 'prod' }));
|
||||
vi.mock('../../../lib/orchestration/orchestrationClient', () => ({
|
||||
orchestrationClient: { selfIdentity, relayInfo },
|
||||
}));
|
||||
|
||||
const linkSession = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
const acceptRequest = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
const reverse = vi.hoisted(() => vi.fn().mockResolvedValue({ agents: [] }));
|
||||
vi.mock('../../../agentworld/AgentWorldShell', () => ({
|
||||
apiClient: {
|
||||
orchestrationPairing: { linkSession, acceptRequest, declineRequest: vi.fn() },
|
||||
directory: { reverse },
|
||||
},
|
||||
}));
|
||||
|
||||
describe('DiscoverPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
selfIdentity.mockResolvedValue({
|
||||
agentId: 'me',
|
||||
handles: [],
|
||||
discoverable: false,
|
||||
cardPublished: false,
|
||||
keyPublished: false,
|
||||
});
|
||||
pairing.current = {
|
||||
...pairing.current,
|
||||
state: { status: 'ok', snapshot: { requests: { incoming: [], outgoing: [] } } } as never,
|
||||
pendingAction: null,
|
||||
actionError: null,
|
||||
};
|
||||
});
|
||||
|
||||
it('shows the discoverability guide when not discoverable', async () => {
|
||||
render(<DiscoverPanel />);
|
||||
await waitFor(() => expect(screen.getByTestId('orch-discover-guide')).toBeInTheDocument());
|
||||
expect(screen.getByTestId('orch-discover-no-requests')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits a link request', async () => {
|
||||
render(<DiscoverPanel />);
|
||||
fireEvent.change(screen.getByTestId('orch-discover-link-input'), {
|
||||
target: { value: 'agent-9' },
|
||||
});
|
||||
fireEvent.submit(screen.getByTestId('orch-discover-link-input').closest('form')!);
|
||||
expect(runAction).toHaveBeenCalledWith('link:agent-9', expect.any(Function));
|
||||
});
|
||||
|
||||
it('accepts an inbound request', async () => {
|
||||
pairing.current = {
|
||||
...pairing.current,
|
||||
state: {
|
||||
status: 'ok',
|
||||
snapshot: {
|
||||
requests: {
|
||||
incoming: [{ status: 'pending', agentId: 'agent-7', contact: {} }],
|
||||
outgoing: [],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
};
|
||||
render(<DiscoverPanel />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('orch-discover-request-row')).toBeInTheDocument()
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('orch-discover-accept'));
|
||||
expect(runAction).toHaveBeenCalledWith('accept:agent-7', expect.any(Function));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import UsagePanel from '../UsagePanel';
|
||||
|
||||
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
|
||||
|
||||
const getBalance = vi.hoisted(() => vi.fn());
|
||||
const getTeamUsage = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../../services/api/creditsApi', () => ({ creditsApi: { getBalance, getTeamUsage } }));
|
||||
|
||||
const getTokenjuiceSavings = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../../utils/tauriCommands/tokenjuice', () => ({ getTokenjuiceSavings }));
|
||||
|
||||
const list = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../../agentworld/AgentWorldShell', () => ({
|
||||
apiClient: { orchestrationPairing: { list } },
|
||||
}));
|
||||
|
||||
describe('UsagePanel', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('renders every stat tile when all sources succeed', async () => {
|
||||
getBalance.mockResolvedValue({ promotionBalanceUsd: 5, teamTopupUsd: 10 });
|
||||
getTeamUsage.mockResolvedValue({
|
||||
cycleSpentUsd: 3.5,
|
||||
cycleBudgetUsd: 20,
|
||||
insights: { totals: { inferenceCalls: 42, integrationCalls: 7 } },
|
||||
});
|
||||
getTokenjuiceSavings.mockResolvedValue({ total: { tokensSaved: 1234, costSavedUsd: 0.9 } });
|
||||
list.mockResolvedValue({ stats: { contactCount: 3 }, contacts: { contacts: [] } });
|
||||
|
||||
render(<UsagePanel />);
|
||||
await waitFor(() => expect(screen.getByTestId('orch-usage-panel')).toBeInTheDocument());
|
||||
expect(screen.getByTestId('orch-usage-connections')).toHaveTextContent('3');
|
||||
expect(screen.getByTestId('orch-usage-balance')).toHaveTextContent('$15.00');
|
||||
expect(screen.getByTestId('orch-usage-cycle-spend')).toHaveTextContent('$3.50');
|
||||
expect(screen.getByTestId('orch-usage-inference-calls')).toHaveTextContent('42');
|
||||
expect(screen.getByTestId('orch-usage-tokens-saved')).toHaveTextContent('1,234');
|
||||
});
|
||||
|
||||
it('degrades unavailable sources to a dash without blanking the page', async () => {
|
||||
getBalance.mockRejectedValue(new Error('billing offline'));
|
||||
getTeamUsage.mockRejectedValue(new Error('billing offline'));
|
||||
getTokenjuiceSavings.mockRejectedValue(new Error('no core'));
|
||||
list.mockRejectedValue(new Error('no relay'));
|
||||
|
||||
render(<UsagePanel />);
|
||||
await waitFor(() => expect(screen.getByTestId('orch-usage-panel')).toBeInTheDocument());
|
||||
expect(screen.getByTestId('orch-usage-balance')).toHaveTextContent('—');
|
||||
expect(screen.getByTestId('orch-usage-connections')).toHaveTextContent('—');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Small presentational building blocks shared by the Orchestration sub-pages
|
||||
* (Connections / Discover / Usage). Pure, no data access.
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/** A titled card surface used to group a panel section. */
|
||||
export function SectionCard({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
children,
|
||||
testId,
|
||||
}: {
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="rounded-xl border border-line bg-surface p-4 shadow-soft"
|
||||
data-testid={testId}>
|
||||
{(title || action) && (
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
{title && <h3 className="text-sm font-semibold text-content">{title}</h3>}
|
||||
{description && <p className="mt-0.5 text-xs text-content-muted">{description}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single metric tile: a big value over a muted label, with an optional hint. */
|
||||
export function StatTile({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
testId,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
hint?: ReactNode;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-line bg-surface p-4 shadow-soft" data-testid={testId}>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-content-muted">{label}</p>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums text-content">{value}</p>
|
||||
{hint && <p className="mt-1 text-[11px] text-content-faint">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { describe, expect, it } from 'vitest';
|
||||
import { AVATAR_MENU_ITEMS, NAV_TABS } from '../navConfig';
|
||||
|
||||
describe('NAV_TABS', () => {
|
||||
it('has exactly 6 entries', () => {
|
||||
expect(NAV_TABS).toHaveLength(6);
|
||||
it('has exactly 7 entries', () => {
|
||||
expect(NAV_TABS).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('has the correct ids in order', () => {
|
||||
@@ -13,6 +13,7 @@ describe('NAV_TABS', () => {
|
||||
'human',
|
||||
'brain',
|
||||
'flows',
|
||||
'orchestration',
|
||||
'agent-world',
|
||||
'connections',
|
||||
]);
|
||||
@@ -24,6 +25,7 @@ describe('NAV_TABS', () => {
|
||||
'/human',
|
||||
'/brain',
|
||||
'/flows',
|
||||
'/orchestration',
|
||||
'/agent-world',
|
||||
'/connections',
|
||||
]);
|
||||
@@ -35,6 +37,7 @@ describe('NAV_TABS', () => {
|
||||
'nav.human',
|
||||
'nav.brain',
|
||||
'nav.flows',
|
||||
'nav.orchestration',
|
||||
'nav.agentWorld',
|
||||
'nav.connections',
|
||||
]);
|
||||
@@ -46,6 +49,7 @@ describe('NAV_TABS', () => {
|
||||
'tab-human',
|
||||
'tab-brain',
|
||||
'tab-flows',
|
||||
'tab-orchestration',
|
||||
'tab-agent-world',
|
||||
'tab-connections',
|
||||
]);
|
||||
|
||||
@@ -20,8 +20,12 @@ export interface NavTab {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered list of sidebar nav entries. Four entries:
|
||||
* chat → human → brain → connections
|
||||
* Ordered list of sidebar nav entries:
|
||||
* chat → human → brain → flows → orchestration → 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`.
|
||||
*
|
||||
* 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
|
||||
@@ -37,6 +41,12 @@ 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',
|
||||
|
||||
@@ -171,6 +171,42 @@ const messages: TranslationMap = {
|
||||
'nav.activity': 'النشاط',
|
||||
'nav.brain': 'الدماغ',
|
||||
'nav.flows': 'سير العمل',
|
||||
'nav.orchestration': 'التنسيق',
|
||||
'orchPage.subtitle': 'نسّق وكيلك الرئيسي',
|
||||
'orchPage.group.agent': 'الوكيل',
|
||||
'orchPage.group.network': 'الشبكة',
|
||||
'orchPage.group.insights': 'رؤى',
|
||||
'orchPage.agent.nav': 'محادثة',
|
||||
'orchPage.agent.mainTab': 'الوكيل الرئيسي',
|
||||
'orchPage.agent.subconsciousTab': 'اللاوعي',
|
||||
'orchPage.agent.description': 'تحدّث مع الوكيل الرئيسي وراقب لاوعيه',
|
||||
'orchPage.connections.nav': 'الاتصالات',
|
||||
'orchPage.connections.title': 'الوكلاء المرتبطون',
|
||||
'orchPage.connections.description': 'الأقران الذين يمكن لوكيلك الرئيسي التنسيق معهم',
|
||||
'orchPage.connections.empty': 'لا توجد اتصالات بعد.',
|
||||
'orchPage.connections.emptyCta': 'إضافة اتصال',
|
||||
'orchPage.connections.statContacts': 'الاتصالات',
|
||||
'orchPage.connections.statPending': 'قيد الانتظار',
|
||||
'orchPage.connections.pendingHint': 'بانتظار القبول',
|
||||
'orchPage.discover.nav': 'اكتشاف',
|
||||
'orchPage.discover.linkAction': 'إضافة',
|
||||
'orchPage.discover.identityTitle': 'قابليتك للاكتشاف',
|
||||
'orchPage.discover.notDiscoverableGuide':
|
||||
'سجّل @handle حتى يتمكن الوكلاء الآخرون من العثور عليك ومراسلتك.',
|
||||
'orchPage.discover.linkTitle': 'ربط وكيل جديد',
|
||||
'orchPage.discover.linkDescription': 'الصق معرّف وكيل لإرسال طلب اتصال.',
|
||||
'orchPage.discover.noRequests': 'لا توجد طلبات واردة.',
|
||||
'orchPage.usage.nav': 'الاستخدام',
|
||||
'orchPage.usage.connections': 'الاتصالات',
|
||||
'orchPage.usage.balance': 'رصيد الائتمان',
|
||||
'orchPage.usage.balanceHint': 'ترويجي + شحن',
|
||||
'orchPage.usage.cycleSpend': 'إنفاق الدورة',
|
||||
'orchPage.usage.ofBudget': 'من',
|
||||
'orchPage.usage.inferenceCalls': 'استدعاءات النموذج',
|
||||
'orchPage.usage.integrationCalls': 'استدعاءات التكامل',
|
||||
'orchPage.usage.tokensSaved': 'الرموز الموفّرة',
|
||||
'orchPage.usage.saved': 'موفّر',
|
||||
'orchPage.usage.footnote': 'يعكس الاستخدام دورة الفوترة الحالية.',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': 'المحفظة',
|
||||
'agentWorld.description':
|
||||
|
||||
@@ -176,6 +176,42 @@ const messages: TranslationMap = {
|
||||
'nav.activity': 'কার্যকলাপ',
|
||||
'nav.brain': 'ব্রেইন',
|
||||
'nav.flows': 'ওয়ার্কফ্লো',
|
||||
'nav.orchestration': 'অর্কেস্ট্রেশন',
|
||||
'orchPage.subtitle': 'আপনার প্রধান এজেন্ট সমন্বয় করুন',
|
||||
'orchPage.group.agent': 'এজেন্ট',
|
||||
'orchPage.group.network': 'নেটওয়ার্ক',
|
||||
'orchPage.group.insights': 'অন্তর্দৃষ্টি',
|
||||
'orchPage.agent.nav': 'চ্যাট',
|
||||
'orchPage.agent.mainTab': 'প্রধান এজেন্ট',
|
||||
'orchPage.agent.subconsciousTab': 'অবচেতন',
|
||||
'orchPage.agent.description': 'প্রধান এজেন্টের সাথে চ্যাট করুন এবং তার অবচেতন দেখুন',
|
||||
'orchPage.connections.nav': 'সংযোগ',
|
||||
'orchPage.connections.title': 'সংযুক্ত এজেন্ট',
|
||||
'orchPage.connections.description': 'যাদের সাথে আপনার প্রধান এজেন্ট সমন্বয় করতে পারে',
|
||||
'orchPage.connections.empty': 'এখনও কোনো সংযোগ নেই।',
|
||||
'orchPage.connections.emptyCta': 'একটি সংযোগ যোগ করুন',
|
||||
'orchPage.connections.statContacts': 'সংযোগ',
|
||||
'orchPage.connections.statPending': 'অপেক্ষমাণ',
|
||||
'orchPage.connections.pendingHint': 'গ্রহণের অপেক্ষায়',
|
||||
'orchPage.discover.nav': 'আবিষ্কার',
|
||||
'orchPage.discover.linkAction': 'যোগ করুন',
|
||||
'orchPage.discover.identityTitle': 'আপনার আবিষ্কারযোগ্যতা',
|
||||
'orchPage.discover.notDiscoverableGuide':
|
||||
'একটি @handle নিবন্ধন করুন যাতে অন্য এজেন্টরা আপনাকে খুঁজে বার্তা পাঠাতে পারে।',
|
||||
'orchPage.discover.linkTitle': 'নতুন এজেন্ট লিঙ্ক করুন',
|
||||
'orchPage.discover.linkDescription': 'সংযোগের অনুরোধ পাঠাতে একটি এজেন্ট আইডি পেস্ট করুন।',
|
||||
'orchPage.discover.noRequests': 'কোনো আগত অনুরোধ নেই।',
|
||||
'orchPage.usage.nav': 'ব্যবহার',
|
||||
'orchPage.usage.connections': 'সংযোগ',
|
||||
'orchPage.usage.balance': 'ক্রেডিট ব্যালেন্স',
|
||||
'orchPage.usage.balanceHint': 'প্রচারমূলক + টপ-আপ',
|
||||
'orchPage.usage.cycleSpend': 'চক্রের ব্যয়',
|
||||
'orchPage.usage.ofBudget': 'এর',
|
||||
'orchPage.usage.inferenceCalls': 'মডেল কল',
|
||||
'orchPage.usage.integrationCalls': 'ইন্টিগ্রেশন কল',
|
||||
'orchPage.usage.tokensSaved': 'সংরক্ষিত টোকেন',
|
||||
'orchPage.usage.saved': 'সংরক্ষিত',
|
||||
'orchPage.usage.footnote': 'ব্যবহার আপনার বর্তমান বিলিং চক্র প্রতিফলিত করে।',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': 'ওয়ালেট',
|
||||
'agentWorld.description':
|
||||
|
||||
@@ -182,6 +182,42 @@ const messages: TranslationMap = {
|
||||
'nav.activity': 'Aktivität',
|
||||
'nav.brain': 'Gehirn',
|
||||
'nav.flows': 'Workflows',
|
||||
'nav.orchestration': 'Orchestrierung',
|
||||
'orchPage.subtitle': 'Koordiniere deinen Hauptagenten',
|
||||
'orchPage.group.agent': 'Agent',
|
||||
'orchPage.group.network': 'Netzwerk',
|
||||
'orchPage.group.insights': 'Einblicke',
|
||||
'orchPage.agent.nav': 'Chat',
|
||||
'orchPage.agent.mainTab': 'Hauptagent',
|
||||
'orchPage.agent.subconsciousTab': 'Unterbewusstsein',
|
||||
'orchPage.agent.description': 'Chatte mit dem Hauptagenten und beobachte sein Unterbewusstsein',
|
||||
'orchPage.connections.nav': 'Verbindungen',
|
||||
'orchPage.connections.title': 'Verknüpfte Agenten',
|
||||
'orchPage.connections.description': 'Peers, mit denen dein Hauptagent zusammenarbeiten kann',
|
||||
'orchPage.connections.empty': 'Noch keine Verbindungen.',
|
||||
'orchPage.connections.emptyCta': 'Verbindung hinzufügen',
|
||||
'orchPage.connections.statContacts': 'Verbindungen',
|
||||
'orchPage.connections.statPending': 'Ausstehend',
|
||||
'orchPage.connections.pendingHint': 'Warten auf Annahme',
|
||||
'orchPage.discover.nav': 'Entdecken',
|
||||
'orchPage.discover.linkAction': 'Hinzufügen',
|
||||
'orchPage.discover.identityTitle': 'Deine Auffindbarkeit',
|
||||
'orchPage.discover.notDiscoverableGuide':
|
||||
'Registriere ein @handle, damit andere Agenten dich finden und dir schreiben können.',
|
||||
'orchPage.discover.linkTitle': 'Neuen Agenten verknüpfen',
|
||||
'orchPage.discover.linkDescription': 'Füge eine Agenten-ID ein, um eine Verbindungsanfrage zu senden.',
|
||||
'orchPage.discover.noRequests': 'Keine eingehenden Anfragen.',
|
||||
'orchPage.usage.nav': 'Nutzung',
|
||||
'orchPage.usage.connections': 'Verbindungen',
|
||||
'orchPage.usage.balance': 'Guthaben',
|
||||
'orchPage.usage.balanceHint': 'Aktion + Aufladung',
|
||||
'orchPage.usage.cycleSpend': 'Zyklusausgaben',
|
||||
'orchPage.usage.ofBudget': 'von',
|
||||
'orchPage.usage.inferenceCalls': 'Modellaufrufe',
|
||||
'orchPage.usage.integrationCalls': 'Integrationsaufrufe',
|
||||
'orchPage.usage.tokensSaved': 'Gesparte Tokens',
|
||||
'orchPage.usage.saved': 'gespart',
|
||||
'orchPage.usage.footnote': 'Die Nutzung bezieht sich auf deinen aktuellen Abrechnungszeitraum.',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': 'Wallet',
|
||||
'agentWorld.description':
|
||||
|
||||
@@ -26,6 +26,44 @@ const en: TranslationMap = {
|
||||
'nav.activity': 'Activity',
|
||||
'nav.brain': 'Brain',
|
||||
'nav.flows': 'Workflows',
|
||||
'nav.orchestration': 'Orchestration',
|
||||
|
||||
// ── Orchestration sub-pages (orchPage.*) ──────────────────────────────────
|
||||
'orchPage.subtitle': 'Coordinate your main agent',
|
||||
'orchPage.group.agent': 'Agent',
|
||||
'orchPage.group.network': 'Network',
|
||||
'orchPage.group.insights': 'Insights',
|
||||
'orchPage.agent.nav': 'Chat',
|
||||
'orchPage.agent.mainTab': 'Main agent',
|
||||
'orchPage.agent.subconsciousTab': 'Subconscious',
|
||||
'orchPage.agent.description': 'Chat with the main agent and watch its subconscious',
|
||||
'orchPage.connections.nav': 'Connections',
|
||||
'orchPage.connections.title': 'Linked agents',
|
||||
'orchPage.connections.description': 'Peers your main agent can coordinate with',
|
||||
'orchPage.connections.empty': 'No connections yet.',
|
||||
'orchPage.connections.emptyCta': 'Add a connection',
|
||||
'orchPage.connections.statContacts': 'Connections',
|
||||
'orchPage.connections.statPending': 'Pending',
|
||||
'orchPage.connections.pendingHint': 'Awaiting acceptance',
|
||||
'orchPage.discover.nav': 'Discover',
|
||||
'orchPage.discover.linkAction': 'Add',
|
||||
'orchPage.discover.identityTitle': 'Your discoverability',
|
||||
'orchPage.discover.notDiscoverableGuide':
|
||||
'Register a @handle so other agents can find and message you.',
|
||||
'orchPage.discover.linkTitle': 'Link a new agent',
|
||||
'orchPage.discover.linkDescription': 'Paste an agent ID to send a connection request.',
|
||||
'orchPage.discover.noRequests': 'No incoming requests.',
|
||||
'orchPage.usage.nav': 'Usage',
|
||||
'orchPage.usage.connections': 'Connections',
|
||||
'orchPage.usage.balance': 'Credit balance',
|
||||
'orchPage.usage.balanceHint': 'Promotional + top-up',
|
||||
'orchPage.usage.cycleSpend': 'Cycle spend',
|
||||
'orchPage.usage.ofBudget': 'of',
|
||||
'orchPage.usage.inferenceCalls': 'Model calls',
|
||||
'orchPage.usage.integrationCalls': 'Integration calls',
|
||||
'orchPage.usage.tokensSaved': 'Tokens saved',
|
||||
'orchPage.usage.saved': 'saved',
|
||||
'orchPage.usage.footnote': 'Usage reflects your current billing cycle.',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': 'Wallet',
|
||||
// Agent World section sub-navigation labels
|
||||
|
||||
@@ -179,6 +179,42 @@ const messages: TranslationMap = {
|
||||
'nav.activity': 'Actividad',
|
||||
'nav.brain': 'Cerebro',
|
||||
'nav.flows': 'Flujos de trabajo',
|
||||
'nav.orchestration': 'Orquestación',
|
||||
'orchPage.subtitle': 'Coordina tu agente principal',
|
||||
'orchPage.group.agent': 'Agente',
|
||||
'orchPage.group.network': 'Red',
|
||||
'orchPage.group.insights': 'Estadísticas',
|
||||
'orchPage.agent.nav': 'Chat',
|
||||
'orchPage.agent.mainTab': 'Agente principal',
|
||||
'orchPage.agent.subconsciousTab': 'Subconsciente',
|
||||
'orchPage.agent.description': 'Chatea con el agente principal y observa su subconsciente',
|
||||
'orchPage.connections.nav': 'Conexiones',
|
||||
'orchPage.connections.title': 'Agentes vinculados',
|
||||
'orchPage.connections.description': 'Pares con los que tu agente principal puede coordinarse',
|
||||
'orchPage.connections.empty': 'Aún no hay conexiones.',
|
||||
'orchPage.connections.emptyCta': 'Agregar una conexión',
|
||||
'orchPage.connections.statContacts': 'Conexiones',
|
||||
'orchPage.connections.statPending': 'Pendiente',
|
||||
'orchPage.connections.pendingHint': 'Esperando aceptación',
|
||||
'orchPage.discover.nav': 'Descubrir',
|
||||
'orchPage.discover.linkAction': 'Agregar',
|
||||
'orchPage.discover.identityTitle': 'Tu visibilidad',
|
||||
'orchPage.discover.notDiscoverableGuide':
|
||||
'Registra un @handle para que otros agentes puedan encontrarte y escribirte.',
|
||||
'orchPage.discover.linkTitle': 'Vincular un nuevo agente',
|
||||
'orchPage.discover.linkDescription': 'Pega un ID de agente para enviar una solicitud de conexión.',
|
||||
'orchPage.discover.noRequests': 'No hay solicitudes entrantes.',
|
||||
'orchPage.usage.nav': 'Uso',
|
||||
'orchPage.usage.connections': 'Conexiones',
|
||||
'orchPage.usage.balance': 'Saldo de crédito',
|
||||
'orchPage.usage.balanceHint': 'Promocional + recarga',
|
||||
'orchPage.usage.cycleSpend': 'Gasto del ciclo',
|
||||
'orchPage.usage.ofBudget': 'de',
|
||||
'orchPage.usage.inferenceCalls': 'Llamadas al modelo',
|
||||
'orchPage.usage.integrationCalls': 'Llamadas de integración',
|
||||
'orchPage.usage.tokensSaved': 'Tokens ahorrados',
|
||||
'orchPage.usage.saved': 'ahorrado',
|
||||
'orchPage.usage.footnote': 'El uso refleja tu ciclo de facturación actual.',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': 'Cartera',
|
||||
'agentWorld.description':
|
||||
|
||||
@@ -179,6 +179,42 @@ const messages: TranslationMap = {
|
||||
'nav.activity': 'Activité',
|
||||
'nav.brain': 'Cerveau',
|
||||
'nav.flows': 'Workflows',
|
||||
'nav.orchestration': 'Orchestration',
|
||||
'orchPage.subtitle': 'Coordonnez votre agent principal',
|
||||
'orchPage.group.agent': 'Agent',
|
||||
'orchPage.group.network': 'Réseau',
|
||||
'orchPage.group.insights': 'Analyses',
|
||||
'orchPage.agent.nav': 'Chat',
|
||||
'orchPage.agent.mainTab': 'Agent principal',
|
||||
'orchPage.agent.subconsciousTab': 'Subconscient',
|
||||
'orchPage.agent.description': "Discutez avec l'agent principal et observez son subconscient",
|
||||
'orchPage.connections.nav': 'Connexions',
|
||||
'orchPage.connections.title': 'Agents liés',
|
||||
'orchPage.connections.description': 'Pairs avec lesquels votre agent principal peut se coordonner',
|
||||
'orchPage.connections.empty': 'Aucune connexion pour le moment.',
|
||||
'orchPage.connections.emptyCta': 'Ajouter une connexion',
|
||||
'orchPage.connections.statContacts': 'Connexions',
|
||||
'orchPage.connections.statPending': 'En attente',
|
||||
'orchPage.connections.pendingHint': "En attente d'acceptation",
|
||||
'orchPage.discover.nav': 'Découvrir',
|
||||
'orchPage.discover.linkAction': 'Ajouter',
|
||||
'orchPage.discover.identityTitle': 'Votre visibilité',
|
||||
'orchPage.discover.notDiscoverableGuide':
|
||||
"Enregistrez un @handle pour que d'autres agents puissent vous trouver et vous écrire.",
|
||||
'orchPage.discover.linkTitle': 'Lier un nouvel agent',
|
||||
'orchPage.discover.linkDescription': "Collez un ID d'agent pour envoyer une demande de connexion.",
|
||||
'orchPage.discover.noRequests': 'Aucune demande entrante.',
|
||||
'orchPage.usage.nav': 'Utilisation',
|
||||
'orchPage.usage.connections': 'Connexions',
|
||||
'orchPage.usage.balance': 'Solde de crédit',
|
||||
'orchPage.usage.balanceHint': 'Promotionnel + recharge',
|
||||
'orchPage.usage.cycleSpend': 'Dépenses du cycle',
|
||||
'orchPage.usage.ofBudget': 'sur',
|
||||
'orchPage.usage.inferenceCalls': 'Appels au modèle',
|
||||
'orchPage.usage.integrationCalls': "Appels d'intégration",
|
||||
'orchPage.usage.tokensSaved': 'Jetons économisés',
|
||||
'orchPage.usage.saved': 'économisé',
|
||||
'orchPage.usage.footnote': "L'utilisation reflète votre cycle de facturation actuel.",
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': 'Portefeuille',
|
||||
'agentWorld.description':
|
||||
|
||||
@@ -176,6 +176,42 @@ const messages: TranslationMap = {
|
||||
'nav.activity': 'गतिविधि',
|
||||
'nav.brain': 'ब्रेन',
|
||||
'nav.flows': 'वर्कफ़्लो',
|
||||
'nav.orchestration': 'ऑर्केस्ट्रेशन',
|
||||
'orchPage.subtitle': 'अपने मुख्य एजेंट का समन्वय करें',
|
||||
'orchPage.group.agent': 'एजेंट',
|
||||
'orchPage.group.network': 'नेटवर्क',
|
||||
'orchPage.group.insights': 'अंतर्दृष्टि',
|
||||
'orchPage.agent.nav': 'चैट',
|
||||
'orchPage.agent.mainTab': 'मुख्य एजेंट',
|
||||
'orchPage.agent.subconsciousTab': 'अवचेतन',
|
||||
'orchPage.agent.description': 'मुख्य एजेंट से चैट करें और उसका अवचेतन देखें',
|
||||
'orchPage.connections.nav': 'कनेक्शन',
|
||||
'orchPage.connections.title': 'लिंक किए गए एजेंट',
|
||||
'orchPage.connections.description': 'वे साथी जिनके साथ आपका मुख्य एजेंट समन्वय कर सकता है',
|
||||
'orchPage.connections.empty': 'अभी तक कोई कनेक्शन नहीं।',
|
||||
'orchPage.connections.emptyCta': 'कनेक्शन जोड़ें',
|
||||
'orchPage.connections.statContacts': 'कनेक्शन',
|
||||
'orchPage.connections.statPending': 'लंबित',
|
||||
'orchPage.connections.pendingHint': 'स्वीकृति की प्रतीक्षा में',
|
||||
'orchPage.discover.nav': 'खोजें',
|
||||
'orchPage.discover.linkAction': 'जोड़ें',
|
||||
'orchPage.discover.identityTitle': 'आपकी खोज-योग्यता',
|
||||
'orchPage.discover.notDiscoverableGuide':
|
||||
'एक @handle पंजीकृत करें ताकि अन्य एजेंट आपको ढूंढ़ और संदेश भेज सकें।',
|
||||
'orchPage.discover.linkTitle': 'नया एजेंट लिंक करें',
|
||||
'orchPage.discover.linkDescription': 'कनेक्शन अनुरोध भेजने के लिए एजेंट आईडी पेस्ट करें।',
|
||||
'orchPage.discover.noRequests': 'कोई आने वाला अनुरोध नहीं।',
|
||||
'orchPage.usage.nav': 'उपयोग',
|
||||
'orchPage.usage.connections': 'कनेक्शन',
|
||||
'orchPage.usage.balance': 'क्रेडिट शेष',
|
||||
'orchPage.usage.balanceHint': 'प्रचार + टॉप-अप',
|
||||
'orchPage.usage.cycleSpend': 'चक्र व्यय',
|
||||
'orchPage.usage.ofBudget': 'में से',
|
||||
'orchPage.usage.inferenceCalls': 'मॉडल कॉल',
|
||||
'orchPage.usage.integrationCalls': 'एकीकरण कॉल',
|
||||
'orchPage.usage.tokensSaved': 'बचाए गए टोकन',
|
||||
'orchPage.usage.saved': 'बचाया',
|
||||
'orchPage.usage.footnote': 'उपयोग आपके वर्तमान बिलिंग चक्र को दर्शाता है।',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': 'वॉलेट',
|
||||
'agentWorld.description':
|
||||
|
||||
@@ -177,6 +177,42 @@ const messages: TranslationMap = {
|
||||
'nav.activity': 'Aktivitas',
|
||||
'nav.brain': 'Otak',
|
||||
'nav.flows': 'Alur Kerja',
|
||||
'nav.orchestration': 'Orkestrasi',
|
||||
'orchPage.subtitle': 'Koordinasikan agen utama Anda',
|
||||
'orchPage.group.agent': 'Agen',
|
||||
'orchPage.group.network': 'Jaringan',
|
||||
'orchPage.group.insights': 'Wawasan',
|
||||
'orchPage.agent.nav': 'Obrolan',
|
||||
'orchPage.agent.mainTab': 'Agen utama',
|
||||
'orchPage.agent.subconsciousTab': 'Bawah sadar',
|
||||
'orchPage.agent.description': 'Mengobrol dengan agen utama dan amati bawah sadarnya',
|
||||
'orchPage.connections.nav': 'Koneksi',
|
||||
'orchPage.connections.title': 'Agen tertaut',
|
||||
'orchPage.connections.description': 'Rekan yang dapat dikoordinasikan oleh agen utama Anda',
|
||||
'orchPage.connections.empty': 'Belum ada koneksi.',
|
||||
'orchPage.connections.emptyCta': 'Tambahkan koneksi',
|
||||
'orchPage.connections.statContacts': 'Koneksi',
|
||||
'orchPage.connections.statPending': 'Menunggu',
|
||||
'orchPage.connections.pendingHint': 'Menunggu persetujuan',
|
||||
'orchPage.discover.nav': 'Temukan',
|
||||
'orchPage.discover.linkAction': 'Tambah',
|
||||
'orchPage.discover.identityTitle': 'Keterlihatan Anda',
|
||||
'orchPage.discover.notDiscoverableGuide':
|
||||
'Daftarkan @handle agar agen lain dapat menemukan dan mengirim pesan kepada Anda.',
|
||||
'orchPage.discover.linkTitle': 'Tautkan agen baru',
|
||||
'orchPage.discover.linkDescription': 'Tempel ID agen untuk mengirim permintaan koneksi.',
|
||||
'orchPage.discover.noRequests': 'Tidak ada permintaan masuk.',
|
||||
'orchPage.usage.nav': 'Penggunaan',
|
||||
'orchPage.usage.connections': 'Koneksi',
|
||||
'orchPage.usage.balance': 'Saldo kredit',
|
||||
'orchPage.usage.balanceHint': 'Promosi + isi ulang',
|
||||
'orchPage.usage.cycleSpend': 'Pengeluaran siklus',
|
||||
'orchPage.usage.ofBudget': 'dari',
|
||||
'orchPage.usage.inferenceCalls': 'Panggilan model',
|
||||
'orchPage.usage.integrationCalls': 'Panggilan integrasi',
|
||||
'orchPage.usage.tokensSaved': 'Token dihemat',
|
||||
'orchPage.usage.saved': 'dihemat',
|
||||
'orchPage.usage.footnote': 'Penggunaan mencerminkan siklus penagihan Anda saat ini.',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': 'Dompet',
|
||||
'agentWorld.description':
|
||||
|
||||
@@ -179,6 +179,42 @@ const messages: TranslationMap = {
|
||||
'nav.activity': 'Attività',
|
||||
'nav.brain': 'Cervello',
|
||||
'nav.flows': 'Flussi di lavoro',
|
||||
'nav.orchestration': 'Orchestrazione',
|
||||
'orchPage.subtitle': 'Coordina il tuo agente principale',
|
||||
'orchPage.group.agent': 'Agente',
|
||||
'orchPage.group.network': 'Rete',
|
||||
'orchPage.group.insights': 'Approfondimenti',
|
||||
'orchPage.agent.nav': 'Chat',
|
||||
'orchPage.agent.mainTab': 'Agente principale',
|
||||
'orchPage.agent.subconsciousTab': 'Subconscio',
|
||||
'orchPage.agent.description': "Chatta con l'agente principale e osserva il suo subconscio",
|
||||
'orchPage.connections.nav': 'Connessioni',
|
||||
'orchPage.connections.title': 'Agenti collegati',
|
||||
'orchPage.connections.description': 'Peer con cui il tuo agente principale può coordinarsi',
|
||||
'orchPage.connections.empty': 'Ancora nessuna connessione.',
|
||||
'orchPage.connections.emptyCta': 'Aggiungi una connessione',
|
||||
'orchPage.connections.statContacts': 'Connessioni',
|
||||
'orchPage.connections.statPending': 'In sospeso',
|
||||
'orchPage.connections.pendingHint': 'In attesa di accettazione',
|
||||
'orchPage.discover.nav': 'Scopri',
|
||||
'orchPage.discover.linkAction': 'Aggiungi',
|
||||
'orchPage.discover.identityTitle': 'La tua reperibilità',
|
||||
'orchPage.discover.notDiscoverableGuide':
|
||||
'Registra un @handle così altri agenti possono trovarti e scriverti.',
|
||||
'orchPage.discover.linkTitle': 'Collega un nuovo agente',
|
||||
'orchPage.discover.linkDescription': 'Incolla un ID agente per inviare una richiesta di connessione.',
|
||||
'orchPage.discover.noRequests': 'Nessuna richiesta in arrivo.',
|
||||
'orchPage.usage.nav': 'Utilizzo',
|
||||
'orchPage.usage.connections': 'Connessioni',
|
||||
'orchPage.usage.balance': 'Saldo credito',
|
||||
'orchPage.usage.balanceHint': 'Promozionale + ricarica',
|
||||
'orchPage.usage.cycleSpend': 'Spesa del ciclo',
|
||||
'orchPage.usage.ofBudget': 'di',
|
||||
'orchPage.usage.inferenceCalls': 'Chiamate al modello',
|
||||
'orchPage.usage.integrationCalls': 'Chiamate di integrazione',
|
||||
'orchPage.usage.tokensSaved': 'Token risparmiati',
|
||||
'orchPage.usage.saved': 'risparmiato',
|
||||
'orchPage.usage.footnote': "L'utilizzo riflette il tuo ciclo di fatturazione attuale.",
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': 'Portafoglio',
|
||||
'agentWorld.description':
|
||||
|
||||
@@ -173,6 +173,42 @@ const messages: TranslationMap = {
|
||||
'nav.activity': '활동',
|
||||
'nav.brain': '브레인',
|
||||
'nav.flows': '워크플로',
|
||||
'nav.orchestration': '오케스트레이션',
|
||||
'orchPage.subtitle': '메인 에이전트를 조율하세요',
|
||||
'orchPage.group.agent': '에이전트',
|
||||
'orchPage.group.network': '네트워크',
|
||||
'orchPage.group.insights': '인사이트',
|
||||
'orchPage.agent.nav': '채팅',
|
||||
'orchPage.agent.mainTab': '메인 에이전트',
|
||||
'orchPage.agent.subconsciousTab': '잠재의식',
|
||||
'orchPage.agent.description': '메인 에이전트와 대화하고 잠재의식을 지켜보세요',
|
||||
'orchPage.connections.nav': '연결',
|
||||
'orchPage.connections.title': '연결된 에이전트',
|
||||
'orchPage.connections.description': '메인 에이전트가 협력할 수 있는 상대',
|
||||
'orchPage.connections.empty': '아직 연결이 없습니다.',
|
||||
'orchPage.connections.emptyCta': '연결 추가',
|
||||
'orchPage.connections.statContacts': '연결',
|
||||
'orchPage.connections.statPending': '대기 중',
|
||||
'orchPage.connections.pendingHint': '수락 대기 중',
|
||||
'orchPage.discover.nav': '탐색',
|
||||
'orchPage.discover.linkAction': '추가',
|
||||
'orchPage.discover.identityTitle': '검색 가능성',
|
||||
'orchPage.discover.notDiscoverableGuide':
|
||||
'다른 에이전트가 찾아 메시지를 보낼 수 있도록 @handle을 등록하세요.',
|
||||
'orchPage.discover.linkTitle': '새 에이전트 연결',
|
||||
'orchPage.discover.linkDescription': '연결 요청을 보내려면 에이전트 ID를 붙여넣으세요.',
|
||||
'orchPage.discover.noRequests': '수신된 요청이 없습니다.',
|
||||
'orchPage.usage.nav': '사용량',
|
||||
'orchPage.usage.connections': '연결',
|
||||
'orchPage.usage.balance': '크레딧 잔액',
|
||||
'orchPage.usage.balanceHint': '프로모션 + 충전',
|
||||
'orchPage.usage.cycleSpend': '주기 지출',
|
||||
'orchPage.usage.ofBudget': '중',
|
||||
'orchPage.usage.inferenceCalls': '모델 호출',
|
||||
'orchPage.usage.integrationCalls': '통합 호출',
|
||||
'orchPage.usage.tokensSaved': '절약된 토큰',
|
||||
'orchPage.usage.saved': '절약',
|
||||
'orchPage.usage.footnote': '사용량은 현재 청구 주기를 반영합니다.',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': '지갑',
|
||||
'agentWorld.description':
|
||||
|
||||
@@ -181,6 +181,42 @@ const messages: TranslationMap = {
|
||||
'nav.activity': 'Aktywność',
|
||||
'nav.brain': 'Mózg',
|
||||
'nav.flows': 'Przepływy pracy',
|
||||
'nav.orchestration': 'Orkiestracja',
|
||||
'orchPage.subtitle': 'Koordynuj swojego głównego agenta',
|
||||
'orchPage.group.agent': 'Agent',
|
||||
'orchPage.group.network': 'Sieć',
|
||||
'orchPage.group.insights': 'Statystyki',
|
||||
'orchPage.agent.nav': 'Czat',
|
||||
'orchPage.agent.mainTab': 'Główny agent',
|
||||
'orchPage.agent.subconsciousTab': 'Podświadomość',
|
||||
'orchPage.agent.description': 'Rozmawiaj z głównym agentem i obserwuj jego podświadomość',
|
||||
'orchPage.connections.nav': 'Połączenia',
|
||||
'orchPage.connections.title': 'Połączeni agenci',
|
||||
'orchPage.connections.description': 'Partnerzy, z którymi może współpracować Twój główny agent',
|
||||
'orchPage.connections.empty': 'Brak połączeń.',
|
||||
'orchPage.connections.emptyCta': 'Dodaj połączenie',
|
||||
'orchPage.connections.statContacts': 'Połączenia',
|
||||
'orchPage.connections.statPending': 'Oczekujące',
|
||||
'orchPage.connections.pendingHint': 'Oczekiwanie na akceptację',
|
||||
'orchPage.discover.nav': 'Odkrywaj',
|
||||
'orchPage.discover.linkAction': 'Dodaj',
|
||||
'orchPage.discover.identityTitle': 'Twoja wykrywalność',
|
||||
'orchPage.discover.notDiscoverableGuide':
|
||||
'Zarejestruj @handle, aby inni agenci mogli Cię znaleźć i napisać do Ciebie.',
|
||||
'orchPage.discover.linkTitle': 'Połącz nowego agenta',
|
||||
'orchPage.discover.linkDescription': 'Wklej identyfikator agenta, aby wysłać prośbę o połączenie.',
|
||||
'orchPage.discover.noRequests': 'Brak przychodzących próśb.',
|
||||
'orchPage.usage.nav': 'Wykorzystanie',
|
||||
'orchPage.usage.connections': 'Połączenia',
|
||||
'orchPage.usage.balance': 'Saldo kredytu',
|
||||
'orchPage.usage.balanceHint': 'Promocja + doładowanie',
|
||||
'orchPage.usage.cycleSpend': 'Wydatki w cyklu',
|
||||
'orchPage.usage.ofBudget': 'z',
|
||||
'orchPage.usage.inferenceCalls': 'Wywołania modelu',
|
||||
'orchPage.usage.integrationCalls': 'Wywołania integracji',
|
||||
'orchPage.usage.tokensSaved': 'Zaoszczędzone tokeny',
|
||||
'orchPage.usage.saved': 'zaoszczędzono',
|
||||
'orchPage.usage.footnote': 'Wykorzystanie odzwierciedla bieżący cykl rozliczeniowy.',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': 'Portfel',
|
||||
'agentWorld.description':
|
||||
|
||||
@@ -178,6 +178,42 @@ const messages: TranslationMap = {
|
||||
'nav.activity': 'Atividade',
|
||||
'nav.brain': 'Cérebro',
|
||||
'nav.flows': 'Fluxos de trabalho',
|
||||
'nav.orchestration': 'Orquestração',
|
||||
'orchPage.subtitle': 'Coordene seu agente principal',
|
||||
'orchPage.group.agent': 'Agente',
|
||||
'orchPage.group.network': 'Rede',
|
||||
'orchPage.group.insights': 'Insights',
|
||||
'orchPage.agent.nav': 'Chat',
|
||||
'orchPage.agent.mainTab': 'Agente principal',
|
||||
'orchPage.agent.subconsciousTab': 'Subconsciente',
|
||||
'orchPage.agent.description': 'Converse com o agente principal e observe seu subconsciente',
|
||||
'orchPage.connections.nav': 'Conexões',
|
||||
'orchPage.connections.title': 'Agentes vinculados',
|
||||
'orchPage.connections.description': 'Pares com os quais seu agente principal pode se coordenar',
|
||||
'orchPage.connections.empty': 'Ainda não há conexões.',
|
||||
'orchPage.connections.emptyCta': 'Adicionar uma conexão',
|
||||
'orchPage.connections.statContacts': 'Conexões',
|
||||
'orchPage.connections.statPending': 'Pendente',
|
||||
'orchPage.connections.pendingHint': 'Aguardando aceitação',
|
||||
'orchPage.discover.nav': 'Descobrir',
|
||||
'orchPage.discover.linkAction': 'Adicionar',
|
||||
'orchPage.discover.identityTitle': 'Sua visibilidade',
|
||||
'orchPage.discover.notDiscoverableGuide':
|
||||
'Registre um @handle para que outros agentes possam encontrá-lo e enviar mensagens.',
|
||||
'orchPage.discover.linkTitle': 'Vincular um novo agente',
|
||||
'orchPage.discover.linkDescription': 'Cole um ID de agente para enviar uma solicitação de conexão.',
|
||||
'orchPage.discover.noRequests': 'Nenhuma solicitação recebida.',
|
||||
'orchPage.usage.nav': 'Uso',
|
||||
'orchPage.usage.connections': 'Conexões',
|
||||
'orchPage.usage.balance': 'Saldo de crédito',
|
||||
'orchPage.usage.balanceHint': 'Promocional + recarga',
|
||||
'orchPage.usage.cycleSpend': 'Gasto do ciclo',
|
||||
'orchPage.usage.ofBudget': 'de',
|
||||
'orchPage.usage.inferenceCalls': 'Chamadas ao modelo',
|
||||
'orchPage.usage.integrationCalls': 'Chamadas de integração',
|
||||
'orchPage.usage.tokensSaved': 'Tokens economizados',
|
||||
'orchPage.usage.saved': 'economizado',
|
||||
'orchPage.usage.footnote': 'O uso reflete seu ciclo de faturamento atual.',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': 'Carteira',
|
||||
'agentWorld.description':
|
||||
|
||||
@@ -181,6 +181,42 @@ const messages: TranslationMap = {
|
||||
'nav.activity': 'Активность',
|
||||
'nav.brain': 'Мозг',
|
||||
'nav.flows': 'Рабочие процессы',
|
||||
'nav.orchestration': 'Оркестрация',
|
||||
'orchPage.subtitle': 'Координируйте вашего главного агента',
|
||||
'orchPage.group.agent': 'Агент',
|
||||
'orchPage.group.network': 'Сеть',
|
||||
'orchPage.group.insights': 'Аналитика',
|
||||
'orchPage.agent.nav': 'Чат',
|
||||
'orchPage.agent.mainTab': 'Главный агент',
|
||||
'orchPage.agent.subconsciousTab': 'Подсознание',
|
||||
'orchPage.agent.description': 'Общайтесь с главным агентом и наблюдайте за его подсознанием',
|
||||
'orchPage.connections.nav': 'Связи',
|
||||
'orchPage.connections.title': 'Связанные агенты',
|
||||
'orchPage.connections.description': 'Партнёры, с которыми может координироваться ваш главный агент',
|
||||
'orchPage.connections.empty': 'Пока нет связей.',
|
||||
'orchPage.connections.emptyCta': 'Добавить связь',
|
||||
'orchPage.connections.statContacts': 'Связи',
|
||||
'orchPage.connections.statPending': 'Ожидание',
|
||||
'orchPage.connections.pendingHint': 'Ожидает подтверждения',
|
||||
'orchPage.discover.nav': 'Обзор',
|
||||
'orchPage.discover.linkAction': 'Добавить',
|
||||
'orchPage.discover.identityTitle': 'Ваша видимость',
|
||||
'orchPage.discover.notDiscoverableGuide':
|
||||
'Зарегистрируйте @handle, чтобы другие агенты могли найти вас и написать.',
|
||||
'orchPage.discover.linkTitle': 'Связать нового агента',
|
||||
'orchPage.discover.linkDescription': 'Вставьте ID агента, чтобы отправить запрос на связь.',
|
||||
'orchPage.discover.noRequests': 'Нет входящих запросов.',
|
||||
'orchPage.usage.nav': 'Использование',
|
||||
'orchPage.usage.connections': 'Связи',
|
||||
'orchPage.usage.balance': 'Баланс кредитов',
|
||||
'orchPage.usage.balanceHint': 'Промо + пополнение',
|
||||
'orchPage.usage.cycleSpend': 'Расходы за цикл',
|
||||
'orchPage.usage.ofBudget': 'из',
|
||||
'orchPage.usage.inferenceCalls': 'Вызовы модели',
|
||||
'orchPage.usage.integrationCalls': 'Вызовы интеграций',
|
||||
'orchPage.usage.tokensSaved': 'Сэкономлено токенов',
|
||||
'orchPage.usage.saved': 'сэкономлено',
|
||||
'orchPage.usage.footnote': 'Использование отражает ваш текущий расчётный период.',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': 'Кошелёк',
|
||||
'agentWorld.description':
|
||||
|
||||
@@ -159,6 +159,42 @@ const messages: TranslationMap = {
|
||||
'nav.activity': '动态',
|
||||
'nav.brain': '大脑',
|
||||
'nav.flows': '工作流',
|
||||
'nav.orchestration': '编排',
|
||||
'orchPage.subtitle': '协调你的主智能体',
|
||||
'orchPage.group.agent': '智能体',
|
||||
'orchPage.group.network': '网络',
|
||||
'orchPage.group.insights': '洞察',
|
||||
'orchPage.agent.nav': '聊天',
|
||||
'orchPage.agent.mainTab': '主智能体',
|
||||
'orchPage.agent.subconsciousTab': '潜意识',
|
||||
'orchPage.agent.description': '与主智能体聊天并观察其潜意识',
|
||||
'orchPage.connections.nav': '连接',
|
||||
'orchPage.connections.title': '已关联智能体',
|
||||
'orchPage.connections.description': '你的主智能体可以协调的伙伴',
|
||||
'orchPage.connections.empty': '暂无连接。',
|
||||
'orchPage.connections.emptyCta': '添加连接',
|
||||
'orchPage.connections.statContacts': '连接',
|
||||
'orchPage.connections.statPending': '待处理',
|
||||
'orchPage.connections.pendingHint': '等待接受',
|
||||
'orchPage.discover.nav': '发现',
|
||||
'orchPage.discover.linkAction': '添加',
|
||||
'orchPage.discover.identityTitle': '你的可发现性',
|
||||
'orchPage.discover.notDiscoverableGuide':
|
||||
'注册一个 @handle,以便其他智能体可以找到你并给你发消息。',
|
||||
'orchPage.discover.linkTitle': '关联新智能体',
|
||||
'orchPage.discover.linkDescription': '粘贴智能体 ID 以发送连接请求。',
|
||||
'orchPage.discover.noRequests': '没有传入请求。',
|
||||
'orchPage.usage.nav': '用量',
|
||||
'orchPage.usage.connections': '连接',
|
||||
'orchPage.usage.balance': '信用余额',
|
||||
'orchPage.usage.balanceHint': '促销 + 充值',
|
||||
'orchPage.usage.cycleSpend': '周期支出',
|
||||
'orchPage.usage.ofBudget': '/',
|
||||
'orchPage.usage.inferenceCalls': '模型调用',
|
||||
'orchPage.usage.integrationCalls': '集成调用',
|
||||
'orchPage.usage.tokensSaved': '已节省令牌',
|
||||
'orchPage.usage.saved': '已节省',
|
||||
'orchPage.usage.footnote': '用量反映你当前的计费周期。',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': '钱包',
|
||||
'agentWorld.description':
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { PaymentRequiredError } from '../../agentworld/invokeApiClient';
|
||||
import { usePairing } from '../usePairing';
|
||||
|
||||
const listMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../../agentworld/AgentWorldShell', () => ({
|
||||
apiClient: { orchestrationPairing: { list: listMock } },
|
||||
}));
|
||||
|
||||
const snapshot = {
|
||||
records: [],
|
||||
contacts: { contacts: [] },
|
||||
requests: { incoming: [], outgoing: [] },
|
||||
stats: { agentId: 'me', contactCount: 2, pendingIncoming: 1, pendingOutgoing: 0 },
|
||||
};
|
||||
|
||||
describe('usePairing', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('loads a snapshot on mount', async () => {
|
||||
listMock.mockResolvedValue(snapshot);
|
||||
const { result } = renderHook(() => usePairing());
|
||||
await waitFor(() => expect(result.current.state.status).toBe('ok'));
|
||||
expect(listMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('maps a PaymentRequiredError to payment_required', async () => {
|
||||
listMock.mockRejectedValue(new PaymentRequiredError({}));
|
||||
const { result } = renderHook(() => usePairing());
|
||||
await waitFor(() => expect(result.current.state.status).toBe('payment_required'));
|
||||
});
|
||||
|
||||
it('surfaces a generic error', async () => {
|
||||
listMock.mockRejectedValue(new Error('boom'));
|
||||
const { result } = renderHook(() => usePairing());
|
||||
await waitFor(() => expect(result.current.state.status).toBe('error'));
|
||||
});
|
||||
|
||||
it('runs an action then reloads', async () => {
|
||||
listMock.mockResolvedValue(snapshot);
|
||||
const { result } = renderHook(() => usePairing());
|
||||
await waitFor(() => expect(result.current.state.status).toBe('ok'));
|
||||
const action = vi.fn().mockResolvedValue(undefined);
|
||||
await act(async () => {
|
||||
await result.current.runAction('block:x', action);
|
||||
});
|
||||
expect(action).toHaveBeenCalledTimes(1);
|
||||
expect(listMock).toHaveBeenCalledTimes(2); // initial + reload
|
||||
expect(result.current.actionError).toBeNull();
|
||||
});
|
||||
|
||||
it('captures an action error without reloading', async () => {
|
||||
listMock.mockResolvedValue(snapshot);
|
||||
const { result } = renderHook(() => usePairing());
|
||||
await waitFor(() => expect(result.current.state.status).toBe('ok'));
|
||||
await act(async () => {
|
||||
await result.current.runAction('block:x', () => Promise.reject(new Error('nope')));
|
||||
});
|
||||
expect(result.current.actionError).toBe('nope');
|
||||
expect(listMock).toHaveBeenCalledTimes(1); // no reload after failure
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Shared pairing data hook for the Orchestration sub-pages.
|
||||
*
|
||||
* Wraps `apiClient.orchestrationPairing.*` (the tiny.place agent contacts graph)
|
||||
* so the Connections, Discover, and Usage panels all read one consistent
|
||||
* snapshot and run mutating actions (link / accept / decline / block) through the
|
||||
* same load→act→reload lifecycle. Kept separate from `useOrchestrationChats`
|
||||
* (which owns the chat transcript surface) so a page can pull in only what it
|
||||
* needs.
|
||||
*/
|
||||
import debugFactory from 'debug';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { apiClient } from '../../agentworld/AgentWorldShell';
|
||||
import { type PairingSnapshot, PaymentRequiredError } from '../agentworld/invokeApiClient';
|
||||
|
||||
const debug = debugFactory('orchestration:pairing');
|
||||
|
||||
export type PairingState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'payment_required' }
|
||||
| { status: 'ok'; snapshot: PairingSnapshot };
|
||||
|
||||
export interface UsePairingResult {
|
||||
state: PairingState;
|
||||
/** Re-fetch the pairing snapshot. */
|
||||
reload: () => Promise<void>;
|
||||
/** Run a mutating pairing action, then reload; tracks pending id + error. */
|
||||
runAction: (actionId: string, fn: () => Promise<unknown>) => Promise<void>;
|
||||
/** The id passed to the currently in-flight `runAction`, or null. */
|
||||
pendingAction: string | null;
|
||||
/** The last action error message, cleared when a new action starts. */
|
||||
actionError: string | null;
|
||||
}
|
||||
|
||||
export function usePairing(): UsePairingResult {
|
||||
const [state, setState] = useState<PairingState>({ status: 'loading' });
|
||||
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
debug('reload: entry');
|
||||
setState(prev => (prev.status === 'ok' ? prev : { status: 'loading' }));
|
||||
try {
|
||||
const snapshot = await apiClient.orchestrationPairing.list();
|
||||
if (!mountedRef.current) return;
|
||||
debug(
|
||||
'reload: ok contacts=%d incoming=%d outgoing=%d',
|
||||
snapshot.contacts.contacts.length,
|
||||
snapshot.requests.incoming.length,
|
||||
snapshot.requests.outgoing.length
|
||||
);
|
||||
setState({ status: 'ok', snapshot });
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
if (error instanceof PaymentRequiredError) {
|
||||
debug('reload: payment_required');
|
||||
setState({ status: 'payment_required' });
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
debug('reload: error %s', message);
|
||||
setState({ status: 'error', message });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runAction = useCallback(
|
||||
async (actionId: string, fn: () => Promise<unknown>) => {
|
||||
debug('runAction: entry id=%s', actionId);
|
||||
setPendingAction(actionId);
|
||||
setActionError(null);
|
||||
try {
|
||||
await fn();
|
||||
if (!mountedRef.current) return;
|
||||
debug('runAction: success id=%s', actionId);
|
||||
await reload();
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
debug('runAction: error id=%s %s', actionId, message);
|
||||
setActionError(message);
|
||||
} finally {
|
||||
if (mountedRef.current) setPendingAction(null);
|
||||
}
|
||||
},
|
||||
[reload]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
const handle = window.setTimeout(() => void reload(), 0);
|
||||
return () => {
|
||||
window.clearTimeout(handle);
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
return { state, reload, runAction, pendingAction, actionError };
|
||||
}
|
||||
+10
-25
@@ -15,7 +15,6 @@ import { MemoryGraph } from '../components/intelligence/MemoryGraph';
|
||||
import { MemorySourcesRegistry } from '../components/intelligence/MemorySourcesRegistry';
|
||||
import { MemoryTreeStatusPanel } from '../components/intelligence/MemoryTreeStatusPanel';
|
||||
import SubconsciousTriggersPanel from '../components/intelligence/SubconsciousTriggersPanel';
|
||||
import TinyPlaceOrchestrationTab from '../components/intelligence/TinyPlaceOrchestrationTab';
|
||||
import { ToastContainer } from '../components/intelligence/Toast';
|
||||
import PanelPage from '../components/layout/PanelPage';
|
||||
import { SidebarContent } from '../components/layout/shell/SidebarSlot';
|
||||
@@ -45,7 +44,6 @@ type BrainTab =
|
||||
| 'memory-data'
|
||||
| 'memory-debug'
|
||||
| 'analysis-views'
|
||||
| 'tinyplace-orchestration'
|
||||
| 'subconscious';
|
||||
|
||||
/** Tabs that render a relocated settings panel (Knowledge & Memory group). */
|
||||
@@ -72,7 +70,6 @@ const BRAIN_TABS: readonly BrainTab[] = [
|
||||
'memory-data',
|
||||
'memory-debug',
|
||||
'analysis-views',
|
||||
'tinyplace-orchestration',
|
||||
'subconscious',
|
||||
];
|
||||
|
||||
@@ -94,6 +91,15 @@ 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.
|
||||
useEffect(() => {
|
||||
if (new URLSearchParams(location.search).get('tab') === 'tinyplace-orchestration') {
|
||||
console.debug('[brain] legacy tinyplace-orchestration deep link → /orchestration');
|
||||
navigate('/orchestration', { replace: true });
|
||||
}
|
||||
}, [location.search, navigate]);
|
||||
|
||||
const [graph, setGraph] = useState<GraphExportResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<GraphMode>('tree');
|
||||
@@ -229,18 +235,6 @@ export default function Brain() {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('memory.tab.orchestration'),
|
||||
items: [
|
||||
{
|
||||
value: 'tinyplace-orchestration',
|
||||
label: t('brain.tabs.tinyplaceOrchestration'),
|
||||
icon: navIcon(
|
||||
'M8 10h.01M12 10h.01M16 10h.01M21 12c0 4.418-4.03 8-9 8a9.77 9.77 0 01-4-.82L3 20l1.3-3.9A7.44 7.44 0 013 12c0-4.418 4.03-8 9-8s9 3.582 9 8z'
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{
|
||||
@@ -283,10 +277,7 @@ export default function Brain() {
|
||||
// Bespoke tabs share the standard scaffold: a single scrolling body,
|
||||
// all custom controls live inside it.
|
||||
<PanelPage contentClassName="p-4">
|
||||
<div
|
||||
className={`mx-auto space-y-5 ${
|
||||
activeTab === 'tinyplace-orchestration' ? 'max-w-5xl' : 'max-w-3xl'
|
||||
}`}>
|
||||
<div className="mx-auto max-w-3xl space-y-5">
|
||||
{activeTab === 'graph' && (
|
||||
<div className="space-y-5 animate-fade-up">
|
||||
<MemoryControls
|
||||
@@ -330,12 +321,6 @@ export default function Brain() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'tinyplace-orchestration' && (
|
||||
<div className="animate-fade-up">
|
||||
<TinyPlaceOrchestrationTab />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'subconscious' && (
|
||||
<div className="space-y-3 animate-fade-up">
|
||||
<BetaBanner />
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* OrchestrationPage — the TinyPlace multi-agent orchestration surface.
|
||||
*
|
||||
* Promoted out of Brain into a first-class sidebar destination (`/orchestration`),
|
||||
* it now fans out into four sub-pages projected into the shell's dynamic sidebar
|
||||
* region (like Brain), driven by `?tab=`:
|
||||
*
|
||||
* - **agent** — chat with the main agent + its subconscious steering loop
|
||||
* - **connections** — manage the agent's accepted peer connections
|
||||
* - **discover** — grow the network: own discoverability, link + inbound requests
|
||||
* - **usage** — stats: connections, credit balance, spend, token savings
|
||||
*/
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import PanelPage from '../components/layout/PanelPage';
|
||||
import { SidebarContent } from '../components/layout/shell/SidebarSlot';
|
||||
import TwoPaneNav from '../components/layout/TwoPaneNav';
|
||||
import AgentChatPanel from '../components/orchestration/AgentChatPanel';
|
||||
import ConnectionsPanel from '../components/orchestration/ConnectionsPanel';
|
||||
import DiscoverPanel from '../components/orchestration/DiscoverPanel';
|
||||
import UsagePanel from '../components/orchestration/UsagePanel';
|
||||
import { useT } from '../lib/i18n/I18nContext';
|
||||
|
||||
type OrchestrationTab = 'agent' | 'connections' | 'discover' | 'usage';
|
||||
|
||||
const ORCHESTRATION_TABS: readonly OrchestrationTab[] = [
|
||||
'agent',
|
||||
'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 activeTab = useMemo<OrchestrationTab>(() => {
|
||||
const raw = new URLSearchParams(location.search).get('tab');
|
||||
return (ORCHESTRATION_TABS as readonly string[]).includes(raw ?? '')
|
||||
? (raw as OrchestrationTab)
|
||||
: 'agent';
|
||||
}, [location.search]);
|
||||
|
||||
const setActiveTab = useCallback(
|
||||
(tab: OrchestrationTab) => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set('tab', tab);
|
||||
navigate({ pathname: location.pathname, search: `?${params.toString()}` });
|
||||
},
|
||||
[location.pathname, location.search, navigate]
|
||||
);
|
||||
|
||||
console.debug('[orchestration] page mount tab=%s', activeTab);
|
||||
|
||||
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={[
|
||||
{
|
||||
label: t('orchPage.group.agent'),
|
||||
items: [
|
||||
{
|
||||
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'
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('orchPage.group.network'),
|
||||
items: [
|
||||
{
|
||||
value: 'connections',
|
||||
label: t('orchPage.connections.nav'),
|
||||
icon: navIcon('M13 10V3L4 14h7v7l9-11h-7z M17 8a3 3 0 100-6 3 3 0 000 6z'),
|
||||
},
|
||||
{
|
||||
value: 'discover',
|
||||
label: t('orchPage.discover.nav'),
|
||||
icon: navIcon('M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('orchPage.group.insights'),
|
||||
items: [
|
||||
{
|
||||
value: 'usage',
|
||||
label: t('orchPage.usage.nav'),
|
||||
icon: navIcon(
|
||||
'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z'
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
header={
|
||||
<p className="min-w-0 text-[11px] text-content-muted">{t('orchPage.subtitle')}</p>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SidebarContent>
|
||||
|
||||
<div className="mx-auto h-full w-full max-w-5xl">
|
||||
{activeTab === 'agent' ? (
|
||||
// The chat panel manages its own full-height layout + scroll.
|
||||
<div className="h-full p-4">
|
||||
<AgentChatPanel />
|
||||
</div>
|
||||
) : (
|
||||
<PanelPage contentClassName="p-4">
|
||||
<div className="mx-auto max-w-3xl animate-fade-up">
|
||||
{activeTab === 'connections' && (
|
||||
<ConnectionsPanel onDiscover={() => setActiveTab('discover')} />
|
||||
)}
|
||||
{activeTab === 'discover' && <DiscoverPanel />}
|
||||
{activeTab === 'usage' && <UsagePanel />}
|
||||
</div>
|
||||
</PanelPage>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,14 @@ const graphExportMock = vi.hoisted(() => vi.fn());
|
||||
// Controllable authenticated identity so we can simulate a logout→login cycle
|
||||
// (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.
|
||||
const navigateSpy = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('react-router-dom', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('react-router-dom')>();
|
||||
return { ...actual, useNavigate: () => navigateSpy };
|
||||
});
|
||||
|
||||
vi.mock('../../utils/tauriCommands', () => ({
|
||||
memoryTreeGraphExport: graphExportMock,
|
||||
@@ -95,13 +103,6 @@ vi.mock('../../components/settings/layout/SettingsLayoutContext', async () => {
|
||||
React.createElement(React.Fragment, null, children),
|
||||
};
|
||||
});
|
||||
vi.mock('../../components/intelligence/TinyPlaceOrchestrationTab', async () => {
|
||||
const React = await import('react');
|
||||
return {
|
||||
default: () => React.createElement('div', { 'data-testid': 'brain-tinyplace-orchestration' }),
|
||||
};
|
||||
});
|
||||
|
||||
const makeGraph = (n: number) => ({
|
||||
nodes: Array.from({ length: n }, (_, i) => ({ id: `n${i}`, kind: 'summary', label: `N${i}` })),
|
||||
edges: [],
|
||||
@@ -182,7 +183,6 @@ describe('Brain page', () => {
|
||||
['analysis-views', 'brain-analysis-views'],
|
||||
['sources', 'brain-sources'],
|
||||
['sync', 'brain-sync'],
|
||||
['tinyplace-orchestration', 'brain-tinyplace-orchestration'],
|
||||
['subconscious', 'brain-subconscious'],
|
||||
])('renders the %s tab', async (tab, testId) => {
|
||||
graphExportMock.mockResolvedValue(makeGraph(0));
|
||||
@@ -193,4 +193,14 @@ describe('Brain page', () => {
|
||||
expect(screen.getByTestId(testId)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects the legacy tinyplace-orchestration deep link to /orchestration', async () => {
|
||||
graphExportMock.mockResolvedValue(makeGraph(0));
|
||||
await act(async () => {
|
||||
renderWithProviders(<Brain />, { initialEntries: ['/?tab=tinyplace-orchestration'] });
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(navigateSpy).toHaveBeenCalledWith('/orchestration', { replace: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { 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 }) }));
|
||||
|
||||
// Stub the four data-backed panels so the shell's tab routing is tested in
|
||||
// isolation (the panels have their own unit tests).
|
||||
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" />,
|
||||
}));
|
||||
|
||||
describe('OrchestrationPage shell', () => {
|
||||
it('defaults to the agent chat panel', async () => {
|
||||
await act(async () => {
|
||||
renderWithProviders(<OrchestrationPage />, { initialEntries: ['/orchestration'] });
|
||||
});
|
||||
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.
|
||||
const usageNav = await screen.findByTestId('two-pane-nav-usage');
|
||||
await act(async () => {
|
||||
fireEvent.click(usageNav);
|
||||
});
|
||||
await waitFor(() => expect(screen.getByTestId('panel-usage')).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());
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,7 @@ const ROUTES: Route[] = [
|
||||
{ hash: '/settings' },
|
||||
{ hash: '/agent-world' },
|
||||
{ hash: '/flows' },
|
||||
{ hash: '/orchestration' },
|
||||
];
|
||||
|
||||
async function rootTextLength(): Promise<number> {
|
||||
|
||||
@@ -472,6 +472,29 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Active sub-agents (ambient fleet awareness) ──────────────────────
|
||||
// When this agent has async/parallel workers registered under its own
|
||||
// session, prepend a compact `[active_subagents]` roster (agent type,
|
||||
// subagent_session_id, live status) so it tracks the fleet from the turn
|
||||
// context instead of relying on remembered `[async_subagent_ref]` blocks
|
||||
// that may have scrolled away. Children register under the parent's
|
||||
// `session_id`, which is this agent's `event_session_id` (see
|
||||
// `build_parent_execution_context`). Gated on presence: agents that never
|
||||
// spawn get an empty block and no injection. Rides per-turn context (like
|
||||
// the goal block) so status is always live.
|
||||
if let Some(block) =
|
||||
crate::openhuman::agent_orchestration::running_subagents::active_subagents_context_block(
|
||||
&self.event_session_id,
|
||||
)
|
||||
{
|
||||
log::info!(
|
||||
"[running_subagents] injecting active_subagents block session={} ({} chars)",
|
||||
self.event_session_id,
|
||||
block.chars().count()
|
||||
);
|
||||
context.push_str(&block);
|
||||
}
|
||||
|
||||
let enriched = if context.is_empty() {
|
||||
log::info!("[agent] no memory context found — using raw user message");
|
||||
self.last_memory_context = None;
|
||||
|
||||
@@ -720,6 +720,86 @@ fn spawn_status_watcher(
|
||||
});
|
||||
}
|
||||
|
||||
/// Compact, read-only view of one registered sub-agent, for ambient injection
|
||||
/// into a parent's turn context (see [`active_subagents_context_block`]).
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct SubagentSnapshot {
|
||||
/// Worker *type* (e.g. `researcher`). Not unique — two parallel researchers
|
||||
/// share this; disambiguate on `subagent_session_id` / `task_id`.
|
||||
pub(crate) agent_id: String,
|
||||
/// Durable, stable per-worker reference the prompt steers/waits/closes by.
|
||||
pub(crate) subagent_session_id: Option<String>,
|
||||
/// Transient registry key.
|
||||
pub(crate) task_id: String,
|
||||
/// Stable status label: `running` / `awaiting_user` / `completed` / `failed`.
|
||||
pub(crate) status: &'static str,
|
||||
}
|
||||
|
||||
/// Snapshot the sub-agents registered under `parent_session`, with each status
|
||||
/// read live from its watch channel. Read-only: it takes the registry lock only
|
||||
/// long enough to clone out the small summaries, never blocks on a child, and
|
||||
/// never mutates the table. Ordered by `agent_id` then `task_id` so the rendered
|
||||
/// roster is stable across turns (the underlying map is unordered).
|
||||
pub(crate) fn snapshot_for_parent(parent_session: &str) -> Vec<SubagentSnapshot> {
|
||||
let map = registry().lock().expect("running_subagents mutex poisoned");
|
||||
let mut out: Vec<SubagentSnapshot> = map
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.parent_session == parent_session)
|
||||
.map(|(task_id, entry)| {
|
||||
let status = match &*entry.status.borrow() {
|
||||
SubagentStatus::Running => "running",
|
||||
SubagentStatus::Completed { .. } => "completed",
|
||||
SubagentStatus::AwaitingUser { .. } => "awaiting_user",
|
||||
SubagentStatus::Failed { .. } => "failed",
|
||||
};
|
||||
SubagentSnapshot {
|
||||
agent_id: entry.agent_id.clone(),
|
||||
subagent_session_id: entry.subagent_session_id.clone(),
|
||||
task_id: task_id.clone(),
|
||||
status,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| {
|
||||
a.agent_id
|
||||
.cmp(&b.agent_id)
|
||||
.then_with(|| a.task_id.cmp(&b.task_id))
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Build the ambient `[active_subagents]` block prepended to a parent's turn
|
||||
/// context when it has async/parallel workers registered. Returns `None` when the
|
||||
/// parent owns no registered sub-agents, so the block only appears when it is
|
||||
/// actionable — turns for agents that never spawn are untouched. Mirrors the
|
||||
/// thread-goal `[active_goal]` block: it rides the per-turn context (not the
|
||||
/// cached system-prompt prefix), so it reflects live status every turn.
|
||||
pub(crate) fn active_subagents_context_block(parent_session: &str) -> Option<String> {
|
||||
let workers = snapshot_for_parent(parent_session);
|
||||
if workers.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut block = format!(
|
||||
"[active_subagents]\n\
|
||||
You have {} background sub-agent(s) in flight (spawned earlier this session). \
|
||||
This is your live roster — trust it over memory. Track each by subagent_session_id; \
|
||||
use wait_subagent to collect a `completed` one, steer_subagent to redirect a `running` \
|
||||
one, continue_subagent to answer an `awaiting_user` one, close_subagent when done, and \
|
||||
list_subagents to re-enumerate. Never fabricate a result for a worker still running or \
|
||||
one that has failed.\n",
|
||||
workers.len()
|
||||
);
|
||||
for w in &workers {
|
||||
let session = w.subagent_session_id.as_deref().unwrap_or("(none)");
|
||||
block.push_str(&format!(
|
||||
"- {} · session={} · task={} · status={}\n",
|
||||
w.agent_id, session, w.task_id, w.status
|
||||
));
|
||||
}
|
||||
block.push_str("[/active_subagents]\n\n");
|
||||
Some(block)
|
||||
}
|
||||
|
||||
/// Resolve a durable `subagent_session_id` to the currently-running transient
|
||||
/// `task_id`, enforcing parent-session ownership.
|
||||
pub(crate) fn task_id_for_session(
|
||||
@@ -1474,6 +1554,87 @@ mod tests {
|
||||
prune("task-session");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_and_block_scope_to_parent_and_reflect_live_status() {
|
||||
let _guard = test_guard();
|
||||
let (tx_a, rx_a) = status_channel();
|
||||
register(
|
||||
"task-fleet-a".into(),
|
||||
"researcher".into(),
|
||||
"fleet-parent".into(),
|
||||
None,
|
||||
Some("subsess-a".into()),
|
||||
test_workspace(),
|
||||
None,
|
||||
RunQueue::new(),
|
||||
dummy_abort(),
|
||||
rx_a,
|
||||
);
|
||||
let (tx_b, rx_b) = status_channel();
|
||||
register(
|
||||
"task-fleet-b".into(),
|
||||
"code_executor".into(),
|
||||
"fleet-parent".into(),
|
||||
None,
|
||||
Some("subsess-b".into()),
|
||||
test_workspace(),
|
||||
None,
|
||||
RunQueue::new(),
|
||||
dummy_abort(),
|
||||
rx_b,
|
||||
);
|
||||
// A worker owned by a different parent must not leak into the snapshot.
|
||||
let (tx_other, rx_other) = status_channel();
|
||||
register(
|
||||
"task-fleet-other".into(),
|
||||
"researcher".into(),
|
||||
"other-parent".into(),
|
||||
None,
|
||||
Some("subsess-other".into()),
|
||||
test_workspace(),
|
||||
None,
|
||||
RunQueue::new(),
|
||||
dummy_abort(),
|
||||
rx_other,
|
||||
);
|
||||
|
||||
// `b` pauses awaiting the user; `a` stays running.
|
||||
tx_b.send(SubagentStatus::AwaitingUser {
|
||||
question: "which repo?".into(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let snap = snapshot_for_parent("fleet-parent");
|
||||
assert_eq!(snap.len(), 2, "only this parent's workers: {snap:?}");
|
||||
// Ordered by agent_id then task_id → code_executor before researcher.
|
||||
assert_eq!(snap[0].agent_id, "code_executor");
|
||||
assert_eq!(snap[0].status, "awaiting_user");
|
||||
assert_eq!(snap[1].agent_id, "researcher");
|
||||
assert_eq!(snap[1].status, "running");
|
||||
|
||||
let block = active_subagents_context_block("fleet-parent").expect("block present");
|
||||
assert!(block.contains("[active_subagents]"));
|
||||
assert!(block.contains("You have 2 background sub-agent(s)"));
|
||||
assert!(block.contains("session=subsess-a"));
|
||||
assert!(block.contains("session=subsess-b · task=task-fleet-b · status=awaiting_user"));
|
||||
assert!(block.ends_with("[/active_subagents]\n\n"));
|
||||
|
||||
// A parent with no registered workers gets no block (no perturbation).
|
||||
assert!(active_subagents_context_block("nobody-here").is_none());
|
||||
|
||||
let _ = tx_a.send(SubagentStatus::Completed {
|
||||
output: "x".into(),
|
||||
iterations: 1,
|
||||
});
|
||||
let _ = tx_other.send(SubagentStatus::Completed {
|
||||
output: "x".into(),
|
||||
iterations: 1,
|
||||
});
|
||||
prune("task-fleet-a");
|
||||
prune("task-fleet-b");
|
||||
prune("task-fleet-other");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_ref_for_task_includes_resume_fields_and_enforces_ownership() {
|
||||
let _guard = test_guard();
|
||||
|
||||
@@ -237,6 +237,17 @@ named = [
|
||||
# to call a tool that does not exist.
|
||||
"spawn_async_subagent",
|
||||
"spawn_parallel_agents",
|
||||
# Fleet awareness. `list_subagents` re-enumerates the async/parallel workers
|
||||
# this parent has in flight (running / awaiting_user / completed-uncollected /
|
||||
# failed) plus reusable durable sessions — the recovery move when the
|
||||
# `[async_subagent_ref]` blocks have scrolled out of context and the model
|
||||
# would otherwise lose track of, or needlessly re-spawn, a worker. It reads
|
||||
# the same live `running_subagents` registry the ambient `[active_subagents]`
|
||||
# turn block is built from. `close_subagent` tears a finished/abandoned worker
|
||||
# down (cancelling it first if still running) so idle sessions don't leak
|
||||
# toward the 256 registry soft cap.
|
||||
"list_subagents",
|
||||
"close_subagent",
|
||||
"wait",
|
||||
"wait_loop",
|
||||
# Async sub-agent control surface. prompt.md's "Async background
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Orchestrator - Staff Engineer
|
||||
|
||||
You are the **Orchestrator**, the senior agent in a multi-agent system. Your role is strategic: you decide when to respond directly, when to use direct tools, and when to delegate. You have a small direct surface for lookups (`file_read`, `grep`, `glob`, `list`, `web_search_tool`, `web_fetch`, `http_request`) and managed storage transfer (`storage_upload_file`, `storage_download_file`, `storage_list_files`, `storage_get_link`). You **never** use generic file-write/edit tools and **never** execute shell commands — ordinary file modifications are delegated to `run_code` (or the owning specialist), while managed storage upload/download calls go through their own tool policy gates.
|
||||
You are the **Orchestrator**, the senior agent in a multi-agent system. Your role is strategic: you decide when to respond directly, when to use direct tools, and when to delegate. **You may have several sub-agents in flight at once** — you are not talking to one worker at a time, you are running a small fleet. Each worker is a separate process with its own transcript and a stable `subagent_session_id`; keeping track of who is running, who is waiting on you, and whose results you have already collected is *your* job, not something the system does for you. You have a small direct surface for lookups (`file_read`, `grep`, `glob`, `list`, `web_search_tool`, `web_fetch`, `http_request`) and managed storage transfer (`storage_upload_file`, `storage_download_file`, `storage_list_files`, `storage_get_link`). You **never** use generic file-write/edit tools and **never** execute shell commands — ordinary file modifications are delegated to `run_code` (or the owning specialist), while managed storage upload/download calls go through their own tool policy gates.
|
||||
|
||||
## Core Responsibilities
|
||||
|
||||
@@ -69,7 +69,52 @@ You can open and operate native apps on this machine, but you do it by **delegat
|
||||
- **`cron_add`, `cron_list`, `cron_remove`, `current_time` are direct named tools** when they appear in your tool list. Call them by name, never via `run_workflow` (that path returns "unknown workflow" for any built-in tool name and always errors).
|
||||
- **Always get explicit user confirmation before creating any schedule** (one-shot or recurring). Propose the exact timing, wait for a yes, then act. If `cron_add` is absent from your tool list and `schedule_task` is unavailable, tell the user you can't schedule it in this environment.
|
||||
|
||||
## Async background sub-agents
|
||||
## Managing your fleet (multiple concurrent sub-agents)
|
||||
|
||||
Most turns you delegate to one specialist, read its reply, and answer. But the moment
|
||||
you use `spawn_async_subagent` or `spawn_parallel_agents`, you are running **several
|
||||
workers at once**, and you stay responsible for every one of them until it is collected
|
||||
or closed. Treat them as a roster, not a single conversation partner.
|
||||
|
||||
**Know what is running — don't rely on memory.** When background workers are live, each
|
||||
of your turns is prefixed with an `[active_subagents]` block listing them (agent type,
|
||||
`subagent_session_id`, and status: `running` / `awaiting_user` / `completed` /
|
||||
`failed`). Read it. It is the source of truth for your fleet — trust it over your
|
||||
recollection of earlier `[async_subagent_ref]` blocks, which may have scrolled out of
|
||||
context.
|
||||
|
||||
- If you are unsure what you have running, or the `[active_subagents]` block and your
|
||||
memory disagree, call **`list_subagents`** to re-enumerate every worker (live *and*
|
||||
reusable) before acting. This is the recovery move — do it instead of guessing or
|
||||
re-spawning a worker that already exists.
|
||||
- **Never spawn a duplicate.** Before spawning, check the roster: if a suitable worker
|
||||
is already `running` or reusable for this task, steer or wait on that one instead of
|
||||
creating another.
|
||||
- A worker shown as `completed` still needs collecting — call `wait_subagent` on its
|
||||
ref to read the result. A `failed` worker will never produce output; surface the
|
||||
failure honestly, don't paper over it.
|
||||
- When you are done with a worker (result collected, or the task is abandoned), call
|
||||
**`close_subagent`** with its `subagent_session_id` so it doesn't linger. Leaked idle
|
||||
workers accumulate against a hard cap and will eventually block new spawns.
|
||||
- Track workers by **`subagent_session_id`** (or `task_id`). `agentId` is only the
|
||||
worker *type* — two researchers you spawned in parallel share an `agentId` but are
|
||||
different workers. Never merge their state.
|
||||
- **Reconciliation loop for parallel work:** spawn → note each `subagent_session_id` →
|
||||
tick/wait on each *independently* → synthesise **only completed** outputs → report any
|
||||
failures. Never fabricate, guess, or average in a result for a worker that is still
|
||||
running or has failed.
|
||||
|
||||
### Parallel fan-out (`spawn_parallel_agents`)
|
||||
|
||||
Use `spawn_parallel_agents` when a task decomposes into **independent** subtasks that can
|
||||
run at the same time and whose results you will combine — e.g. "research these 3 vendors",
|
||||
"check each of these 4 files". It returns an array of results, one per spawned worker, in
|
||||
spawn order. Reason over the whole array: some entries may have failed while others
|
||||
succeeded. Do **not** use it when the subtasks depend on each other's output (sequence
|
||||
those, or use `rhai_workflows` for real control flow), and don't fan out work that a
|
||||
single delegation or a direct tool already covers.
|
||||
|
||||
### Async background sub-agents
|
||||
|
||||
Use `spawn_async_subagent` only for low-attention background work where the current user
|
||||
response must not depend on the result. Good fits: best-effort memory archiving,
|
||||
|
||||
Reference in New Issue
Block a user