diff --git a/app/src/components/orchestration/AgentChatPanel.tsx b/app/src/components/orchestration/AgentChatPanel.tsx index e19a41d3c..669c139ce 100644 --- a/app/src/components/orchestration/AgentChatPanel.tsx +++ b/app/src/components/orchestration/AgentChatPanel.tsx @@ -1,31 +1,153 @@ /** - * AgentChatPanel — "chat with the main agent and see its subconscious". + * AgentChatPanel — chat with the main agent, styled like the app's normal chat. * - * 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. + * A ThreadList-style rail (Main agent / Subconscious) beside a centered message + * pane rendered with the shared {@link SessionTranscript} (chat-window bubbles + + * inline harness activity), plus the subconscious steering header and the Master + * composer. + * + * When the agent engages a fleet session (a session parked on an approval), an + * inline **View session** card surfaces below the thread; opening it slides in a + * right-hand session side-tab showing that session's live chat + a reply + * composer. The side-tab never opens on its own — the user clicks the card. */ import debugFactory from 'debug'; import { type FormEvent, useCallback, useEffect, useRef, useState } from 'react'; import { useT } from '../../lib/i18n/I18nContext'; +import { + orchestrationClient, + type SessionSummary, +} from '../../lib/orchestration/orchestrationClient'; import { MASTER_CHAT_KEY, SUBCONSCIOUS_CHAT_KEY, useOrchestrationChats, } from '../../lib/orchestration/useOrchestrationChats'; +import { + useContactSessions, + useSessionTranscript, +} from '../../lib/orchestration/useOrchestrationSessions'; import { subconsciousTrigger } from '../../utils/tauriCommands/subconscious'; -import OrchestrationFocusPane from '../intelligence/OrchestrationFocusPane'; +import Button from '../ui/Button'; +import SessionTranscript from './SessionTranscript'; const debug = debugFactory('orchestration:agent-chat'); +function sessionLabel(session: SessionSummary): string { + return session.label?.trim() || session.sessionId; +} + +/** Right-hand session side-tab: a fleet session's live chat + reply composer. */ +function SessionDrawer({ session, onClose }: { session: SessionSummary; onClose: () => void }) { + const { t } = useT(); + const { state, messages, refresh } = useSessionTranscript(session.sessionId); + const [body, setBody] = useState(''); + const [sending, setSending] = useState(false); + const [sendError, setSendError] = useState(null); + + const submit = useCallback( + (event: FormEvent) => { + event.preventDefault(); + const trimmed = body.trim(); + if (!trimmed || sending) return; + setSending(true); + setSendError(null); + debug('[orchestration:agent-chat] session reply: send session=%s', session.sessionId); + void orchestrationClient + .sendMasterMessage({ + body: trimmed, + recipient: session.agentId, + sessionId: session.sessionId, + }) + .then(() => { + setBody(''); + void refresh(); + }) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + debug( + '[orchestration:agent-chat] session reply: failed session=%s %s', + session.sessionId, + message + ); + setSendError(message); + }) + .finally(() => setSending(false)); + }, + [body, sending, session.agentId, session.sessionId, refresh] + ); + + return ( + + ); +} + export default function AgentChatPanel() { const { t } = useT(); const { sessionsState, messagesState, + chats, selectedId, selected, status, @@ -34,10 +156,12 @@ export default function AgentChatPanel() { refresh, sendMessage, } = useOrchestrationChats(t); + const contactSessions = useContactSessions(); const [composerBody, setComposerBody] = useState(''); const [sending, setSending] = useState(false); const [runningReview, setRunningReview] = useState(false); + const [openSessionId, setOpenSessionId] = useState(null); const mountedRef = useRef(true); useEffect(() => { mountedRef.current = true; @@ -75,58 +199,199 @@ export default function AgentChatPanel() { const steeringText = status?.steering?.text?.trim() || null; const isMasterSelected = selectedId === MASTER_CHAT_KEY; + const isSubconscious = selectedId === SUBCONSCIOUS_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') }, - ]; + const rail = chats.filter(c => c.id === MASTER_CHAT_KEY || c.id === SUBCONSCIOUS_CHAT_KEY); + // Sessions the agent has engaged that are parked on an approval → "needs you". + const pinged = contactSessions.sessions.filter(s => s.status === 'waiting-approval'); + const openSession = contactSessions.sessions.find(s => s.sessionId === openSessionId) ?? null; return ( -
- {/* Master ↔ Subconscious switch. */} -
- {tabs.map(tab => { - const active = selectedId === tab.key; - return ( - - ); - })} -
+
+ {/* Thread rail — mirrors the normal chat's ThreadList. */} + -
- void runSteeringReview()} - canCompose={isMasterSelected} - composerBody={composerBody} - onComposerChange={setComposerBody} - sending={sending} - onSubmitComposer={submitComposer} + {/* Message pane. */} +
+ {/* Subconscious steering header. */} + {isSubconscious ? ( +
+
+

+ {steeringText + ? t('tinyplaceOrchestration.steeringHeader.current') + : t('tinyplaceOrchestration.steeringHeader.none')} +

+ {steeringText ? ( +

{steeringText}

+ ) : null} +
+ +
+ ) : null} + + {sessionsState.status === 'loading' || messagesState.status === 'loading' ? ( +
+ {t('tinyplaceOrchestration.loading')} +
+ ) : sessionsState.status === 'error' || messagesState.status === 'error' ? ( +
+

+ {t('tinyplaceOrchestration.failedToLoad')}:{' '} + {sessionsState.status === 'error' + ? sessionsState.message + : messagesState.status === 'error' + ? messagesState.message + : ''} +

+ +
+ ) : ( +
+
+ {selected?.messages.length ? ( + + ) : ( +

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

+ )} + + {/* View-session cards for sessions the agent engaged (needs you). */} + {pinged.map(session => ( +
+ +
+ ))} +
+
+ )} + + {/* Master composer. */} + {isMasterSelected && sessionsState.status === 'ok' ? ( +
+ {masterError ? ( +

+ {t('tinyplaceOrchestration.composer.sendFailed')}: {masterError} +

+ ) : null} +
+ setComposerBody(event.target.value)} + placeholder={t('tinyplaceOrchestration.composer.placeholder')} + 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-primary-500 focus:ring-2 focus:ring-primary-500/20" + /> + +
+
+ ) : null} +
+ + {/* Session side-tab (opens on demand from a View-session card). */} + {openSession ? ( + // `key` resets the drawer's local composer/sending state when switching + // to a different session, so a draft reply never leaks across sessions. + setOpenSessionId(null)} /> -
+ ) : null}
); } diff --git a/app/src/components/orchestration/ConnectionsPanel.tsx b/app/src/components/orchestration/ConnectionsPanel.tsx index 87a8a5234..f9bae83cf 100644 --- a/app/src/components/orchestration/ConnectionsPanel.tsx +++ b/app/src/components/orchestration/ConnectionsPanel.tsx @@ -1,34 +1,324 @@ /** - * ConnectionsPanel — "manage connections". + * ConnectionsPanel — the agent's connections **and their sessions**. * - * 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. + * A list of accepted tiny.place contacts where each contact expands to reveal + * your sessions with it (status · last activity · message count). Opening a + * session shows its conversation history in place — rendered in the app's normal + * chat-window style via {@link SessionTranscript} — with an inline reply + * composer. Pending requests + a summary sit on top. Linking new agents still + * lives in the sibling DiscoverPanel. + * + * Renders inside the same single `max-w-3xl` column OrchestrationPage gives it. */ -import { useEffect, useMemo, useState } from 'react'; +import debugFactory from 'debug'; +import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { apiClient } from '../../agentworld/AgentWorldShell'; import { useT } from '../../lib/i18n/I18nContext'; +import { + type InstanceStatus, + orchestrationClient, + type SessionSummary, +} from '../../lib/orchestration/orchestrationClient'; +import { + useContactSessions, + useSessionTranscript, +} from '../../lib/orchestration/useOrchestrationSessions'; import { usePairing } from '../../lib/orchestration/usePairing'; import { contactAddress, extractHandle } from '../intelligence/orchestrationTabHelpers'; import Button from '../ui/Button'; import { SectionCard, StatTile } from './primitives'; +import SessionTranscript from './SessionTranscript'; + +const debug = debugFactory('orchestration:connections'); + +type Translate = (key: string, fallback?: string) => string; + +interface StatusMeta { + label: string; + dot: string; + tone: string; +} + +function statusMeta(status: InstanceStatus, t: Translate): StatusMeta { + switch (status) { + case 'waiting-approval': + return { + label: t('orchPage.connections.status.needsYou'), + dot: 'bg-amber-500', + tone: 'text-amber-700 dark:text-amber-300', + }; + case 'running': + return { + label: t('orchPage.connections.status.running'), + dot: 'bg-sage-500', + tone: 'text-sage-700 dark:text-sage-300', + }; + case 'errored': + return { + label: t('orchPage.connections.status.error'), + dot: 'bg-coral-500', + tone: 'text-coral-700 dark:text-coral-300', + }; + case 'idle': + return { + label: t('orchPage.connections.status.idle'), + dot: 'bg-content-faint', + tone: 'text-content-muted', + }; + default: + return { + label: t('orchPage.connections.status.done'), + dot: 'bg-content-faint', + tone: 'text-content-muted', + }; + } +} + +function shortAddress(address: string): string { + return address.length <= 14 ? address : `${address.slice(0, 6)}…${address.slice(-4)}`; +} + +// ── Session view (in place) ────────────────────────────────────────────────── +function SessionView({ + session, + contactAddr, + handle, + onBack, +}: { + session: SessionSummary; + contactAddr: string; + handle: string | null; + onBack: () => void; +}) { + const { t } = useT(); + const { state, messages, refresh } = useSessionTranscript(session.sessionId); + const [body, setBody] = useState(''); + const [sending, setSending] = useState(false); + const [sendError, setSendError] = useState(null); + const label = session.label?.trim() || session.sessionId; + const meta = statusMeta(session.status, t); + + const submit = useCallback( + (event: FormEvent) => { + event.preventDefault(); + const trimmed = body.trim(); + if (!trimmed || sending) return; + setSending(true); + setSendError(null); + debug('[orchestration:connections] session reply: send session=%s', session.sessionId); + void orchestrationClient + .sendMasterMessage({ body: trimmed, recipient: contactAddr, sessionId: session.sessionId }) + .then(() => { + setBody(''); + void refresh(); + }) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + debug( + '[orchestration:connections] session reply: failed session=%s %s', + session.sessionId, + message + ); + setSendError(message); + }) + .finally(() => setSending(false)); + }, + [body, sending, contactAddr, session.sessionId, refresh] + ); + + return ( +
+ + + {label} + + {meta.label} + + + } + description={ + session.messageCount + ? t('orchPage.connections.messageCount').replace('{n}', String(session.messageCount)) + : undefined + }> + {state.status === 'loading' ? ( +

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

+ ) : state.status === 'error' ? ( +

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

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

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

+ ) : ( + + )} + {sendError ? ( +

+ {t('tinyplaceOrchestration.composer.sendFailed')}: {sendError} +

+ ) : null} +
+ setBody(e.target.value)} + placeholder={t('orchPage.connections.replyPlaceholder')} + data-testid="orch-session-reply-input" + 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-primary-500 focus:ring-2 focus:ring-primary-500/20" + /> + +
+
+
+ ); +} + +// ── One connection row + its nested sessions ───────────────────────────────── +function ConnectionRow({ + address, + handle, + sessions, + expanded, + onToggle, + onOpenSession, + onNewSession, + creating, +}: { + address: string; + handle: string | null; + sessions: SessionSummary[]; + expanded: boolean; + onToggle: () => void; + onOpenSession: (s: SessionSummary) => void; + onNewSession: () => void; + creating: boolean; +}) { + const { t } = useT(); + const online = sessions.some(s => s.active); + return ( +
  • + + + {expanded ? ( +
    + {sessions.map(session => { + const meta = statusMeta(session.status, t); + const label = session.label?.trim() || session.sessionId; + return ( + + ); + })} + +
    + ) : null} +
  • + ); +} export default function ConnectionsPanel({ onDiscover }: { onDiscover?: () => void }) { const { t } = useT(); const { state, runAction, pendingAction, actionError } = usePairing(); + const sessions = useContactSessions(); const [handles, setHandles] = useState>({}); + const [expanded, setExpanded] = useState>(new Set()); + const [open, setOpen] = useState<{ address: string; session: SessionSummary } | null>(null); + const [creating, setCreating] = useState(null); const snapshot = state.status === 'ok' ? state.snapshot : null; const accepted = useMemo( () => (snapshot?.contacts.contacts ?? []).filter(c => c.status === 'accepted'), [snapshot?.contacts.contacts] ); + const incoming = useMemo(() => snapshot?.requests.incoming ?? [], [snapshot?.requests.incoming]); const stats = snapshot?.stats ?? null; + const totalSessions = sessions.sessions.length; - // Best-effort @handle resolution for the listed contacts (address always shown). - const addressesKey = accepted.map(contactAddress).filter(Boolean).join(','); + // Best-effort @handle resolution (address always shown). + const addressesKey = [...accepted, ...incoming].map(contactAddress).filter(Boolean).join(','); useEffect(() => { const ids = addressesKey ? Array.from(new Set(addressesKey.split(','))) : []; if (ids.length === 0) return; @@ -54,6 +344,27 @@ export default function ConnectionsPanel({ onDiscover }: { onDiscover?: () => vo }; }, [addressesKey]); + const toggle = useCallback((address: string) => { + setExpanded(prev => { + const next = new Set(prev); + if (next.has(address)) next.delete(address); + else next.add(address); + return next; + }); + }, []); + + const newSession = useCallback( + (address: string) => { + setCreating(address); + void runAction(`new:${address}`, async () => { + const { session } = await orchestrationClient.sessionsCreate({ agentId: address }); + await sessions.refresh(); + setOpen({ address, session }); + }).finally(() => setCreating(null)); + }, + [runAction, sessions] + ); + if (state.status === 'loading') { return (

    vo ); } + if (open) { + // Prefer the live session row (socket-refreshed) so the header's + // status / message count / label stay current; fall back to the captured + // snapshot for a just-created session not yet in the list. + const liveSession = + sessions.sessions.find(s => s.sessionId === open.session.sessionId) ?? open.session; + return ( + setOpen(null)} + /> + ); + } + return ( -

    -
    +
    +
    + vo

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

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

      +

      + {shortAddress(address)} +

      +
      +
      + + +
      +
    • + ); + })} +
    +
    + ) : null} + - {t('orchPage.discover.linkAction')} - - ) - }> + description={t('orchPage.connections.description')}> {accepted.length === 0 ? (

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

    @@ -129,35 +499,21 @@ export default function ConnectionsPanel({ onDiscover }: { onDiscover?: () => vo )}
    ) : ( -
      +
        {accepted.map(contact => { const address = contactAddress(contact); - const handle = handles[address]; - const actionId = `block:${address}`; return ( -
      • -
        -

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

        -

        {address}

        -
        - -
      • + address={address} + handle={handles[address] ?? null} + sessions={sessions.byContact.get(address) ?? []} + expanded={expanded.has(address)} + onToggle={() => toggle(address)} + onOpenSession={session => setOpen({ address, session })} + onNewSession={() => newSession(address)} + creating={creating === address} + /> ); })}
      diff --git a/app/src/components/orchestration/SessionTranscript.tsx b/app/src/components/orchestration/SessionTranscript.tsx new file mode 100644 index 000000000..5b5a087e0 --- /dev/null +++ b/app/src/components/orchestration/SessionTranscript.tsx @@ -0,0 +1,221 @@ +/** + * SessionTranscript — renders an orchestration session/chat transcript in the + * app's normal chat-window visual language (right-aligned solid-primary user + * bubbles, left neutral agent bubbles), with v2 harness activity woven in as + * inline non-bubble blocks: merged tool call+result (red on failure), thinking, + * error, and approval_request. Shared by the Agent chat pane and the Connections + * session view so a session reads identically wherever it appears. + * + * Approvals are actionable only on your OWN agent (master/subconscious) — pass + * `onDecide`. In a peer session omit it and the approval renders read-only. + */ +import type { ReactElement } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { mergeToolActivity, type ToolActivity } from '../../lib/orchestration/mergeToolActivity'; +import type { ChatMessage } from '../../lib/orchestration/useOrchestrationChats'; +import { formatTime } from '../intelligence/orchestrationTabHelpers'; + +export type ApprovalDecision = 'approve' | 'deny' | 'always'; + +export interface SessionTranscriptProps { + messages: ChatMessage[]; + /** When present, approval rows show actionable buttons wired to this. */ + onDecide?: (message: ChatMessage, decision: ApprovalDecision) => void; +} + +/** + * Whether a row's `from` marks it as owner/user-authored (right-side bubble). + * The composer's own reply is mirrored back with role `"owner"`; the master + * optimistic append uses the localized "you". Both belong on the right. + */ +function isOwnerAuthored(from: string): boolean { + return from === 'you' || from === 'owner' || from === 'user'; +} + +/** Lightweight `**bold**` rendering without pulling in a markdown lib. */ +function renderInline(text: string): (string | ReactElement)[] { + return text.split(/(\*\*[^*]+\*\*)/g).map((part, i) => + part.startsWith('**') && part.endsWith('**') ? ( + + {part.slice(2, -2)} + + ) : ( + part + ) + ); +} + +function UserBubble({ message }: { message: ChatMessage }): ReactElement { + return ( +
      +
      +
      +

      {message.body}

      +
      + {formatTime(message.timestamp)} +
      +
      + ); +} + +function AgentBubble({ message }: { message: ChatMessage }): ReactElement { + return ( +
      +
      +
      +

      + {renderInline(message.body)} +

      +
      + {formatTime(message.timestamp)} +
      +
      + ); +} + +function ThinkingRow({ message }: { message: ChatMessage }): ReactElement { + return ( +
      +
      + +

      {message.body}

      +
      +
      + ); +} + +function ErrorRow({ message }: { message: ChatMessage }): ReactElement { + return ( +
      +
      + +

      + {message.body} +

      +
      +
      + ); +} + +function ToolBlock({ tool }: { tool: ToolActivity }): ReactElement { + return ( +
      +
      +
      + + {tool.toolName ? ( + + {tool.toolName} + + ) : null} + + {tool.command} + +
      + {tool.hasResult ? ( +
      + + {tool.failed ? '✕' : '↳'} + +
      +              {tool.output}
      +            
      +
      + ) : null} +
      +
      + ); +} + +function ApprovalRow({ + message, + onDecide, +}: { + message: ChatMessage; + onDecide?: (message: ChatMessage, decision: ApprovalDecision) => void; +}): ReactElement { + const { t } = useT(); + return ( +
      +
      +
      + + + {t('chat.approval.title')} + + {message.toolName ? ( + + {message.toolName} + + ) : null} +
      + + {message.body} + + {onDecide ? ( +
      + + + +
      + ) : null} +
      +
      + ); +} + +export default function SessionTranscript({ + messages, + onDecide, +}: SessionTranscriptProps): ReactElement { + const rows = mergeToolActivity(messages); + return ( +
      + {rows.map((row, i) => { + if (row.kind === 'tool') return ; + const { message } = row; + switch (message.eventKind) { + case 'user_prompt': + return ; + case 'agent_thinking': + return ; + case 'error': + return ; + case 'approval_request': + return ; + default: + // agent_message + legacy v1 rows → bubble by sender. Owner/user- + // authored rows (incl. a reply mirrored back with role "owner") sit + // on the right; everything else is an agent bubble on the left. + return isOwnerAuthored(message.from) ? ( + + ) : ( + + ); + } + })} +
      + ); +} diff --git a/app/src/components/orchestration/__tests__/AgentChatPanel.test.tsx b/app/src/components/orchestration/__tests__/AgentChatPanel.test.tsx index 4f46051df..a27ff99f6 100644 --- a/app/src/components/orchestration/__tests__/AgentChatPanel.test.tsx +++ b/app/src/components/orchestration/__tests__/AgentChatPanel.test.tsx @@ -1,22 +1,34 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SessionSummary } from '../../../lib/orchestration/orchestrationClient'; import AgentChatPanel from '../AgentChatPanel'; vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); const selectChat = vi.hoisted(() => vi.fn()); +const sendMessage = vi.hoisted(() => vi.fn().mockResolvedValue(true)); const chatsApi = vi.hoisted(() => ({ current: { - sessionsState: { status: 'ok' }, - messagesState: { status: 'ok' }, + sessionsState: { status: 'ok' as const }, + messagesState: { status: 'ok' as const }, + chats: [ + { id: 'master', title: 'Master', subtitle: 'you', unread: 0, messages: [] as unknown[] }, + { + id: 'subconscious', + title: 'Subconscious', + subtitle: 'loop', + unread: 0, + messages: [] as unknown[], + }, + ], selectedId: 'master', - selected: { id: 'master', title: 'Master', messages: [] }, - status: null, - masterError: null, + selected: { id: 'master', title: 'Master', messages: [] as unknown[] }, + status: null as unknown, + masterError: null as string | null, selectChat, refresh: vi.fn(), - sendMessage: vi.fn().mockResolvedValue(true), + sendMessage, }, })); vi.mock('../../../lib/orchestration/useOrchestrationChats', () => ({ @@ -25,81 +37,124 @@ vi.mock('../../../lib/orchestration/useOrchestrationChats', () => ({ 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()); +const sendMasterMessage = vi.hoisted(() => vi.fn().mockResolvedValue({ ok: true, messageId: 'm' })); +vi.mock('../../../lib/orchestration/orchestrationClient', async orig => ({ + ...(await orig()), + orchestrationClient: { sendMasterMessage }, +})); - 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'); +const contactSessions = vi.hoisted(() => ({ current: [] as SessionSummary[] })); +const transcript = vi.hoisted(() => ({ + current: { state: { status: 'ok' as const }, messages: [] as unknown[], refresh: vi.fn() }, +})); +vi.mock('../../../lib/orchestration/useOrchestrationSessions', () => ({ + useContactSessions: () => ({ + state: { status: 'ok' }, + sessions: contactSessions.current, + byContact: new Map(), + refresh: vi.fn(), + }), + useSessionTranscript: () => transcript.current, +})); + +const pinged: SessionSummary = { + sessionId: 's-auth', + agentId: '@peer', + source: 'claude', + status: 'waiting-approval', + chatKind: 'session', + lastMessageAt: '2026-07-08T00:00:00Z', + unread: 0, + active: true, + pinned: false, + label: 'auth-fix', + messageCount: 3, +}; + +describe('AgentChatPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + contactSessions.current = []; + chatsApi.current = { + ...chatsApi.current, + selectedId: 'master', + selected: { id: 'master', title: 'Master', messages: [] }, + masterError: null, + }; }); - it('switches to the subconscious chat when its tab is clicked', () => { + it('renders the thread rail and switches conversation', () => { render(); + expect(screen.getByTestId('orch-agent-tab-master')).toBeInTheDocument(); 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 }; + it('sends a master message from the composer', async () => { render(); - expect(screen.getByTestId('focus-pane')).toHaveAttribute('data-can-compose', 'false'); + fireEvent.change(screen.getByTestId('orch-agent-composer-input'), { target: { value: 'go' } }); + fireEvent.click(screen.getByTestId('orch-agent-composer-send')); + await waitFor(() => + expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ id: 'master' }), 'go') + ); }); - it('sends a composed message through the chats api', async () => { - const sendMessage = vi.fn().mockResolvedValue(true); - chatsApi.current = { ...chatsApi.current, selectedId: 'master', selectChat, sendMessage }; + it('shows the steering header + runs a review on the subconscious thread', () => { + chatsApi.current = { + ...chatsApi.current, + selectedId: 'subconscious', + selected: { id: 'subconscious', title: 'Subconscious', messages: [] }, + }; render(); - fireEvent.change(screen.getByTestId('focus-input'), { target: { value: 'hello' } }); - fireEvent.submit(screen.getByTestId('focus-form')); - await waitFor(() => expect(sendMessage).toHaveBeenCalledWith(expect.anything(), 'hello')); + expect(screen.getByTestId('orch-agent-steering-header')).toBeInTheDocument(); + fireEvent.click(screen.getByText('tinyplaceOrchestration.steeringHeader.runReview')); + expect(subconsciousTrigger).toHaveBeenCalledWith('tinyplace'); }); - it('triggers a subconscious steering review', async () => { - chatsApi.current = { ...chatsApi.current, selectChat }; + it('opens a session side-tab from a View-session card and replies (no auto-open)', async () => { + contactSessions.current = [pinged]; render(); - fireEvent.click(screen.getByTestId('focus-steering')); - await waitFor(() => expect(subconsciousTrigger).toHaveBeenCalledWith('tinyplace')); + expect(screen.queryByTestId('orch-agent-session-drawer')).not.toBeInTheDocument(); + fireEvent.click(screen.getByTestId('orch-agent-view-session-s-auth')); + expect(screen.getByTestId('orch-agent-session-drawer')).toBeInTheDocument(); + + fireEvent.change(screen.getByTestId('orch-agent-drawer-reply'), { target: { value: 'hi' } }); + fireEvent.click( + screen.getByTestId('orch-agent-session-drawer').querySelector('button[type="submit"]')! + ); + await waitFor(() => + expect(sendMasterMessage).toHaveBeenCalledWith({ + body: 'hi', + recipient: '@peer', + sessionId: 's-auth', + }) + ); + + fireEvent.click(screen.getByTestId('orch-agent-drawer-close')); + expect(screen.queryByTestId('orch-agent-session-drawer')).not.toBeInTheDocument(); + }); + + it('surfaces a drawer reply failure', async () => { + contactSessions.current = [pinged]; + sendMasterMessage.mockRejectedValueOnce(new Error('boom')); + render(); + fireEvent.click(screen.getByTestId('orch-agent-view-session-s-auth')); + fireEvent.change(screen.getByTestId('orch-agent-drawer-reply'), { target: { value: 'hi' } }); + fireEvent.click( + screen.getByTestId('orch-agent-session-drawer').querySelector('button[type="submit"]')! + ); + expect(await screen.findByTestId('orch-agent-drawer-reply-error')).toHaveTextContent('boom'); + }); + + it('shows an error state when the transcript fails to load', () => { + chatsApi.current = { + ...chatsApi.current, + messagesState: { status: 'error', message: 'load failed' } as never, + }; + render(); + expect(screen.getByText(/load failed/)).toBeInTheDocument(); }); }); diff --git a/app/src/components/orchestration/__tests__/ConnectionsPanel.test.tsx b/app/src/components/orchestration/__tests__/ConnectionsPanel.test.tsx index cc9a26ff1..440f3f37b 100644 --- a/app/src/components/orchestration/__tests__/ConnectionsPanel.test.tsx +++ b/app/src/components/orchestration/__tests__/ConnectionsPanel.test.tsx @@ -1,11 +1,16 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SessionSummary } from '../../../lib/orchestration/orchestrationClient'; import ConnectionsPanel from '../ConnectionsPanel'; vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); -const runAction = vi.hoisted(() => vi.fn()); +const runAction = vi.hoisted(() => + vi.fn(async (_id: string, fn: () => Promise) => { + await fn(); + }) +); const pairing = vi.hoisted(() => ({ current: { state: { status: 'ok' as const, snapshot: {} as Record }, @@ -17,22 +22,69 @@ const pairing = vi.hoisted(() => ({ })); vi.mock('../../../lib/orchestration/usePairing', () => ({ usePairing: () => pairing.current })); -const blockRequest = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const acceptRequest = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const declineRequest = 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 } }, + apiClient: { + orchestrationPairing: { acceptRequest, declineRequest, blockRequest: vi.fn() }, + directory: { reverse }, + }, })); -function okState(accepted: unknown[], stats?: unknown) { +const sessionsCreate = vi.hoisted(() => vi.fn()); +const sendMasterMessage = vi.hoisted(() => vi.fn().mockResolvedValue({ ok: true, messageId: 'm' })); +vi.mock('../../../lib/orchestration/orchestrationClient', async orig => ({ + ...(await orig()), + orchestrationClient: { sessionsCreate, sendMasterMessage }, +})); + +const sessionsHook = vi.hoisted(() => ({ + byContact: new Map(), + refresh: vi.fn(), +})); +const transcriptHook = vi.hoisted(() => ({ + current: { state: { status: 'ok' as const }, messages: [] as unknown[], refresh: vi.fn() }, +})); +vi.mock('../../../lib/orchestration/useOrchestrationSessions', () => ({ + useContactSessions: () => ({ + state: { status: 'ok' }, + sessions: [...sessionsHook.byContact.values()].flat(), + byContact: sessionsHook.byContact, + refresh: sessionsHook.refresh, + }), + useSessionTranscript: () => transcriptHook.current, +})); + +const ADDR = '3icjiLXhn6BMv43MsHjpKKxm7hEYBk7R5rvNXB1HUk7g'; + +function session(over: Partial): SessionSummary { + return { + sessionId: 's1', + agentId: ADDR, + source: 'claude', + status: 'waiting-approval', + chatKind: 'session', + lastMessageAt: '2026-07-08T00:00:00Z', + unread: 0, + active: true, + pinned: false, + label: 'auth-fix', + messageCount: 5, + ...over, + }; +} + +function okState(accepted: unknown[], incoming: unknown[] = []) { return { status: 'ok' as const, snapshot: { contacts: { contacts: accepted }, - requests: { incoming: [], outgoing: [] }, - stats: stats ?? { + requests: { incoming, outgoing: [] }, + stats: { agentId: 'me', contactCount: accepted.length, - pendingIncoming: 0, + pendingIncoming: incoming.length, pendingOutgoing: 0, }, }, @@ -42,6 +94,8 @@ function okState(accepted: unknown[], stats?: unknown) { describe('ConnectionsPanel', () => { beforeEach(() => { vi.clearAllMocks(); + sessionsHook.byContact = new Map(); + transcriptHook.current = { state: { status: 'ok' }, messages: [], refresh: vi.fn() }; pairing.current = { ...pairing.current, pendingAction: null, actionError: null }; }); @@ -60,16 +114,63 @@ describe('ConnectionsPanel', () => { 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, - }; + it('accepts and declines an incoming request', () => { + const req = { agentId: ADDR, status: 'pending', direction: 'incoming' }; + pairing.current = { ...pairing.current, state: okState([], [req]) 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)); + fireEvent.click(screen.getByText('tinyplaceOrchestration.pairing.accept')); + expect(acceptRequest).toHaveBeenCalledWith(ADDR); + fireEvent.click(screen.getByText('tinyplaceOrchestration.pairing.decline')); + expect(declineRequest).toHaveBeenCalledWith(ADDR); + }); + + it('expands a contact to reveal its sessions, opens one, and replies', async () => { + const contact = { agentId: ADDR, status: 'accepted', direction: 'outgoing' }; + sessionsHook.byContact.set(ADDR, [session({})]); + pairing.current = { ...pairing.current, state: okState([contact]) as never }; + render(); + + fireEvent.click(screen.getByTestId(`orch-connection-${ADDR}`).querySelector('button')!); + const sessionRow = await screen.findByTestId('orch-session-s1'); + expect(sessionRow).toHaveTextContent('auth-fix'); + + fireEvent.click(sessionRow); + expect(screen.getByTestId('orch-session-view')).toBeInTheDocument(); + + fireEvent.change(screen.getByTestId('orch-session-reply-input'), { target: { value: 'hi' } }); + fireEvent.click(screen.getByTestId('orch-session-reply-send')); + await waitFor(() => + expect(sendMasterMessage).toHaveBeenCalledWith({ + body: 'hi', + recipient: ADDR, + sessionId: 's1', + }) + ); + + fireEvent.click(screen.getByTestId('orch-session-back')); + expect(screen.getByTestId('orch-connections-panel')).toBeInTheDocument(); + }); + + it('surfaces a reply send failure instead of swallowing it', async () => { + const contact = { agentId: ADDR, status: 'accepted', direction: 'outgoing' }; + sessionsHook.byContact.set(ADDR, [session({})]); + sendMasterMessage.mockRejectedValueOnce(new Error('relay down')); + pairing.current = { ...pairing.current, state: okState([contact]) as never }; + render(); + fireEvent.click(screen.getByTestId(`orch-connection-${ADDR}`).querySelector('button')!); + fireEvent.click(await screen.findByTestId('orch-session-s1')); + fireEvent.change(screen.getByTestId('orch-session-reply-input'), { target: { value: 'hi' } }); + fireEvent.click(screen.getByTestId('orch-session-reply-send')); + expect(await screen.findByTestId('orch-session-reply-error')).toHaveTextContent('relay down'); + }); + + it('creates a new session under a contact', async () => { + const contact = { agentId: ADDR, status: 'accepted', direction: 'outgoing' }; + sessionsCreate.mockResolvedValue({ session: session({ sessionId: 's-new' }) }); + pairing.current = { ...pairing.current, state: okState([contact]) as never }; + render(); + fireEvent.click(screen.getByTestId(`orch-connection-${ADDR}`).querySelector('button')!); + fireEvent.click(await screen.findByTestId(`orch-new-session-${ADDR}`)); + await waitFor(() => expect(sessionsCreate).toHaveBeenCalledWith({ agentId: ADDR })); }); }); diff --git a/app/src/components/orchestration/__tests__/SessionTranscript.test.tsx b/app/src/components/orchestration/__tests__/SessionTranscript.test.tsx new file mode 100644 index 000000000..5f94458ca --- /dev/null +++ b/app/src/components/orchestration/__tests__/SessionTranscript.test.tsx @@ -0,0 +1,85 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ChatMessage } from '../../../lib/orchestration/useOrchestrationChats'; +import SessionTranscript from '../SessionTranscript'; + +vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); + +const msg = (over: Partial): ChatMessage => ({ + id: 'x', + from: 'agent', + body: '', + timestamp: '2026-07-08T17:00:00Z', + encrypted: false, + ...over, +}); + +describe('SessionTranscript', () => { + it('renders user vs agent bubbles by sender', () => { + render( + + ); + expect( + screen.getByText('hello').closest('[data-event-kind="user_prompt"]') + ).toBeInTheDocument(); + expect( + screen.getByText('hi back').closest('[data-event-kind="agent_message"]') + ).toBeInTheDocument(); + }); + + it('renders an owner-authored reply (role "owner") as a user bubble', () => { + // A composer reply is mirrored back with role "owner" and no eventKind; + // it must sit on the right (primary bubble), not as a left agent bubble. + render(); + expect(screen.getByText('my reply').closest('.bg-primary-500')).toBeInTheDocument(); + }); + + it('merges a tool_call+result and marks failure', () => { + render( + + ); + const tool = screen.getByText('ls').closest('[data-event-kind="tool_call"]')!; + expect(tool).toHaveAttribute('data-failed', 'true'); + expect(screen.getByText('boom')).toBeInTheDocument(); + }); + + it('renders an approval read-only without onDecide', () => { + render( + + ); + expect(screen.getByText('chat.approval.title')).toBeInTheDocument(); + expect(screen.queryByText('chat.approval.approve')).not.toBeInTheDocument(); + }); + + it('wires approval buttons to onDecide', () => { + const onDecide = vi.fn(); + const approval = msg({ id: 'ap', eventKind: 'approval_request', body: 'run it' }); + render(); + fireEvent.click(screen.getByText('chat.approval.approve')); + expect(onDecide).toHaveBeenCalledWith(approval, 'approve'); + fireEvent.click(screen.getByText('chat.approval.deny')); + expect(onDecide).toHaveBeenCalledWith(approval, 'deny'); + }); +}); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 1fae8784e..9665905f4 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -180,13 +180,25 @@ const messages: TranslationMap = { 'orchPage.agent.mainTab': 'الوكيل الرئيسي', 'orchPage.agent.subconsciousTab': 'اللاوعي', 'orchPage.agent.description': 'تحدّث مع الوكيل الرئيسي وراقب لاوعيه', + 'orchPage.agent.viewSession': 'عرض الجلسة', 'orchPage.connections.nav': 'الاتصالات', 'orchPage.connections.title': 'الوكلاء المرتبطون', - 'orchPage.connections.description': 'الأقران الذين يمكن لوكيلك الرئيسي التنسيق معهم', + 'orchPage.connections.description': 'الأقران الذين ينسّق معهم وكيلك — وسّع أحدهم لعرض جلساتك معه', 'orchPage.connections.empty': 'لا توجد اتصالات بعد.', 'orchPage.connections.emptyCta': 'إضافة اتصال', 'orchPage.connections.statContacts': 'الاتصالات', 'orchPage.connections.statPending': 'قيد الانتظار', + 'orchPage.connections.statSessions': 'الجلسات', + 'orchPage.connections.sessionCount': '{n} جلسة', + 'orchPage.connections.noSessions': 'لا توجد جلسات', + 'orchPage.connections.messageCount': '{n} رسالة', + 'orchPage.connections.back': 'الاتصالات', + 'orchPage.connections.replyPlaceholder': 'رد…', + 'orchPage.connections.status.needsYou': 'يحتاج إليك', + 'orchPage.connections.status.running': 'قيد التشغيل', + 'orchPage.connections.status.idle': 'خامل', + 'orchPage.connections.status.done': 'تم', + 'orchPage.connections.status.error': 'خطأ', 'orchPage.connections.pendingHint': 'بانتظار القبول', 'orchPage.discover.nav': 'اكتشاف', 'orchPage.discover.linkAction': 'إضافة', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 1213d9664..c4cb141b1 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -185,13 +185,26 @@ const messages: TranslationMap = { 'orchPage.agent.mainTab': 'প্রধান এজেন্ট', 'orchPage.agent.subconsciousTab': 'অবচেতন', 'orchPage.agent.description': 'প্রধান এজেন্টের সাথে চ্যাট করুন এবং তার অবচেতন দেখুন', + 'orchPage.agent.viewSession': 'সেশন দেখুন', 'orchPage.connections.nav': 'সংযোগ', 'orchPage.connections.title': 'সংযুক্ত এজেন্ট', - 'orchPage.connections.description': 'যাদের সাথে আপনার প্রধান এজেন্ট সমন্বয় করতে পারে', + 'orchPage.connections.description': + 'যাদের সঙ্গে আপনার এজেন্ট সমন্বয় করে — একটি প্রসারিত করে তাদের সঙ্গে আপনার সেশনগুলো দেখুন', 'orchPage.connections.empty': 'এখনও কোনো সংযোগ নেই।', 'orchPage.connections.emptyCta': 'একটি সংযোগ যোগ করুন', 'orchPage.connections.statContacts': 'সংযোগ', 'orchPage.connections.statPending': 'অপেক্ষমাণ', + 'orchPage.connections.statSessions': 'সেশন', + 'orchPage.connections.sessionCount': '{n}টি সেশন', + 'orchPage.connections.noSessions': 'কোনো সেশন নেই', + 'orchPage.connections.messageCount': '{n}টি বার্তা', + 'orchPage.connections.back': 'সংযোগ', + 'orchPage.connections.replyPlaceholder': 'উত্তর…', + 'orchPage.connections.status.needsYou': 'আপনাকে প্রয়োজন', + 'orchPage.connections.status.running': 'চলছে', + 'orchPage.connections.status.idle': 'নিষ্ক্রিয়', + 'orchPage.connections.status.done': 'সম্পন্ন', + 'orchPage.connections.status.error': 'ত্রুটি', 'orchPage.connections.pendingHint': 'গ্রহণের অপেক্ষায়', 'orchPage.discover.nav': 'আবিষ্কার', 'orchPage.discover.linkAction': 'যোগ করুন', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 2f4634f0e..4edba2f6e 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -191,13 +191,26 @@ const messages: TranslationMap = { 'orchPage.agent.mainTab': 'Hauptagent', 'orchPage.agent.subconsciousTab': 'Unterbewusstsein', 'orchPage.agent.description': 'Chatte mit dem Hauptagenten und beobachte sein Unterbewusstsein', + 'orchPage.agent.viewSession': 'Sitzung ansehen', 'orchPage.connections.nav': 'Verbindungen', 'orchPage.connections.title': 'Verknüpfte Agenten', - 'orchPage.connections.description': 'Peers, mit denen dein Hauptagent zusammenarbeiten kann', + 'orchPage.connections.description': + 'Peers, mit denen dein Agent zusammenarbeitet — erweitere einen, um deine Sitzungen damit anzuzeigen', 'orchPage.connections.empty': 'Noch keine Verbindungen.', 'orchPage.connections.emptyCta': 'Verbindung hinzufügen', 'orchPage.connections.statContacts': 'Verbindungen', 'orchPage.connections.statPending': 'Ausstehend', + 'orchPage.connections.statSessions': 'Sitzungen', + 'orchPage.connections.sessionCount': '{n} Sitzungen', + 'orchPage.connections.noSessions': 'Keine Sitzungen', + 'orchPage.connections.messageCount': '{n} Nachrichten', + 'orchPage.connections.back': 'Verbindungen', + 'orchPage.connections.replyPlaceholder': 'Antworten…', + 'orchPage.connections.status.needsYou': 'Braucht dich', + 'orchPage.connections.status.running': 'Läuft', + 'orchPage.connections.status.idle': 'Inaktiv', + 'orchPage.connections.status.done': 'Fertig', + 'orchPage.connections.status.error': 'Fehler', 'orchPage.connections.pendingHint': 'Warten auf Annahme', 'orchPage.discover.nav': 'Entdecken', 'orchPage.discover.linkAction': 'Hinzufügen', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 5ce017b4e..7e3110aef 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -37,14 +37,27 @@ const en: TranslationMap = { 'orchPage.agent.mainTab': 'Main agent', 'orchPage.agent.subconsciousTab': 'Subconscious', 'orchPage.agent.description': 'Chat with the main agent and watch its subconscious', + 'orchPage.agent.viewSession': 'View session', 'orchPage.connections.nav': 'Connections', 'orchPage.connections.title': 'Linked agents', - 'orchPage.connections.description': 'Peers your main agent can coordinate with', + 'orchPage.connections.description': + 'Peers your agent coordinates with — expand one to see your sessions with it', 'orchPage.connections.empty': 'No connections yet.', 'orchPage.connections.emptyCta': 'Add a connection', 'orchPage.connections.statContacts': 'Connections', + 'orchPage.connections.statSessions': 'Sessions', 'orchPage.connections.statPending': 'Pending', 'orchPage.connections.pendingHint': 'Awaiting acceptance', + 'orchPage.connections.sessionCount': '{n} sessions', + 'orchPage.connections.noSessions': 'No sessions', + 'orchPage.connections.messageCount': '{n} messages', + 'orchPage.connections.back': 'Connections', + 'orchPage.connections.replyPlaceholder': 'Reply…', + 'orchPage.connections.status.needsYou': 'Needs you', + 'orchPage.connections.status.running': 'Running', + 'orchPage.connections.status.idle': 'Idle', + 'orchPage.connections.status.done': 'Done', + 'orchPage.connections.status.error': 'Error', 'orchPage.discover.nav': 'Discover', 'orchPage.discover.linkAction': 'Add', 'orchPage.discover.identityTitle': 'Your discoverability', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 7874703e1..90ada019b 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -188,13 +188,26 @@ const messages: TranslationMap = { 'orchPage.agent.mainTab': 'Agente principal', 'orchPage.agent.subconsciousTab': 'Subconsciente', 'orchPage.agent.description': 'Chatea con el agente principal y observa su subconsciente', + 'orchPage.agent.viewSession': 'Ver sesión', 'orchPage.connections.nav': 'Conexiones', 'orchPage.connections.title': 'Agentes vinculados', - 'orchPage.connections.description': 'Pares con los que tu agente principal puede coordinarse', + 'orchPage.connections.description': + 'Pares con los que se coordina tu agente: expande uno para ver tus sesiones con él', '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.statSessions': 'Sesiones', + 'orchPage.connections.sessionCount': '{n} sesiones', + 'orchPage.connections.noSessions': 'Sin sesiones', + 'orchPage.connections.messageCount': '{n} mensajes', + 'orchPage.connections.back': 'Conexiones', + 'orchPage.connections.replyPlaceholder': 'Responder…', + 'orchPage.connections.status.needsYou': 'Te necesita', + 'orchPage.connections.status.running': 'En ejecución', + 'orchPage.connections.status.idle': 'Inactivo', + 'orchPage.connections.status.done': 'Hecho', + 'orchPage.connections.status.error': 'Fallo', 'orchPage.connections.pendingHint': 'Esperando aceptación', 'orchPage.discover.nav': 'Descubrir', 'orchPage.discover.linkAction': 'Agregar', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index a5ca6bff5..c6a5a3d62 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -188,14 +188,26 @@ const messages: TranslationMap = { 'orchPage.agent.mainTab': 'Agent principal', 'orchPage.agent.subconsciousTab': 'Subconscient', 'orchPage.agent.description': "Discutez avec l'agent principal et observez son subconscient", + 'orchPage.agent.viewSession': 'Voir la session', 'orchPage.connections.nav': 'Connexions', 'orchPage.connections.title': 'Agents liés', 'orchPage.connections.description': - 'Pairs avec lesquels votre agent principal peut se coordonner', + 'Pairs avec lesquels votre agent se coordonne : développez-en un pour voir vos sessions avec lui', '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.statSessions': 'Séances', + 'orchPage.connections.sessionCount': '{n} séances', + 'orchPage.connections.noSessions': 'Aucune séance', + 'orchPage.connections.messageCount': '{n} messages échangés', + 'orchPage.connections.back': 'Connexions', + 'orchPage.connections.replyPlaceholder': 'Répondre…', + 'orchPage.connections.status.needsYou': 'Requiert votre attention', + 'orchPage.connections.status.running': 'En cours', + 'orchPage.connections.status.idle': 'Inactif', + 'orchPage.connections.status.done': 'Terminé', + 'orchPage.connections.status.error': 'Erreur', 'orchPage.connections.pendingHint': "En attente d'acceptation", 'orchPage.discover.nav': 'Découvrir', 'orchPage.discover.linkAction': 'Ajouter', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index c93a8cf3f..891c80379 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -185,13 +185,26 @@ const messages: TranslationMap = { 'orchPage.agent.mainTab': 'मुख्य एजेंट', 'orchPage.agent.subconsciousTab': 'अवचेतन', 'orchPage.agent.description': 'मुख्य एजेंट से चैट करें और उसका अवचेतन देखें', + 'orchPage.agent.viewSession': 'सत्र देखें', 'orchPage.connections.nav': 'कनेक्शन', 'orchPage.connections.title': 'लिंक किए गए एजेंट', - 'orchPage.connections.description': 'वे साथी जिनके साथ आपका मुख्य एजेंट समन्वय कर सकता है', + 'orchPage.connections.description': + 'वे साथी जिनके साथ आपका एजेंट समन्वय करता है — किसी एक को विस्तृत करके उनके साथ अपने सत्र देखें', 'orchPage.connections.empty': 'अभी तक कोई कनेक्शन नहीं।', 'orchPage.connections.emptyCta': 'कनेक्शन जोड़ें', 'orchPage.connections.statContacts': 'कनेक्शन', 'orchPage.connections.statPending': 'लंबित', + 'orchPage.connections.statSessions': 'सत्र', + 'orchPage.connections.sessionCount': '{n} सत्र', + 'orchPage.connections.noSessions': 'कोई सत्र नहीं', + 'orchPage.connections.messageCount': '{n} संदेश', + 'orchPage.connections.back': 'कनेक्शन', + 'orchPage.connections.replyPlaceholder': 'उत्तर दें…', + 'orchPage.connections.status.needsYou': 'आपकी ज़रूरत है', + 'orchPage.connections.status.running': 'चल रहा है', + 'orchPage.connections.status.idle': 'निष्क्रिय', + 'orchPage.connections.status.done': 'पूर्ण', + 'orchPage.connections.status.error': 'त्रुटि', 'orchPage.connections.pendingHint': 'स्वीकृति की प्रतीक्षा में', 'orchPage.discover.nav': 'खोजें', 'orchPage.discover.linkAction': 'जोड़ें', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index e33da0a0a..0cbb7ead4 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -186,13 +186,26 @@ const messages: TranslationMap = { 'orchPage.agent.mainTab': 'Agen utama', 'orchPage.agent.subconsciousTab': 'Bawah sadar', 'orchPage.agent.description': 'Mengobrol dengan agen utama dan amati bawah sadarnya', + 'orchPage.agent.viewSession': 'Lihat sesi', 'orchPage.connections.nav': 'Koneksi', 'orchPage.connections.title': 'Agen tertaut', - 'orchPage.connections.description': 'Rekan yang dapat dikoordinasikan oleh agen utama Anda', + 'orchPage.connections.description': + 'Rekan yang dikoordinasikan agen Anda — perluas salah satu untuk melihat sesi Anda dengannya', 'orchPage.connections.empty': 'Belum ada koneksi.', 'orchPage.connections.emptyCta': 'Tambahkan koneksi', 'orchPage.connections.statContacts': 'Koneksi', 'orchPage.connections.statPending': 'Menunggu', + 'orchPage.connections.statSessions': 'Sesi', + 'orchPage.connections.sessionCount': '{n} sesi', + 'orchPage.connections.noSessions': 'Tidak ada sesi', + 'orchPage.connections.messageCount': '{n} pesan', + 'orchPage.connections.back': 'Koneksi', + 'orchPage.connections.replyPlaceholder': 'Balas…', + 'orchPage.connections.status.needsYou': 'Perlu Anda', + 'orchPage.connections.status.running': 'Berjalan', + 'orchPage.connections.status.idle': 'Menganggur', + 'orchPage.connections.status.done': 'Selesai', + 'orchPage.connections.status.error': 'Kesalahan', 'orchPage.connections.pendingHint': 'Menunggu persetujuan', 'orchPage.discover.nav': 'Temukan', 'orchPage.discover.linkAction': 'Tambah', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 136cfd5a0..dcceb358f 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -188,13 +188,26 @@ const messages: TranslationMap = { 'orchPage.agent.mainTab': 'Agente principale', 'orchPage.agent.subconsciousTab': 'Subconscio', 'orchPage.agent.description': "Chatta con l'agente principale e osserva il suo subconscio", + 'orchPage.agent.viewSession': 'Vedi sessione', 'orchPage.connections.nav': 'Connessioni', 'orchPage.connections.title': 'Agenti collegati', - 'orchPage.connections.description': 'Peer con cui il tuo agente principale può coordinarsi', + 'orchPage.connections.description': + 'Peer con cui il tuo agente si coordina: espandine uno per vedere le tue sessioni con esso', 'orchPage.connections.empty': 'Ancora nessuna connessione.', 'orchPage.connections.emptyCta': 'Aggiungi una connessione', 'orchPage.connections.statContacts': 'Connessioni', 'orchPage.connections.statPending': 'In sospeso', + 'orchPage.connections.statSessions': 'Sessioni', + 'orchPage.connections.sessionCount': '{n} sessioni', + 'orchPage.connections.noSessions': 'Nessuna sessione', + 'orchPage.connections.messageCount': '{n} messaggi', + 'orchPage.connections.back': 'Connessioni', + 'orchPage.connections.replyPlaceholder': 'Rispondi…', + 'orchPage.connections.status.needsYou': 'Richiede la tua attenzione', + 'orchPage.connections.status.running': 'In esecuzione', + 'orchPage.connections.status.idle': 'Inattivo', + 'orchPage.connections.status.done': 'Completato', + 'orchPage.connections.status.error': 'Errore', 'orchPage.connections.pendingHint': 'In attesa di accettazione', 'orchPage.discover.nav': 'Scopri', 'orchPage.discover.linkAction': 'Aggiungi', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 42dac7d55..039a79f42 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -182,13 +182,26 @@ const messages: TranslationMap = { 'orchPage.agent.mainTab': '메인 에이전트', 'orchPage.agent.subconsciousTab': '잠재의식', 'orchPage.agent.description': '메인 에이전트와 대화하고 잠재의식을 지켜보세요', + 'orchPage.agent.viewSession': '세션 보기', 'orchPage.connections.nav': '연결', 'orchPage.connections.title': '연결된 에이전트', - 'orchPage.connections.description': '메인 에이전트가 협력할 수 있는 상대', + 'orchPage.connections.description': + '에이전트가 협력하는 상대 — 하나를 펼쳐 해당 상대와의 세션을 확인하세요', 'orchPage.connections.empty': '아직 연결이 없습니다.', 'orchPage.connections.emptyCta': '연결 추가', 'orchPage.connections.statContacts': '연결', 'orchPage.connections.statPending': '대기 중', + 'orchPage.connections.statSessions': '세션', + 'orchPage.connections.sessionCount': '{n}개 세션', + 'orchPage.connections.noSessions': '세션 없음', + 'orchPage.connections.messageCount': '{n}개 메시지', + 'orchPage.connections.back': '연결', + 'orchPage.connections.replyPlaceholder': '답장…', + 'orchPage.connections.status.needsYou': '확인 필요', + 'orchPage.connections.status.running': '실행 중', + 'orchPage.connections.status.idle': '유휴', + 'orchPage.connections.status.done': '완료', + 'orchPage.connections.status.error': '오류', 'orchPage.connections.pendingHint': '수락 대기 중', 'orchPage.discover.nav': '탐색', 'orchPage.discover.linkAction': '추가', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 1e50f418a..b0628785e 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -190,13 +190,26 @@ const messages: TranslationMap = { '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.agent.viewSession': 'Zobacz sesję', '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.description': + 'Partnerzy, z którymi koordynuje się Twój agent — rozwiń jednego, aby zobaczyć swoje sesje z nim', 'orchPage.connections.empty': 'Brak połączeń.', 'orchPage.connections.emptyCta': 'Dodaj połączenie', 'orchPage.connections.statContacts': 'Połączenia', 'orchPage.connections.statPending': 'Oczekujące', + 'orchPage.connections.statSessions': 'Sesje', + 'orchPage.connections.sessionCount': '{n} sesji', + 'orchPage.connections.noSessions': 'Brak sesji', + 'orchPage.connections.messageCount': '{n} wiadomości', + 'orchPage.connections.back': 'Połączenia', + 'orchPage.connections.replyPlaceholder': 'Odpowiedz…', + 'orchPage.connections.status.needsYou': 'Wymaga Ciebie', + 'orchPage.connections.status.running': 'Uruchomione', + 'orchPage.connections.status.idle': 'Bezczynny', + 'orchPage.connections.status.done': 'Gotowe', + 'orchPage.connections.status.error': 'Błąd', 'orchPage.connections.pendingHint': 'Oczekiwanie na akceptację', 'orchPage.discover.nav': 'Odkrywaj', 'orchPage.discover.linkAction': 'Dodaj', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 9b1e99556..c5bb8b7d1 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -187,13 +187,26 @@ const messages: TranslationMap = { 'orchPage.agent.mainTab': 'Agente principal', 'orchPage.agent.subconsciousTab': 'Subconsciente', 'orchPage.agent.description': 'Converse com o agente principal e observe seu subconsciente', + 'orchPage.agent.viewSession': 'Ver sessão', '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.description': + 'Pares com quem seu agente se coordena — expanda um para ver suas sessões com ele', '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.statSessions': 'Sessões', + 'orchPage.connections.sessionCount': '{n} sessões', + 'orchPage.connections.noSessions': 'Sem sessões', + 'orchPage.connections.messageCount': '{n} mensagens', + 'orchPage.connections.back': 'Conexões', + 'orchPage.connections.replyPlaceholder': 'Responder…', + 'orchPage.connections.status.needsYou': 'Precisa de você', + 'orchPage.connections.status.running': 'Em execução', + 'orchPage.connections.status.idle': 'Inativo', + 'orchPage.connections.status.done': 'Concluído', + 'orchPage.connections.status.error': 'Erro', 'orchPage.connections.pendingHint': 'Aguardando aceitação', 'orchPage.discover.nav': 'Descobrir', 'orchPage.discover.linkAction': 'Adicionar', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index b79720bd6..27c4cd655 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -190,14 +190,26 @@ const messages: TranslationMap = { 'orchPage.agent.mainTab': 'Главный агент', 'orchPage.agent.subconsciousTab': 'Подсознание', 'orchPage.agent.description': 'Общайтесь с главным агентом и наблюдайте за его подсознанием', + 'orchPage.agent.viewSession': 'Показать сессию', 'orchPage.connections.nav': 'Связи', 'orchPage.connections.title': 'Связанные агенты', 'orchPage.connections.description': - 'Партнёры, с которыми может координироваться ваш главный агент', + 'Партнёры, с которыми координируется ваш агент — разверните одного, чтобы увидеть ваши сессии с ним', 'orchPage.connections.empty': 'Пока нет связей.', 'orchPage.connections.emptyCta': 'Добавить связь', 'orchPage.connections.statContacts': 'Связи', 'orchPage.connections.statPending': 'Ожидание', + 'orchPage.connections.statSessions': 'Сессии', + 'orchPage.connections.sessionCount': '{n} сессий', + 'orchPage.connections.noSessions': 'Нет сессий', + 'orchPage.connections.messageCount': '{n} сообщений', + 'orchPage.connections.back': 'Связи', + 'orchPage.connections.replyPlaceholder': 'Ответить…', + 'orchPage.connections.status.needsYou': 'Требует вас', + 'orchPage.connections.status.running': 'Выполняется', + 'orchPage.connections.status.idle': 'Неактивно', + 'orchPage.connections.status.done': 'Готово', + 'orchPage.connections.status.error': 'Ошибка', 'orchPage.connections.pendingHint': 'Ожидает подтверждения', 'orchPage.discover.nav': 'Обзор', 'orchPage.discover.linkAction': 'Добавить', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index edc19ff25..d3c2ef1fa 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -168,13 +168,25 @@ const messages: TranslationMap = { 'orchPage.agent.mainTab': '主智能体', 'orchPage.agent.subconsciousTab': '潜意识', 'orchPage.agent.description': '与主智能体聊天并观察其潜意识', + 'orchPage.agent.viewSession': '查看会话', 'orchPage.connections.nav': '连接', 'orchPage.connections.title': '已关联智能体', - 'orchPage.connections.description': '你的主智能体可以协调的伙伴', + 'orchPage.connections.description': '你的智能体协作的伙伴 — 展开其中一个即可查看你与它的会话', 'orchPage.connections.empty': '暂无连接。', 'orchPage.connections.emptyCta': '添加连接', 'orchPage.connections.statContacts': '连接', 'orchPage.connections.statPending': '待处理', + 'orchPage.connections.statSessions': '会话', + 'orchPage.connections.sessionCount': '{n} 个会话', + 'orchPage.connections.noSessions': '暂无会话', + 'orchPage.connections.messageCount': '{n} 条消息', + 'orchPage.connections.back': '连接', + 'orchPage.connections.replyPlaceholder': '回复…', + 'orchPage.connections.status.needsYou': '需要你', + 'orchPage.connections.status.running': '运行中', + 'orchPage.connections.status.idle': '空闲', + 'orchPage.connections.status.done': '完成', + 'orchPage.connections.status.error': '错误', 'orchPage.connections.pendingHint': '等待接受', 'orchPage.discover.nav': '发现', 'orchPage.discover.linkAction': '添加', diff --git a/app/src/lib/orchestration/mergeToolActivity.test.ts b/app/src/lib/orchestration/mergeToolActivity.test.ts new file mode 100644 index 000000000..40dd20237 --- /dev/null +++ b/app/src/lib/orchestration/mergeToolActivity.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; + +import { mergeToolActivity, type ToolActivity, toolResultFailed } from './mergeToolActivity'; +import type { ChatMessage } from './useOrchestrationChats'; + +const msg = (over: Partial): ChatMessage => ({ + id: 'x', + from: 'agent', + body: '', + timestamp: '2026-07-08T00:00:00Z', + encrypted: false, + ...over, +}); + +describe('toolResultFailed', () => { + it('flags isError, non-zero exit, or ok=false; passes success', () => { + expect(toolResultFailed({ ok: true, isError: false, exitCode: 0 })).toBe(false); + expect(toolResultFailed({ isError: true })).toBe(true); + expect(toolResultFailed({ exitCode: 1 })).toBe(true); + expect(toolResultFailed({ ok: false })).toBe(true); + expect(toolResultFailed({})).toBe(false); + }); +}); + +describe('mergeToolActivity', () => { + it('merges a tool_call with its tool_result by callId', () => { + const rows = mergeToolActivity([ + msg({ id: 'a', eventKind: 'tool_call', toolName: 'Bash', callId: 'c1', body: 'ls' }), + msg({ id: 'b', eventKind: 'tool_result', callId: 'c1', body: 'out', ok: true }), + ]); + expect(rows).toHaveLength(1); + const tool = rows[0] as ToolActivity; + expect(tool.kind).toBe('tool'); + expect(tool.command).toBe('ls'); + expect(tool.output).toBe('out'); + expect(tool.toolName).toBe('Bash'); + expect(tool.hasResult).toBe(true); + expect(tool.failed).toBe(false); + }); + + it('marks a failed result (isError / exitCode)', () => { + const rows = mergeToolActivity([ + msg({ id: 'a', eventKind: 'tool_call', callId: 'c1', body: 'read x' }), + msg({ + id: 'b', + eventKind: 'tool_result', + callId: 'c1', + body: 'boom', + isError: true, + exitCode: 1, + ok: false, + }), + ]); + expect((rows[0] as ToolActivity).failed).toBe(true); + }); + + it('keeps a tool_call open (no result) as an unfinished tool row', () => { + const rows = mergeToolActivity([ + msg({ id: 'a', eventKind: 'tool_call', callId: 'c1', body: 'ls' }), + ]); + expect(rows).toHaveLength(1); + expect((rows[0] as ToolActivity).hasResult).toBe(false); + }); + + it('renders an orphan tool_result (no prior call) standalone', () => { + const rows = mergeToolActivity([ + msg({ + id: 'r', + eventKind: 'tool_result', + callId: 'zz', + body: 'late', + ok: true, + toolName: 'Read', + }), + ]); + expect(rows).toHaveLength(1); + const tool = rows[0] as ToolActivity; + expect(tool.command).toBe(''); + expect(tool.output).toBe('late'); + expect(tool.toolName).toBe('Read'); + expect(tool.hasResult).toBe(true); + }); + + it('passes non-tool rows through as message rows, preserving order', () => { + const rows = mergeToolActivity([ + msg({ id: 'u', eventKind: 'user_prompt', body: 'hi' }), + msg({ id: 'a', eventKind: 'tool_call', callId: 'c1', body: 'ls' }), + msg({ id: 'b', eventKind: 'tool_result', callId: 'c1', body: 'out' }), + msg({ id: 'm', eventKind: 'agent_message', body: 'done' }), + ]); + expect(rows.map(r => r.kind)).toEqual(['message', 'tool', 'message']); + expect(rows[0]).toMatchObject({ kind: 'message', message: { id: 'u' } }); + expect(rows[2]).toMatchObject({ kind: 'message', message: { id: 'm' } }); + }); + + it('handles interleaved concurrent tool calls', () => { + const rows = mergeToolActivity([ + msg({ id: 'a', eventKind: 'tool_call', callId: 'c1', body: 'one' }), + msg({ id: 'b', eventKind: 'tool_call', callId: 'c2', body: 'two' }), + msg({ id: 'c', eventKind: 'tool_result', callId: 'c2', body: 'two-out' }), + msg({ id: 'd', eventKind: 'tool_result', callId: 'c1', body: 'one-out' }), + ]); + expect(rows).toHaveLength(2); + expect((rows[0] as ToolActivity).output).toBe('one-out'); + expect((rows[1] as ToolActivity).output).toBe('two-out'); + }); +}); diff --git a/app/src/lib/orchestration/mergeToolActivity.ts b/app/src/lib/orchestration/mergeToolActivity.ts new file mode 100644 index 000000000..ce5424057 --- /dev/null +++ b/app/src/lib/orchestration/mergeToolActivity.ts @@ -0,0 +1,103 @@ +/** + * Fold a v2 orchestration transcript into renderable rows. + * + * The harness stream delivers a `tool_call` and its `tool_result` as two + * separate rows correlated by `callId`. For rendering we want them as ONE unit + * (command + output + outcome), so `mergeToolActivity` pairs them and derives a + * single `failed` flag from the result's `isError` / `exitCode` / `ok`. Every + * other row passes through unchanged. Pure — unit-tested in isolation. + */ +import type { ChatMessage } from './useOrchestrationChats'; + +/** A merged tool_call (+ its tool_result when present). */ +export interface ToolActivity { + kind: 'tool'; + /** Stable key: the tool_call id (or the orphan result id). */ + id: string; + toolName?: string; + /** The tool_call body (command / display). Empty for an orphan result. */ + command: string; + /** The tool_result body (output), once it has arrived. */ + output?: string; + callId?: string; + /** True once the matching tool_result exists. */ + hasResult: boolean; + /** The tool run failed — `isError`, a non-zero `exitCode`, or `ok === false`. */ + failed: boolean; + timestamp: string; +} + +/** A plain (non-tool) transcript row, rendered as a bubble/inline block. */ +export interface MessageRow { + kind: 'message'; + message: ChatMessage; +} + +export type TranscriptRow = ToolActivity | MessageRow; + +/** Whether a `tool_result` row represents a failure. */ +export function toolResultFailed( + result: Pick +): boolean { + return result.isError === true || (result.exitCode ?? 0) > 0 || result.ok === false; +} + +function activityFromCall(call: ChatMessage): ToolActivity { + return { + kind: 'tool', + id: call.id, + ...(call.toolName ? { toolName: call.toolName } : {}), + command: call.body, + ...(call.callId ? { callId: call.callId } : {}), + hasResult: false, + failed: false, + timestamp: call.timestamp, + }; +} + +function applyResult(activity: ToolActivity, result: ChatMessage): ToolActivity { + return { + ...activity, + output: result.body, + hasResult: true, + failed: toolResultFailed(result), + ...(activity.toolName ? {} : result.toolName ? { toolName: result.toolName } : {}), + }; +} + +/** + * Merge `tool_call`/`tool_result` pairs (by `callId`) into single `tool` rows. + * A result with no matching prior call renders as its own tool row (command + * empty). Order is preserved by the position of the `tool_call` (or an orphan + * result). Non-tool rows are wrapped as `message` rows. + */ +export function mergeToolActivity(messages: ChatMessage[]): TranscriptRow[] { + const rows: TranscriptRow[] = []; + // callId → index into `rows` of the open tool activity awaiting its result. + const openByCallId = new Map(); + + for (const message of messages) { + if (message.eventKind === 'tool_call') { + const activity = activityFromCall(message); + if (message.callId) openByCallId.set(message.callId, rows.length); + rows.push(activity); + continue; + } + if (message.eventKind === 'tool_result') { + const openIndex = message.callId ? openByCallId.get(message.callId) : undefined; + if (openIndex !== undefined) { + const open = rows[openIndex] as ToolActivity; + rows[openIndex] = applyResult(open, message); + if (message.callId) openByCallId.delete(message.callId); + } else { + // Orphan result (call not seen) — render standalone. + const orphan = applyResult(activityFromCall({ ...message, body: '' }), message); + rows.push(orphan); + } + continue; + } + rows.push({ kind: 'message', message }); + } + + return rows; +} diff --git a/app/src/lib/orchestration/orchestrationClient.ts b/app/src/lib/orchestration/orchestrationClient.ts index 27f7af6cd..aee648c03 100644 --- a/app/src/lib/orchestration/orchestrationClient.ts +++ b/app/src/lib/orchestration/orchestrationClient.ts @@ -46,6 +46,8 @@ export interface SessionSummary { chatKind: OrchestrationChatKind; lastMessageAt: string; unread: number; + /** Total persisted messages in the session; `0` for pinned/new windows. */ + messageCount?: number; active: boolean; pinned: boolean; } @@ -83,6 +85,12 @@ export interface OrchestrationMessage { toolName?: string; /** Correlation id shared by a tool_call and its tool_result. */ callId?: string; + /** tool_result outcome: whether the tool call succeeded. Absent off tool_result. */ + ok?: boolean; + /** tool_result: the harness flagged the result as an error. */ + isError?: boolean; + /** tool_result: process exit code when the tool was a command. */ + exitCode?: number; } export interface OrchestrationSteering { diff --git a/app/src/lib/orchestration/useOrchestrationChats.ts b/app/src/lib/orchestration/useOrchestrationChats.ts index d04d899c9..042791f94 100644 --- a/app/src/lib/orchestration/useOrchestrationChats.ts +++ b/app/src/lib/orchestration/useOrchestrationChats.ts @@ -44,6 +44,12 @@ export interface ChatMessage { toolName?: string; /** Correlation id linking a tool_call to its tool_result. */ callId?: string; + /** tool_result outcome — whether the tool call succeeded. */ + ok?: boolean; + /** tool_result — the harness flagged the result as an error. */ + isError?: boolean; + /** tool_result — process exit code when the tool was a command. */ + exitCode?: number; } export interface ChatWindow { @@ -94,6 +100,9 @@ function mapMessage(message: OrchestrationMessage): ChatMessage { ...(message.eventKind ? { eventKind: message.eventKind } : {}), ...(message.toolName ? { toolName: message.toolName } : {}), ...(message.callId ? { callId: message.callId } : {}), + ...(message.ok !== undefined ? { ok: message.ok } : {}), + ...(message.isError !== undefined ? { isError: message.isError } : {}), + ...(message.exitCode !== undefined ? { exitCode: message.exitCode } : {}), }; } diff --git a/app/src/lib/orchestration/useOrchestrationSessions.test.ts b/app/src/lib/orchestration/useOrchestrationSessions.test.ts new file mode 100644 index 000000000..5c40bcbf2 --- /dev/null +++ b/app/src/lib/orchestration/useOrchestrationSessions.test.ts @@ -0,0 +1,114 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { OrchestrationMessage, SessionSummary } from './orchestrationClient'; +import { + mapTranscriptMessage, + useContactSessions, + useSessionTranscript, +} from './useOrchestrationSessions'; + +const sessionsList = vi.hoisted(() => vi.fn()); +const messagesList = vi.hoisted(() => vi.fn()); +vi.mock('./orchestrationClient', async orig => ({ + ...(await orig()), + orchestrationClient: { sessionsList, messagesList }, +})); +vi.mock('../../services/socketService', () => ({ socketService: { on: vi.fn(), off: vi.fn() } })); + +const session = (over: Partial): SessionSummary => ({ + sessionId: 's1', + agentId: '@a', + source: 'claude', + status: 'idle', + chatKind: 'session', + lastMessageAt: '2026-07-08T00:00:00Z', + unread: 0, + active: false, + pinned: false, + ...over, +}); + +describe('mapTranscriptMessage', () => { + it('maps wire fields incl. tool outcome, defaulting from to role/agentId', () => { + const wire = { + id: 'm1', + agentId: '@a', + sessionId: 's1', + chatKind: 'session', + role: '', + body: 'out', + timestamp: 't', + seq: 1, + eventKind: 'tool_result', + toolName: 'Bash', + callId: 'c1', + ok: false, + isError: true, + exitCode: 2, + } as OrchestrationMessage; + const m = mapTranscriptMessage(wire); + expect(m.from).toBe('@a'); + expect(m.eventKind).toBe('tool_result'); + expect(m.ok).toBe(false); + expect(m.isError).toBe(true); + expect(m.exitCode).toBe(2); + }); +}); + +describe('useContactSessions', () => { + beforeEach(() => vi.clearAllMocks()); + + it('groups session-kind rows by contact agent id', async () => { + sessionsList.mockResolvedValue({ + sessions: [ + session({ sessionId: 's1', agentId: '@a' }), + session({ sessionId: 's2', agentId: '@a' }), + session({ sessionId: 's3', agentId: '@b' }), + session({ sessionId: 'master', agentId: 'master', chatKind: 'master' }), + ], + }); + const { result } = renderHook(() => useContactSessions()); + await waitFor(() => expect(result.current.state.status).toBe('ok')); + expect(result.current.sessions).toHaveLength(3); + expect(result.current.byContact.get('@a')).toHaveLength(2); + expect(result.current.byContact.get('@b')).toHaveLength(1); + }); + + it('surfaces an error state', async () => { + sessionsList.mockRejectedValue(new Error('boom')); + const { result } = renderHook(() => useContactSessions()); + await waitFor(() => expect(result.current.state.status).toBe('error')); + }); +}); + +describe('useSessionTranscript', () => { + beforeEach(() => vi.clearAllMocks()); + + it('loads a session transcript', async () => { + messagesList.mockResolvedValue({ + messages: [ + { + id: 'm1', + agentId: '@a', + sessionId: 's1', + chatKind: 'session', + role: 'agent', + body: 'hi', + timestamp: 't', + seq: 1, + }, + ], + }); + const { result } = renderHook(() => useSessionTranscript('s1')); + await waitFor(() => expect(result.current.state.status).toBe('ok')); + expect(result.current.messages).toHaveLength(1); + expect(messagesList).toHaveBeenCalledWith({ chat: 's1', limit: 100 }); + }); + + it('stays idle for a null session', async () => { + const { result } = renderHook(() => useSessionTranscript(null)); + await waitFor(() => expect(result.current.state.status).toBe('idle')); + expect(messagesList).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/lib/orchestration/useOrchestrationSessions.ts b/app/src/lib/orchestration/useOrchestrationSessions.ts new file mode 100644 index 000000000..0ed1232b0 --- /dev/null +++ b/app/src/lib/orchestration/useOrchestrationSessions.ts @@ -0,0 +1,207 @@ +/** + * Hooks backing the Connections + Agent-chat session surfaces. + * + * - {@link useContactSessions}: the sessions list grouped by contact agent id + * (the `sessionsByContact` map the roster/accordion needs), live-refreshed on + * the `orchestration:message` socket event. + * - {@link useSessionTranscript}: the message transcript for one session + * (lazy-loaded, socket-refreshed), mapped to {@link ChatMessage} for the + * shared `SessionTranscript` renderer. + * + * Kept separate from `useOrchestrationChats` (which owns the pinned master / + * subconscious chat surface) so a panel pulls in only what it needs. + */ +import debugFactory from 'debug'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { socketService } from '../../services/socketService'; +import { + orchestrationClient, + type OrchestrationMessage, + type OrchestrationMessageEvent, + PaymentRequiredError, + type SessionSummary, +} from './orchestrationClient'; +import type { ChatMessage } from './useOrchestrationChats'; + +const debug = debugFactory('orchestration:sessions'); + +const TRANSCRIPT_LIMIT = 100; + +export type SessionsState = + | { status: 'loading' } + | { status: 'error'; message: string } + | { status: 'payment_required' } + | { status: 'ok' }; + +export interface UseContactSessionsResult { + state: SessionsState; + /** All non-pinned session windows. */ + sessions: SessionSummary[]; + /** Sessions grouped by their peer contact agent id. */ + byContact: Map; + refresh: () => Promise; +} + +function groupByContact(sessions: SessionSummary[]): Map { + const map = new Map(); + for (const session of sessions) { + if (session.chatKind !== 'session' || !session.agentId) continue; + const list = map.get(session.agentId) ?? []; + list.push(session); + map.set(session.agentId, list); + } + return map; +} + +export function useContactSessions(): UseContactSessionsResult { + const [state, setState] = useState({ status: 'loading' }); + const [sessions, setSessions] = useState([]); + const mountedRef = useRef(true); + + const refresh = useCallback(async () => { + debug('[orchestration:sessions] contact-sessions refresh: entry'); + try { + const { sessions: rows } = await orchestrationClient.sessionsList(); + if (!mountedRef.current) return; + const sessionRows = rows.filter(s => s.chatKind === 'session'); + debug('[orchestration:sessions] contact-sessions refresh: ok count=%d', sessionRows.length); + setSessions(sessionRows); + setState({ status: 'ok' }); + } catch (error) { + if (!mountedRef.current) return; + if (error instanceof PaymentRequiredError) { + debug('[orchestration:sessions] contact-sessions refresh: payment_required'); + setState({ status: 'payment_required' }); + return; + } + const message = error instanceof Error ? error.message : String(error); + debug('[orchestration:sessions] contact-sessions refresh: error %s', message); + setState({ status: 'error', message }); + } + }, []); + + useEffect(() => { + mountedRef.current = true; + const handle = window.setTimeout(() => void refresh(), 0); + const onMessage = (): void => { + debug('[orchestration:sessions] socket refresh (contact sessions)'); + void refresh(); + }; + socketService.on('orchestration:message', onMessage); + socketService.on('orchestration_message', onMessage); + return () => { + window.clearTimeout(handle); + mountedRef.current = false; + socketService.off('orchestration:message', onMessage); + socketService.off('orchestration_message', onMessage); + }; + }, [refresh]); + + const byContact = useMemo(() => groupByContact(sessions), [sessions]); + return { state, sessions, byContact, refresh }; +} + +/** OrchestrationMessage → ChatMessage view-model row. */ +export function mapTranscriptMessage(message: OrchestrationMessage): ChatMessage { + return { + id: message.id, + from: message.role?.trim() || message.agentId || '', + body: message.body, + timestamp: message.timestamp, + encrypted: false, + ...(message.eventKind ? { eventKind: message.eventKind } : {}), + ...(message.toolName ? { toolName: message.toolName } : {}), + ...(message.callId ? { callId: message.callId } : {}), + ...(message.ok !== undefined ? { ok: message.ok } : {}), + ...(message.isError !== undefined ? { isError: message.isError } : {}), + ...(message.exitCode !== undefined ? { exitCode: message.exitCode } : {}), + }; +} + +export type TranscriptState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'error'; message: string } + | { status: 'ok' }; + +export interface UseSessionTranscriptResult { + state: TranscriptState; + messages: ChatMessage[]; + refresh: () => Promise; +} + +/** Load + live-refresh one session's transcript. Pass `null` to load nothing. */ +export function useSessionTranscript(sessionId: string | null): UseSessionTranscriptResult { + const [state, setState] = useState({ status: 'idle' }); + const [messages, setMessages] = useState([]); + const mountedRef = useRef(true); + // Monotonic request token: only the newest in-flight load may apply its + // result, so switching `sessionId` can never overwrite state with a slower + // response for the PREVIOUS session (the shared `mountedRef` alone can't + // guard this — the new effect re-sets it to true before the stale request + // resolves). + const reqRef = useRef(0); + + const refresh = useCallback(async () => { + if (!sessionId) { + setMessages([]); + setState({ status: 'idle' }); + return; + } + const reqId = ++reqRef.current; + const target = sessionId; + debug('[orchestration:sessions] transcript load: entry session=%s req=%d', target, reqId); + setState(prev => (prev.status === 'ok' ? prev : { status: 'loading' })); + try { + const { messages: rows } = await orchestrationClient.messagesList({ + chat: target, + limit: TRANSCRIPT_LIMIT, + }); + // Drop a stale response (a newer load started, or we unmounted). + if (!mountedRef.current || reqRef.current !== reqId) { + debug( + '[orchestration:sessions] transcript load: dropped stale session=%s req=%d', + target, + reqId + ); + return; + } + debug( + '[orchestration:sessions] transcript load: ok session=%s count=%d', + target, + rows.length + ); + setMessages(rows.map(mapTranscriptMessage)); + setState({ status: 'ok' }); + } catch (error) { + if (!mountedRef.current || reqRef.current !== reqId) return; + const message = error instanceof Error ? error.message : String(error); + debug('[orchestration:sessions] transcript load: error session=%s %s', target, message); + setState({ status: 'error', message }); + } + }, [sessionId]); + + useEffect(() => { + mountedRef.current = true; + const handle = window.setTimeout(() => void refresh(), 0); + const onMessage = (payload: unknown): void => { + const event = payload as OrchestrationMessageEvent | null; + const affected = event && event.chatKind === 'session' ? event.sessionId : null; + if (affected && affected === sessionId) { + debug('[orchestration:sessions] socket refresh (transcript) session=%s', sessionId); + void refresh(); + } + }; + socketService.on('orchestration:message', onMessage); + socketService.on('orchestration_message', onMessage); + return () => { + window.clearTimeout(handle); + mountedRef.current = false; + socketService.off('orchestration:message', onMessage); + socketService.off('orchestration_message', onMessage); + }; + }, [refresh, sessionId]); + + return { state, messages, refresh }; +}