feat(orchestration): chat-UI redesign — agent chat as normal chat window + connections sessions (#4713)

This commit is contained in:
sanil-23
2026-07-08 16:31:02 -07:00
committed by GitHub
parent c25ab861d1
commit 003e8c5d12
26 changed files with 2005 additions and 196 deletions
@@ -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<string | null>(null);
const submit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
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 (
<aside
data-testid="orch-agent-session-drawer"
className="absolute inset-y-0 right-0 z-30 flex w-[24rem] flex-col border-l border-line bg-surface shadow-[-8px_0_24px_-12px_rgba(0,0,0,0.25)]">
<div className="flex items-center gap-2 border-b border-line px-4 py-2.5">
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-full border border-line bg-surface-strong text-[11px] font-semibold text-content-secondary">
{sessionLabel(session).slice(0, 2)}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-content">{sessionLabel(session)}</p>
<p className="truncate text-[11px] text-content-muted">
{session.status === 'waiting-approval' ? t('orchPage.connections.status.needsYou') : ''}
</p>
</div>
<button
type="button"
onClick={onClose}
data-testid="orch-agent-drawer-close"
className="flex-none rounded p-1 text-content-faint transition hover:bg-surface-hover">
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{state.status === 'loading' ? (
<p className="py-6 text-center text-sm text-content-muted">
{t('tinyplaceOrchestration.loading')}
</p>
) : state.status === 'error' ? (
<p className="py-6 text-center text-sm text-coral-600 dark:text-coral-300">
{t('tinyplaceOrchestration.failedToLoad')}: {state.message}
</p>
) : messages.length === 0 ? (
<p className="py-6 text-center text-sm text-content-faint">
{t('tinyplaceOrchestration.noMessages')}
</p>
) : (
<SessionTranscript messages={messages} />
)}
</div>
<form className="border-t border-line p-3" onSubmit={submit}>
{sendError ? (
<p
data-testid="orch-agent-drawer-reply-error"
className="mb-2 rounded-md bg-coral-50 px-2 py-1 text-xs text-coral-700 dark:bg-coral-500/10 dark:text-coral-300">
{t('tinyplaceOrchestration.composer.sendFailed')}: {sendError}
</p>
) : null}
<div className="flex gap-2">
<input
value={body}
onChange={e => setBody(e.target.value)}
placeholder={t('orchPage.connections.replyPlaceholder')}
data-testid="orch-agent-drawer-reply"
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"
/>
<Button type="submit" variant="primary" size="sm" disabled={!body.trim() || sending}>
{t('tinyplaceOrchestration.composer.send')}
</Button>
</div>
</form>
</aside>
);
}
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<string | null>(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 (
<div className="flex h-full min-h-[520px] flex-col overflow-hidden rounded-xl border border-line bg-surface shadow-soft">
{/* Master ↔ Subconscious switch. */}
<div
className="flex shrink-0 items-center gap-1 border-b border-line px-3 py-2"
role="tablist"
aria-label={t('orchPage.agent.description')}>
{tabs.map(tab => {
const active = selectedId === tab.key;
return (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={active}
data-testid={`orch-agent-tab-${tab.key}`}
onClick={() => selectChat(tab.key)}
className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
active
? 'bg-surface-subtle font-medium text-content'
: 'text-content-secondary hover:bg-surface-hover hover:text-content'
}`}>
{tab.label}
</button>
);
})}
</div>
<div className="relative flex h-full min-h-[520px] overflow-hidden rounded-xl border border-line bg-surface shadow-soft">
{/* Thread rail — mirrors the normal chat's ThreadList. */}
<aside className="flex w-56 flex-none flex-col border-r border-line bg-surface-muted/40">
<div className="border-b border-line-subtle px-3 py-2 text-[10px] font-semibold uppercase tracking-wide text-content-muted">
{t('orchPage.agent.mainTab')}
</div>
<div
className="min-h-0 flex-1 overflow-y-auto"
role="tablist"
aria-label={t('orchPage.agent.description')}>
{rail.map(chat => {
const active = selectedId === chat.id;
return (
<button
key={chat.id}
type="button"
role="tab"
aria-selected={active}
data-testid={`orch-agent-tab-${chat.id}`}
onClick={() => selectChat(chat.id)}
className={`flex w-full items-center gap-2.5 border-b border-line-subtle/60 px-3 py-2.5 text-left transition-colors dark:border-line/60 ${
active
? 'border-l-2 border-l-primary-500 bg-primary-50 dark:bg-primary-900/30'
: 'hover:bg-surface-hover'
}`}>
<span
className={`flex h-7 w-7 flex-none items-center justify-center rounded-lg text-[11px] font-semibold ${
active
? 'bg-primary-500 text-white'
: 'border border-line bg-surface-strong text-content-secondary'
}`}>
{chat.id === SUBCONSCIOUS_CHAT_KEY ? 'S' : 'M'}
</span>
<span className="min-w-0 flex-1">
<span
className={`block truncate text-sm ${
active
? 'font-medium text-primary-700 dark:text-primary-200'
: 'text-content-secondary'
}`}>
{chat.title}
</span>
<span className="block truncate text-[11px] text-content-faint">
{chat.subtitle}
</span>
</span>
{chat.unread > 0 ? (
<span className="flex-none rounded-full bg-primary-500 px-1.5 py-0.5 text-[10px] font-semibold text-content-inverted">
{chat.unread}
</span>
) : null}
</button>
);
})}
</div>
</aside>
<div className="flex min-h-0 flex-1 flex-col">
<OrchestrationFocusPane
selected={selected}
sessionsState={sessionsState}
messagesState={messagesState}
status={status}
masterError={masterError}
refresh={refresh}
steeringText={steeringText}
runningReview={runningReview}
onRunSteeringReview={() => void runSteeringReview()}
canCompose={isMasterSelected}
composerBody={composerBody}
onComposerChange={setComposerBody}
sending={sending}
onSubmitComposer={submitComposer}
{/* Message pane. */}
<main className="relative flex min-w-0 flex-1 flex-col bg-surface/70 dark:bg-black/40">
{/* Subconscious steering header. */}
{isSubconscious ? (
<div
data-testid="orch-agent-steering-header"
className="flex items-center justify-between gap-3 border-b border-line bg-amber-50/40 px-5 py-2.5 dark:bg-amber-500/5">
<div className="min-w-0">
<p className="text-xs font-medium text-content">
{steeringText
? t('tinyplaceOrchestration.steeringHeader.current')
: t('tinyplaceOrchestration.steeringHeader.none')}
</p>
{steeringText ? (
<p className="mt-0.5 truncate text-xs text-content-muted">{steeringText}</p>
) : null}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => void runSteeringReview()}
disabled={runningReview}>
{runningReview
? t('tinyplaceOrchestration.steeringHeader.running')
: t('tinyplaceOrchestration.steeringHeader.runReview')}
</Button>
</div>
) : null}
{sessionsState.status === 'loading' || messagesState.status === 'loading' ? (
<div className="flex flex-1 items-center justify-center text-sm text-content-muted">
{t('tinyplaceOrchestration.loading')}
</div>
) : sessionsState.status === 'error' || messagesState.status === 'error' ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-sm text-coral-600 dark:text-coral-300">
<p>
{t('tinyplaceOrchestration.failedToLoad')}:{' '}
{sessionsState.status === 'error'
? sessionsState.message
: messagesState.status === 'error'
? messagesState.message
: ''}
</p>
<Button variant="secondary" size="sm" onClick={() => void refresh()}>
{t('common.retry')}
</Button>
</div>
) : (
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-[48.75rem] space-y-3 px-5 py-5">
{selected?.messages.length ? (
<SessionTranscript messages={selected.messages} />
) : (
<p className="py-10 text-center text-sm text-content-faint">
{t('tinyplaceOrchestration.noMessages')}
</p>
)}
{/* View-session cards for sessions the agent engaged (needs you). */}
{pinged.map(session => (
<div key={session.sessionId} className="flex justify-start">
<button
type="button"
data-testid={`orch-agent-view-session-${session.sessionId}`}
onClick={() => setOpenSessionId(session.sessionId)}
className="flex w-full max-w-[85%] items-center gap-3 rounded-xl border border-primary-200 bg-primary-50 px-3 py-2.5 text-left transition hover:bg-primary-100/60 dark:border-primary-500/30 dark:bg-primary-900/20">
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-lg bg-primary-500/15 text-sm text-primary-600 dark:text-primary-300">
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-content">
{sessionLabel(session)}
</span>
<span className="block truncate text-[11px] text-amber-600 dark:text-amber-300">
{t('orchPage.connections.status.needsYou')}
</span>
</span>
<span className="flex-none rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white">
{t('orchPage.agent.viewSession')}
</span>
</button>
</div>
))}
</div>
</div>
)}
{/* Master composer. */}
{isMasterSelected && sessionsState.status === 'ok' ? (
<form
className="flex flex-col gap-2 border-t border-line px-5 py-3"
onSubmit={submitComposer}>
{masterError ? (
<p className="rounded-md bg-coral-50 px-2 py-1 text-xs text-coral-700 dark:bg-coral-500/10 dark:text-coral-300">
{t('tinyplaceOrchestration.composer.sendFailed')}: {masterError}
</p>
) : null}
<div className="flex gap-2">
<input
data-testid="orch-agent-composer-input"
value={composerBody}
onChange={event => 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"
/>
<Button
type="submit"
variant="primary"
size="sm"
data-testid="orch-agent-composer-send"
disabled={!composerBody.trim() || sending}>
{t('tinyplaceOrchestration.composer.send')}
</Button>
</div>
</form>
) : null}
</main>
{/* 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.
<SessionDrawer
key={openSession.sessionId}
session={openSession}
onClose={() => setOpenSessionId(null)}
/>
</div>
) : null}
</div>
);
}
@@ -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<string | null>(null);
const label = session.label?.trim() || session.sessionId;
const meta = statusMeta(session.status, t);
const submit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
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 (
<div className="space-y-3" data-testid="orch-session-view">
<button
type="button"
onClick={onBack}
data-testid="orch-session-back"
className="flex items-center gap-1.5 text-xs font-medium text-primary-600 transition hover:text-primary-700 dark:text-primary-300">
{t('orchPage.connections.back')} / {handle ? `@${handle}` : shortAddress(contactAddr)}
</button>
<SectionCard
title={
<span className="flex items-center gap-2">
{label}
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${meta.tone}`}>
{meta.label}
</span>
</span>
}
description={
session.messageCount
? t('orchPage.connections.messageCount').replace('{n}', String(session.messageCount))
: undefined
}>
{state.status === 'loading' ? (
<p className="py-6 text-center text-sm text-content-muted">
{t('tinyplaceOrchestration.loading')}
</p>
) : state.status === 'error' ? (
<p className="py-6 text-center text-sm text-coral-600 dark:text-coral-300">
{t('tinyplaceOrchestration.failedToLoad')}: {state.message}
</p>
) : messages.length === 0 ? (
<p className="py-6 text-center text-sm text-content-faint">
{t('tinyplaceOrchestration.noMessages')}
</p>
) : (
<SessionTranscript messages={messages} />
)}
{sendError ? (
<p
data-testid="orch-session-reply-error"
className="mt-3 rounded-md bg-coral-50 px-2 py-1 text-xs text-coral-700 dark:bg-coral-500/10 dark:text-coral-300">
{t('tinyplaceOrchestration.composer.sendFailed')}: {sendError}
</p>
) : null}
<form className="mt-3 flex gap-2 border-t border-line pt-3" onSubmit={submit}>
<input
value={body}
onChange={e => 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"
/>
<Button
type="submit"
variant="primary"
size="sm"
disabled={!body.trim() || sending}
data-testid="orch-session-reply-send">
{t('tinyplaceOrchestration.composer.send')}
</Button>
</form>
</SectionCard>
</div>
);
}
// ── 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 (
<li className="py-1" data-testid={`orch-connection-${address}`}>
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition hover:bg-surface-hover">
<span className="flex-none text-[10px] text-content-muted">{expanded ? '▾' : '▸'}</span>
<span className="relative flex-none">
<span className="flex h-9 w-9 items-center justify-center rounded-full border border-line bg-surface-strong text-[11px] font-semibold text-content-secondary">
{(handle ?? address).slice(0, 2)}
</span>
{online ? (
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-surface bg-sage-500" />
) : null}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-content">
{handle ? `@${handle}` : t('tinyplaceOrchestration.unknownSender')}
</span>
<span className="block truncate font-mono text-[11px] text-content-faint">
{shortAddress(address)}
</span>
</span>
<span className="flex-none rounded-full bg-surface-strong px-2 py-0.5 text-[10px] font-medium text-content-muted">
{sessions.length === 0
? t('orchPage.connections.noSessions')
: t('orchPage.connections.sessionCount').replace('{n}', String(sessions.length))}
</span>
</button>
{expanded ? (
<div className="ml-9 mt-1 space-y-1 pl-2">
{sessions.map(session => {
const meta = statusMeta(session.status, t);
const label = session.label?.trim() || session.sessionId;
return (
<button
key={session.sessionId}
type="button"
data-testid={`orch-session-${session.sessionId}`}
onClick={() => onOpenSession(session)}
className="flex w-full items-center gap-3 rounded-lg border border-line bg-surface-subtle px-3 py-2 text-left transition hover:bg-surface-hover">
<span className={`h-1.5 w-1.5 flex-none rounded-full ${meta.dot}`} />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-content">{label}</span>
<span className={`flex-none text-[10px] font-medium ${meta.tone}`}>
{meta.label}
</span>
</span>
{session.currentTask ? (
<span className="mt-0.5 block truncate text-[11px] text-content-muted">
{session.currentTask}
</span>
) : null}
</span>
<span className="flex-none text-[10px] text-content-faint">
{session.messageCount
? t('orchPage.connections.messageCount').replace(
'{n}',
String(session.messageCount)
)
: ''}
</span>
<span className="flex-none text-content-faint"></span>
</button>
);
})}
<button
type="button"
data-testid={`orch-new-session-${address}`}
disabled={creating}
onClick={onNewSession}
className="flex w-full items-center gap-1 rounded-lg px-3 py-1.5 text-left text-[11px] font-medium text-primary-600 transition hover:bg-surface-hover disabled:opacity-50 dark:text-primary-300">
+ {t('tinyplaceOrchestration.newSession')}
</button>
</div>
) : null}
</li>
);
}
export default function ConnectionsPanel({ onDiscover }: { onDiscover?: () => void }) {
const { t } = useT();
const { state, runAction, pendingAction, actionError } = usePairing();
const sessions = useContactSessions();
const [handles, setHandles] = useState<Record<string, string | null>>({});
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [open, setOpen] = useState<{ address: string; session: SessionSummary } | null>(null);
const [creating, setCreating] = useState<string | null>(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 (
<p
@@ -78,14 +389,35 @@ export default function ConnectionsPanel({ onDiscover }: { onDiscover?: () => 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 (
<SessionView
session={liveSession}
contactAddr={open.address}
handle={handles[open.address] ?? null}
onBack={() => setOpen(null)}
/>
);
}
return (
<div className="space-y-5" data-testid="orch-connections-panel">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-4" data-testid="orch-connections-panel">
<div className="grid grid-cols-3 gap-3">
<StatTile
label={t('orchPage.connections.statContacts')}
value={stats?.contactCount ?? accepted.length}
testId="orch-connections-stat-contacts"
/>
<StatTile
label={t('orchPage.connections.statSessions')}
value={totalSessions}
testId="orch-connections-stat-sessions"
/>
<StatTile
label={t('orchPage.connections.statPending')}
value={(stats?.pendingIncoming ?? 0) + (stats?.pendingOutgoing ?? 0)}
@@ -100,20 +432,58 @@ export default function ConnectionsPanel({ onDiscover }: { onDiscover?: () => vo
</p>
)}
{incoming.length > 0 ? (
<SectionCard
title={t('tinyplaceOrchestration.pairing.requests')}
testId="orch-connections-requests">
<ul className="divide-y divide-line">
{incoming.map(request => {
const address = contactAddress(request);
const handle = handles[address];
return (
<li key={address} className="flex items-center justify-between gap-3 py-2.5">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-content">
{handle ? `@${handle}` : t('tinyplaceOrchestration.unknownSender')}
</p>
<p className="truncate font-mono text-[11px] text-content-faint">
{shortAddress(address)}
</p>
</div>
<div className="flex flex-none gap-1.5">
<Button
variant="primary"
size="sm"
disabled={pendingAction !== null || !address}
onClick={() =>
void runAction(`accept:${address}`, () =>
apiClient.orchestrationPairing.acceptRequest(address)
)
}>
{t('tinyplaceOrchestration.pairing.accept')}
</Button>
<Button
variant="secondary"
size="sm"
disabled={pendingAction !== null || !address}
onClick={() =>
void runAction(`decline:${address}`, () =>
apiClient.orchestrationPairing.declineRequest(address)
)
}>
{t('tinyplaceOrchestration.pairing.decline')}
</Button>
</div>
</li>
);
})}
</ul>
</SectionCard>
) : null}
<SectionCard
title={t('orchPage.connections.title')}
description={t('orchPage.connections.description')}
action={
onDiscover && (
<Button
variant="secondary"
size="sm"
onClick={onDiscover}
data-testid="orch-connections-add">
{t('orchPage.discover.linkAction')}
</Button>
)
}>
description={t('orchPage.connections.description')}>
{accepted.length === 0 ? (
<div className="py-6 text-center" data-testid="orch-connections-empty">
<p className="text-sm text-content-muted">{t('orchPage.connections.empty')}</p>
@@ -129,35 +499,21 @@ export default function ConnectionsPanel({ onDiscover }: { onDiscover?: () => vo
)}
</div>
) : (
<ul className="divide-y divide-line">
<ul className="divide-y divide-line/60">
{accepted.map(contact => {
const address = contactAddress(contact);
const handle = handles[address];
const actionId = `block:${address}`;
return (
<li
<ConnectionRow
key={address}
className="flex items-center justify-between gap-3 py-2.5"
data-testid="orch-connection-row">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-content">
{handle ? `@${handle}` : t('tinyplaceOrchestration.unknownSender')}
</p>
<p className="truncate font-mono text-[11px] text-content-faint">{address}</p>
</div>
<Button
variant="tertiary"
size="sm"
disabled={pendingAction === actionId}
onClick={() =>
void runAction(actionId, () =>
apiClient.orchestrationPairing.blockRequest(address)
)
}
data-testid="orch-connection-block">
{t('tinyplaceOrchestration.pairing.block')}
</Button>
</li>
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}
/>
);
})}
</ul>
@@ -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('**') ? (
<strong key={i} className="font-semibold">
{part.slice(2, -2)}
</strong>
) : (
part
)
);
}
function UserBubble({ message }: { message: ChatMessage }): ReactElement {
return (
<div className="flex justify-end" data-event-kind="user_prompt">
<div className="flex max-w-[75%] flex-col items-end gap-1">
<div className="overflow-hidden break-words rounded-2xl rounded-br-md bg-primary-500 px-4 py-2.5 text-content-inverted">
<p className="whitespace-pre-wrap text-sm leading-relaxed">{message.body}</p>
</div>
<span className="px-1 text-[10px] text-white/60">{formatTime(message.timestamp)}</span>
</div>
</div>
);
}
function AgentBubble({ message }: { message: ChatMessage }): ReactElement {
return (
<div className="flex justify-start" data-event-kind={message.eventKind ?? 'v1'}>
<div className="flex max-w-[75%] flex-col items-start gap-1">
<div className="rounded-2xl rounded-bl-md bg-surface-strong px-4 py-2.5 text-content dark:bg-surface-muted/80">
<p className="whitespace-pre-wrap text-sm leading-relaxed">
{renderInline(message.body)}
</p>
</div>
<span className="px-1 text-[10px] text-content-faint">{formatTime(message.timestamp)}</span>
</div>
</div>
);
}
function ThinkingRow({ message }: { message: ChatMessage }): ReactElement {
return (
<div className="flex justify-start" data-event-kind="agent_thinking">
<div className="flex max-w-[75%] items-start gap-2 px-1 text-content-faint">
<span className="mt-0.5 flex-none text-xs"></span>
<p className="whitespace-pre-wrap text-xs italic leading-relaxed">{message.body}</p>
</div>
</div>
);
}
function ErrorRow({ message }: { message: ChatMessage }): ReactElement {
return (
<div className="flex justify-start" data-event-kind="error">
<div className="flex w-full max-w-[85%] items-start gap-2 rounded-xl border border-coral-200 bg-coral-50 px-3 py-2 dark:border-coral-500/30 dark:bg-coral-500/10">
<span className="mt-0.5 flex-none text-xs text-coral-500"></span>
<p className="whitespace-pre-wrap text-xs leading-relaxed text-coral-700 dark:text-coral-300">
{message.body}
</p>
</div>
</div>
);
}
function ToolBlock({ tool }: { tool: ToolActivity }): ReactElement {
return (
<div className="flex justify-start" data-event-kind="tool_call" data-failed={tool.failed}>
<div className="w-full max-w-[85%] overflow-hidden rounded-xl border border-line bg-surface-subtle">
<div className="flex items-center gap-2 border-b border-line-subtle px-3 py-1.5">
<span className="flex-none text-xs text-content-faint"></span>
{tool.toolName ? (
<span className="flex-none rounded bg-surface-strong px-1.5 py-0.5 font-mono text-[10px] font-medium text-content-secondary">
{tool.toolName}
</span>
) : null}
<code className="min-w-0 flex-1 truncate font-mono text-[11px] text-content-muted">
{tool.command}
</code>
</div>
{tool.hasResult ? (
<div
className={`flex gap-2 px-3 py-2 ${tool.failed ? 'bg-coral-50 dark:bg-coral-500/10' : ''}`}>
<span
className={`mt-0.5 flex-none text-xs ${tool.failed ? 'text-coral-500' : 'text-sage-500'}`}
aria-hidden>
{tool.failed ? '✕' : '↳'}
</span>
<pre
className={`min-w-0 flex-1 overflow-x-auto whitespace-pre-wrap font-mono text-[11px] leading-relaxed ${
tool.failed ? 'text-coral-700 dark:text-coral-300' : 'text-content-muted'
}`}>
{tool.output}
</pre>
</div>
) : null}
</div>
</div>
);
}
function ApprovalRow({
message,
onDecide,
}: {
message: ChatMessage;
onDecide?: (message: ChatMessage, decision: ApprovalDecision) => void;
}): ReactElement {
const { t } = useT();
return (
<div className="flex justify-start" data-event-kind="approval_request">
<div className="w-full max-w-[85%] rounded-xl border border-amber-300 bg-amber-50 px-3 py-2.5 dark:border-amber-500/40 dark:bg-amber-500/10">
<div className="flex items-center gap-2">
<span className="text-sm text-amber-500"></span>
<span className="text-xs font-semibold text-amber-700 dark:text-amber-300">
{t('chat.approval.title')}
</span>
{message.toolName ? (
<span className="rounded bg-amber-100 px-1.5 py-0.5 font-mono text-[10px] text-amber-700 dark:bg-amber-500/20 dark:text-amber-200">
{message.toolName}
</span>
) : null}
</div>
<code className="mt-1.5 block overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] text-content-secondary">
{message.body}
</code>
{onDecide ? (
<div className="mt-2.5 flex gap-2">
<button
type="button"
onClick={() => onDecide(message, 'approve')}
className="rounded-lg bg-amber-500 px-3 py-1 text-xs font-semibold text-white transition hover:bg-amber-600">
{t('chat.approval.approve')}
</button>
<button
type="button"
onClick={() => onDecide(message, 'deny')}
className="rounded-lg border border-line bg-surface px-3 py-1 text-xs font-medium text-content-secondary transition hover:bg-surface-hover">
{t('chat.approval.deny')}
</button>
<button
type="button"
onClick={() => onDecide(message, 'always')}
className="rounded-lg px-2 py-1 text-xs font-medium text-content-faint transition hover:text-content-secondary">
{t('chat.approval.alwaysAllow')}
</button>
</div>
) : null}
</div>
</div>
);
}
export default function SessionTranscript({
messages,
onDecide,
}: SessionTranscriptProps): ReactElement {
const rows = mergeToolActivity(messages);
return (
<div className="space-y-3" data-testid="session-transcript">
{rows.map((row, i) => {
if (row.kind === 'tool') return <ToolBlock key={row.id} tool={row} />;
const { message } = row;
switch (message.eventKind) {
case 'user_prompt':
return <UserBubble key={message.id} message={message} />;
case 'agent_thinking':
return <ThinkingRow key={message.id} message={message} />;
case 'error':
return <ErrorRow key={message.id} message={message} />;
case 'approval_request':
return <ApprovalRow key={message.id} message={message} onDecide={onDecide} />;
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) ? (
<UserBubble key={message.id} message={message} />
) : (
<AgentBubble key={`${message.id}-${i}`} message={message} />
);
}
})}
</div>
);
}
@@ -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) => (
<div
data-testid="focus-pane"
data-can-compose={String(canCompose)}
data-selected={selected?.id}>
<form data-testid="focus-form" onSubmit={onSubmitComposer}>
<input
data-testid="focus-input"
value={composerBody}
onChange={e => onComposerChange(e.target.value)}
/>
</form>
<button data-testid="focus-steering" onClick={() => onRunSteeringReview()}>
review
</button>
</div>
),
}));
const subconsciousTrigger = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock('../../../utils/tauriCommands/subconscious', () => ({ subconsciousTrigger }));
describe('AgentChatPanel', () => {
beforeEach(() => vi.clearAllMocks());
const sendMasterMessage = vi.hoisted(() => vi.fn().mockResolvedValue({ ok: true, messageId: 'm' }));
vi.mock('../../../lib/orchestration/orchestrationClient', async orig => ({
...(await orig<typeof import('../../../lib/orchestration/orchestrationClient')>()),
orchestrationClient: { sendMasterMessage },
}));
it('renders the master/subconscious toggle and the focus pane', () => {
render(<AgentChatPanel />);
expect(screen.getByTestId('orch-agent-tab-master')).toBeInTheDocument();
expect(screen.getByTestId('orch-agent-tab-subconscious')).toBeInTheDocument();
expect(screen.getByTestId('focus-pane')).toHaveAttribute('data-can-compose', 'true');
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(<AgentChatPanel />);
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(<AgentChatPanel />);
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(<AgentChatPanel />);
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(<AgentChatPanel />);
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(<AgentChatPanel />);
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(<AgentChatPanel />);
expect(screen.getByText(/load failed/)).toBeInTheDocument();
});
});
@@ -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<unknown>) => {
await fn();
})
);
const pairing = vi.hoisted(() => ({
current: {
state: { status: 'ok' as const, snapshot: {} as Record<string, unknown> },
@@ -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<typeof import('../../../lib/orchestration/orchestrationClient')>()),
orchestrationClient: { sessionsCreate, sendMasterMessage },
}));
const sessionsHook = vi.hoisted(() => ({
byContact: new Map<string, SessionSummary[]>(),
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>): 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(<ConnectionsPanel />);
expect(screen.getByTestId('orch-connection-row')).toBeInTheDocument();
// handle resolves from the directory reverse lookup
await waitFor(() => expect(screen.getByText('@alice')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('orch-connection-block'));
expect(runAction).toHaveBeenCalledWith('block:agent-1', expect.any(Function));
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(<ConnectionsPanel />);
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(<ConnectionsPanel />);
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(<ConnectionsPanel />);
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 }));
});
});
@@ -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>): 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(
<SessionTranscript
messages={[
msg({ id: 'u', from: 'you', eventKind: 'user_prompt', body: 'hello' }),
msg({ id: 'a', from: 'agent', eventKind: 'agent_message', body: 'hi back' }),
]}
/>
);
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(<SessionTranscript messages={[msg({ id: 'o', from: 'owner', body: 'my reply' })]} />);
expect(screen.getByText('my reply').closest('.bg-primary-500')).toBeInTheDocument();
});
it('merges a tool_call+result and marks failure', () => {
render(
<SessionTranscript
messages={[
msg({ id: 'tc', eventKind: 'tool_call', toolName: 'Bash', callId: 'c1', body: 'ls' }),
msg({
id: 'tr',
eventKind: 'tool_result',
callId: 'c1',
body: 'boom',
isError: true,
exitCode: 1,
}),
]}
/>
);
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(
<SessionTranscript
messages={[
msg({ id: 'ap', eventKind: 'approval_request', toolName: 'gh', body: 'gh pr create' }),
]}
/>
);
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(<SessionTranscript messages={[approval]} onDecide={onDecide} />);
fireEvent.click(screen.getByText('chat.approval.approve'));
expect(onDecide).toHaveBeenCalledWith(approval, 'approve');
fireEvent.click(screen.getByText('chat.approval.deny'));
expect(onDecide).toHaveBeenCalledWith(approval, 'deny');
});
});
+13 -1
View File
@@ -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': 'إضافة',
+14 -1
View File
@@ -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': 'যোগ করুন',
+14 -1
View File
@@ -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',
+14 -1
View File
@@ -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',
+14 -1
View File
@@ -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',
+13 -1
View File
@@ -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',
+14 -1
View File
@@ -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': 'जोड़ें',
+14 -1
View File
@@ -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',
+14 -1
View File
@@ -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',
+14 -1
View File
@@ -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': '추가',
+14 -1
View File
@@ -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',
+14 -1
View File
@@ -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',
+13 -1
View File
@@ -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': 'Добавить',
+13 -1
View File
@@ -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': '添加',
@@ -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>): 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');
});
});
@@ -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<ChatMessage, 'ok' | 'isError' | 'exitCode'>
): 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<string, number>();
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;
}
@@ -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 {
@@ -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 } : {}),
};
}
@@ -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<typeof import('./orchestrationClient')>()),
orchestrationClient: { sessionsList, messagesList },
}));
vi.mock('../../services/socketService', () => ({ socketService: { on: vi.fn(), off: vi.fn() } }));
const session = (over: Partial<SessionSummary>): 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();
});
});
@@ -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<string, SessionSummary[]>;
refresh: () => Promise<void>;
}
function groupByContact(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
const map = new Map<string, SessionSummary[]>();
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<SessionsState>({ status: 'loading' });
const [sessions, setSessions] = useState<SessionSummary[]>([]);
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<void>;
}
/** Load + live-refresh one session's transcript. Pass `null` to load nothing. */
export function useSessionTranscript(sessionId: string | null): UseSessionTranscriptResult {
const [state, setState] = useState<TranscriptState>({ status: 'idle' });
const [messages, setMessages] = useState<ChatMessage[]>([]);
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 };
}