From 078844323df68fd01805023bf216d407e17db8c0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:04:51 -0700 Subject: [PATCH] feat(orchestration): promote TinyPlace Orchestration to its own tab + teach the orchestrator to manage a fleet (#4693) --- app/src/AppRoutes.tsx | 18 ++ .../layout/shell/CollapsedNavRail.test.tsx | 1 + app/src/components/layout/shell/navIcons.tsx | 17 ++ .../orchestration/AgentChatPanel.tsx | 132 ++++++++++ .../orchestration/ConnectionsPanel.tsx | 168 +++++++++++++ .../orchestration/DiscoverPanel.tsx | 228 ++++++++++++++++++ .../components/orchestration/UsagePanel.tsx | 140 +++++++++++ .../__tests__/AgentChatPanel.test.tsx | 105 ++++++++ .../__tests__/ConnectionsPanel.test.tsx | 75 ++++++ .../__tests__/DiscoverPanel.test.tsx | 89 +++++++ .../__tests__/UsagePanel.test.tsx | 53 ++++ .../components/orchestration/primitives.tsx | 58 +++++ app/src/config/__tests__/navConfig.test.ts | 8 +- app/src/config/navConfig.ts | 14 +- app/src/lib/i18n/ar.ts | 36 +++ app/src/lib/i18n/bn.ts | 36 +++ app/src/lib/i18n/de.ts | 36 +++ app/src/lib/i18n/en.ts | 38 +++ app/src/lib/i18n/es.ts | 36 +++ app/src/lib/i18n/fr.ts | 36 +++ app/src/lib/i18n/hi.ts | 36 +++ app/src/lib/i18n/id.ts | 36 +++ app/src/lib/i18n/it.ts | 36 +++ app/src/lib/i18n/ko.ts | 36 +++ app/src/lib/i18n/pl.ts | 36 +++ app/src/lib/i18n/pt.ts | 36 +++ app/src/lib/i18n/ru.ts | 36 +++ app/src/lib/i18n/zh-CN.ts | 36 +++ .../__tests__/usePairing.test.ts | 65 +++++ app/src/lib/orchestration/usePairing.ts | 101 ++++++++ app/src/pages/Brain.tsx | 35 +-- app/src/pages/OrchestrationPage.tsx | 140 +++++++++++ app/src/pages/__tests__/Brain.test.tsx | 26 +- .../__tests__/OrchestrationPage.test.tsx | 70 ++++++ app/test/e2e/specs/navigation.spec.ts | 1 + .../agent/harness/session/turn/core.rs | 23 ++ .../agent_orchestration/running_subagents.rs | 161 +++++++++++++ .../agents/orchestrator/agent.toml | 11 + .../agents/orchestrator/prompt.md | 49 +++- 39 files changed, 2255 insertions(+), 39 deletions(-) create mode 100644 app/src/components/orchestration/AgentChatPanel.tsx create mode 100644 app/src/components/orchestration/ConnectionsPanel.tsx create mode 100644 app/src/components/orchestration/DiscoverPanel.tsx create mode 100644 app/src/components/orchestration/UsagePanel.tsx create mode 100644 app/src/components/orchestration/__tests__/AgentChatPanel.test.tsx create mode 100644 app/src/components/orchestration/__tests__/ConnectionsPanel.test.tsx create mode 100644 app/src/components/orchestration/__tests__/DiscoverPanel.test.tsx create mode 100644 app/src/components/orchestration/__tests__/UsagePanel.test.tsx create mode 100644 app/src/components/orchestration/primitives.tsx create mode 100644 app/src/lib/orchestration/__tests__/usePairing.test.ts create mode 100644 app/src/lib/orchestration/usePairing.ts create mode 100644 app/src/pages/OrchestrationPage.tsx create mode 100644 app/src/pages/__tests__/OrchestrationPage.test.tsx diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx index f8c4559da..63e175599 100644 --- a/app/src/AppRoutes.tsx +++ b/app/src/AppRoutes.tsx @@ -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). */} + + + + } + /> + {/* Back-compat: the old Brain deep link → the promoted top-level tab. */} + } + /> + {/* Back-compat: /activity and /intelligence → settings notifications page. */} } /> } /> diff --git a/app/src/components/layout/shell/CollapsedNavRail.test.tsx b/app/src/components/layout/shell/CollapsedNavRail.test.tsx index b9d10b612..7b4984f1b 100644 --- a/app/src/components/layout/shell/CollapsedNavRail.test.tsx +++ b/app/src/components/layout/shell/CollapsedNavRail.test.tsx @@ -29,6 +29,7 @@ describe('CollapsedNavRail', () => { 'nav.human', 'nav.brain', 'nav.flows', + 'nav.orchestration', 'nav.agentWorld', 'nav.connections', ]) { diff --git a/app/src/components/layout/shell/navIcons.tsx b/app/src/components/layout/shell/navIcons.tsx index 565b5d920..dc785dae6 100644 --- a/app/src/components/layout/shell/navIcons.tsx +++ b/app/src/components/layout/shell/navIcons.tsx @@ -118,6 +118,23 @@ export function NavIcon({ id, className = 'w-5 h-5' }: NavIconProps) { /> ); + case 'orchestration': + // Conductor/hub glyph — one root node branching to two workers, + // representing multi-agent orchestration (distinct from the horizontal + // `flows` automation graph). + return ( + + + + + + + ); case 'agent-world': // Globe/network glyph — represents the A2A agent social network. return ( diff --git a/app/src/components/orchestration/AgentChatPanel.tsx b/app/src/components/orchestration/AgentChatPanel.tsx new file mode 100644 index 000000000..e19a41d3c --- /dev/null +++ b/app/src/components/orchestration/AgentChatPanel.tsx @@ -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) => { + 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 ( +
+ {/* Master ↔ Subconscious switch. */} +
+ {tabs.map(tab => { + const active = selectedId === tab.key; + return ( + + ); + })} +
+ +
+ void runSteeringReview()} + canCompose={isMasterSelected} + composerBody={composerBody} + onComposerChange={setComposerBody} + sending={sending} + onSubmitComposer={submitComposer} + /> +
+
+ ); +} diff --git a/app/src/components/orchestration/ConnectionsPanel.tsx b/app/src/components/orchestration/ConnectionsPanel.tsx new file mode 100644 index 000000000..87a8a5234 --- /dev/null +++ b/app/src/components/orchestration/ConnectionsPanel.tsx @@ -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>({}); + + 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 ( +

+ {t('tinyplaceOrchestration.loading')} +

+ ); + } + if (state.status === 'payment_required') { + return ( +

+ {t('tinyplaceOrchestration.paymentRequired')} +

+ ); + } + if (state.status === 'error') { + return ( +

+ {t('tinyplaceOrchestration.failedToLoad')}: {state.message} +

+ ); + } + + return ( +
+
+ + +
+ + {actionError && ( +

+ {actionError} +

+ )} + + + {t('orchPage.discover.linkAction')} + + ) + }> + {accepted.length === 0 ? ( +
+

{t('orchPage.connections.empty')}

+ {onDiscover && ( + + )} +
+ ) : ( +
    + {accepted.map(contact => { + const address = contactAddress(contact); + const handle = handles[address]; + const actionId = `block:${address}`; + return ( +
  • +
    +

    + {handle ? `@${handle}` : t('tinyplaceOrchestration.unknownSender')} +

    +

    {address}

    +
    + +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/app/src/components/orchestration/DiscoverPanel.tsx b/app/src/components/orchestration/DiscoverPanel.tsx new file mode 100644 index 000000000..6cafaced5 --- /dev/null +++ b/app/src/components/orchestration/DiscoverPanel.tsx @@ -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(null); + const [relay, setRelay] = useState(null); + const [identityLoading, setIdentityLoading] = useState(true); + const [handles, setHandles] = useState>({}); + + 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) => { + 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 ( +
+ {/* Own identity / discoverability. */} + + {identityLoading ? ( +

+ {t('tinyplaceOrchestration.identity.loading')} +

+ ) : identity ? ( +
+
+ + {discoverable + ? t('tinyplaceOrchestration.identity.discoverable') + : t('tinyplaceOrchestration.identity.undiscoverable')} + + {relay && ( + + {relay.network === 'prod' + ? t('tinyplaceOrchestration.relay.prod') + : t('tinyplaceOrchestration.relay.staging')} + + )} +
+

+ {identity.primaryHandle + ? `@${identity.primaryHandle.replace(/^@+/, '')}` + : t('tinyplaceOrchestration.identity.noHandle')} +

+ {!discoverable && ( +

+ {t('orchPage.discover.notDiscoverableGuide')} +

+ )} +
+ ) : ( +

+ {t('tinyplaceOrchestration.identity.noHandle')} +

+ )} +
+ + {/* Link a new agent. */} + +
+ 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" + /> + +
+ {actionError && ( +

{actionError}

+ )} +
+ + {/* Inbound requests. */} + + {state.status === 'loading' ? ( +

{t('tinyplaceOrchestration.loading')}

+ ) : incoming.length === 0 ? ( +

+ {t('orchPage.discover.noRequests')} +

+ ) : ( +
    + {incoming.map(request => { + const address = contactAddress(request); + const handle = handles[address]; + return ( +
  • +
    +

    + {handle ? `@${handle}` : t('tinyplaceOrchestration.unknownSender')} +

    +

    {address}

    +
    +
    + + +
    +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/app/src/components/orchestration/UsagePanel.tsx b/app/src/components/orchestration/UsagePanel.tsx new file mode 100644 index 000000000..1438bb1de --- /dev/null +++ b/app/src/components/orchestration/UsagePanel.tsx @@ -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(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 ( +

+ {t('tinyplaceOrchestration.loading')} +

+ ); + } + + const balanceUsd = + data?.balance != null + ? data.balance.promotionBalanceUsd + data.balance.teamTopupUsd + : undefined; + const totals = data?.team?.insights.totals; + + return ( +
+
+ + + + + + +
+

{t('orchPage.usage.footnote')}

+
+ ); +} diff --git a/app/src/components/orchestration/__tests__/AgentChatPanel.test.tsx b/app/src/components/orchestration/__tests__/AgentChatPanel.test.tsx new file mode 100644 index 000000000..4f46051df --- /dev/null +++ b/app/src/components/orchestration/__tests__/AgentChatPanel.test.tsx @@ -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) => ( +
+
+ onComposerChange(e.target.value)} + /> +
+ +
+ ), +})); + +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(); + 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(); + 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(); + 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(); + 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(); + fireEvent.click(screen.getByTestId('focus-steering')); + await waitFor(() => expect(subconsciousTrigger).toHaveBeenCalledWith('tinyplace')); + }); +}); diff --git a/app/src/components/orchestration/__tests__/ConnectionsPanel.test.tsx b/app/src/components/orchestration/__tests__/ConnectionsPanel.test.tsx new file mode 100644 index 000000000..cc9a26ff1 --- /dev/null +++ b/app/src/components/orchestration/__tests__/ConnectionsPanel.test.tsx @@ -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 }, + 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(); + 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(); + 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(); + 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)); + }); +}); diff --git a/app/src/components/orchestration/__tests__/DiscoverPanel.test.tsx b/app/src/components/orchestration/__tests__/DiscoverPanel.test.tsx new file mode 100644 index 000000000..58a776369 --- /dev/null +++ b/app/src/components/orchestration/__tests__/DiscoverPanel.test.tsx @@ -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(); + await waitFor(() => expect(screen.getByTestId('orch-discover-guide')).toBeInTheDocument()); + expect(screen.getByTestId('orch-discover-no-requests')).toBeInTheDocument(); + }); + + it('submits a link request', async () => { + render(); + 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(); + 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)); + }); +}); diff --git a/app/src/components/orchestration/__tests__/UsagePanel.test.tsx b/app/src/components/orchestration/__tests__/UsagePanel.test.tsx new file mode 100644 index 000000000..c4565be28 --- /dev/null +++ b/app/src/components/orchestration/__tests__/UsagePanel.test.tsx @@ -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(); + 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(); + await waitFor(() => expect(screen.getByTestId('orch-usage-panel')).toBeInTheDocument()); + expect(screen.getByTestId('orch-usage-balance')).toHaveTextContent('—'); + expect(screen.getByTestId('orch-usage-connections')).toHaveTextContent('—'); + }); +}); diff --git a/app/src/components/orchestration/primitives.tsx b/app/src/components/orchestration/primitives.tsx new file mode 100644 index 000000000..8226d9df4 --- /dev/null +++ b/app/src/components/orchestration/primitives.tsx @@ -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 ( +
+ {(title || action) && ( +
+
+ {title &&

{title}

} + {description &&

{description}

} +
+ {action} +
+ )} + {children} +
+ ); +} + +/** 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 ( +
+

{label}

+

{value}

+ {hint &&

{hint}

} +
+ ); +} diff --git a/app/src/config/__tests__/navConfig.test.ts b/app/src/config/__tests__/navConfig.test.ts index 0a20263b5..72d5a57f2 100644 --- a/app/src/config/__tests__/navConfig.test.ts +++ b/app/src/config/__tests__/navConfig.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it } from 'vitest'; import { AVATAR_MENU_ITEMS, NAV_TABS } from '../navConfig'; describe('NAV_TABS', () => { - it('has exactly 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', ]); diff --git a/app/src/config/navConfig.ts b/app/src/config/navConfig.ts index ff47ca08e..76d43665e 100644 --- a/app/src/config/navConfig.ts +++ b/app/src/config/navConfig.ts @@ -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', diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index be364a039..1fae8784e 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -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': diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index d59283f50..1213d9664 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -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': diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 3f3d8cb6f..7034bb26e 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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': diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index a805d7569..5ce017b4e 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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 diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 2cf8e7ad5..67c3dab46 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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': diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 87b5aae97..5b2cc5dd8 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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': diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index d829f0ae7..c93a8cf3f 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -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': diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index ae5122164..e33da0a0a 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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': diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 3e7d9f0f6..09f0e1601 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -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': diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index cdedbb1fc..42dac7d55 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -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': diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index f546e0572..3bccc102c 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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': diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index a5c4a12aa..a856d2e12 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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': diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 702823bf1..931b53471 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -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': diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 37574dea4..edc19ff25 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -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': diff --git a/app/src/lib/orchestration/__tests__/usePairing.test.ts b/app/src/lib/orchestration/__tests__/usePairing.test.ts new file mode 100644 index 000000000..fd037f325 --- /dev/null +++ b/app/src/lib/orchestration/__tests__/usePairing.test.ts @@ -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 + }); +}); diff --git a/app/src/lib/orchestration/usePairing.ts b/app/src/lib/orchestration/usePairing.ts new file mode 100644 index 000000000..8e6291335 --- /dev/null +++ b/app/src/lib/orchestration/usePairing.ts @@ -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; + /** Run a mutating pairing action, then reload; tracks pending id + error. */ + runAction: (actionId: string, fn: () => Promise) => Promise; + /** 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({ status: 'loading' }); + const [pendingAction, setPendingAction] = useState(null); + const [actionError, setActionError] = useState(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) => { + 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 }; +} diff --git a/app/src/pages/Brain.tsx b/app/src/pages/Brain.tsx index f2f3faf3a..d5a80c3f6 100644 --- a/app/src/pages/Brain.tsx +++ b/app/src/pages/Brain.tsx @@ -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(null); const [error, setError] = useState(null); const [mode, setMode] = useState('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. -
+
{activeTab === 'graph' && (
)} - {activeTab === 'tinyplace-orchestration' && ( -
- -
- )} - {activeTab === 'subconscious' && (
diff --git a/app/src/pages/OrchestrationPage.tsx b/app/src/pages/OrchestrationPage.tsx new file mode 100644 index 000000000..cebcc3037 --- /dev/null +++ b/app/src/pages/OrchestrationPage.tsx @@ -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) => ( + + + +); + +export default function OrchestrationPage() { + const { t } = useT(); + const location = useLocation(); + const navigate = useNavigate(); + + const activeTab = useMemo(() => { + 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 ( +
+ +
+ 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={ +

{t('orchPage.subtitle')}

+ } + /> +
+
+ +
+ {activeTab === 'agent' ? ( + // The chat panel manages its own full-height layout + scroll. +
+ +
+ ) : ( + +
+ {activeTab === 'connections' && ( + setActiveTab('discover')} /> + )} + {activeTab === 'discover' && } + {activeTab === 'usage' && } +
+
+ )} +
+
+ ); +} diff --git a/app/src/pages/__tests__/Brain.test.tsx b/app/src/pages/__tests__/Brain.test.tsx index 11db7184e..b4118e588 100644 --- a/app/src/pages/__tests__/Brain.test.tsx +++ b/app/src/pages/__tests__/Brain.test.tsx @@ -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(); + 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(, { initialEntries: ['/?tab=tinyplace-orchestration'] }); + }); + await waitFor(() => { + expect(navigateSpy).toHaveBeenCalledWith('/orchestration', { replace: true }); + }); + }); }); diff --git a/app/src/pages/__tests__/OrchestrationPage.test.tsx b/app/src/pages/__tests__/OrchestrationPage.test.tsx new file mode 100644 index 000000000..46e5ff473 --- /dev/null +++ b/app/src/pages/__tests__/OrchestrationPage.test.tsx @@ -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: () =>
, +})); +vi.mock('../../components/orchestration/ConnectionsPanel', () => ({ + default: ({ onDiscover }: { onDiscover?: () => void }) => ( + + ), +})); +vi.mock('../../components/orchestration/DiscoverPanel', () => ({ + default: () =>
, +})); +vi.mock('../../components/orchestration/UsagePanel', () => ({ + default: () =>
, +})); + +describe('OrchestrationPage shell', () => { + it('defaults to the agent chat panel', async () => { + await act(async () => { + renderWithProviders(, { 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(, { initialEntries: [`/orchestration?tab=${tab}`] }); + }); + expect(screen.getByTestId(testId)).toBeInTheDocument(); + }); + + it('projects a sub-nav that switches tabs', async () => { + await act(async () => { + renderWithProviders(, { initialEntries: ['/orchestration'] }); + }); + // Sub-nav renders via the sidebar portal once the outlet mounts. + 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(, { + initialEntries: ['/orchestration?tab=connections'], + }); + }); + await act(async () => { + fireEvent.click(screen.getByTestId('panel-connections')); + }); + await waitFor(() => expect(screen.getByTestId('panel-discover')).toBeInTheDocument()); + }); +}); diff --git a/app/test/e2e/specs/navigation.spec.ts b/app/test/e2e/specs/navigation.spec.ts index 458dc13aa..9ea0ac4f6 100644 --- a/app/test/e2e/specs/navigation.spec.ts +++ b/app/test/e2e/specs/navigation.spec.ts @@ -45,6 +45,7 @@ const ROUTES: Route[] = [ { hash: '/settings' }, { hash: '/agent-world' }, { hash: '/flows' }, + { hash: '/orchestration' }, ]; async function rootTextLength(): Promise { diff --git a/src/openhuman/agent/harness/session/turn/core.rs b/src/openhuman/agent/harness/session/turn/core.rs index e645c85c1..a2433cda4 100644 --- a/src/openhuman/agent/harness/session/turn/core.rs +++ b/src/openhuman/agent/harness/session/turn/core.rs @@ -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; diff --git a/src/openhuman/agent_orchestration/running_subagents.rs b/src/openhuman/agent_orchestration/running_subagents.rs index 2f3cbd3d3..4b26363af 100644 --- a/src/openhuman/agent_orchestration/running_subagents.rs +++ b/src/openhuman/agent_orchestration/running_subagents.rs @@ -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, + /// 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 { + let map = registry().lock().expect("running_subagents mutex poisoned"); + let mut out: Vec = 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 { + 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(); diff --git a/src/openhuman/agent_registry/agents/orchestrator/agent.toml b/src/openhuman/agent_registry/agents/orchestrator/agent.toml index cf3076f7f..031c12030 100644 --- a/src/openhuman/agent_registry/agents/orchestrator/agent.toml +++ b/src/openhuman/agent_registry/agents/orchestrator/agent.toml @@ -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 diff --git a/src/openhuman/agent_registry/agents/orchestrator/prompt.md b/src/openhuman/agent_registry/agents/orchestrator/prompt.md index a56ffe5ad..cd83c344e 100644 --- a/src/openhuman/agent_registry/agents/orchestrator/prompt.md +++ b/src/openhuman/agent_registry/agents/orchestrator/prompt.md @@ -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,