mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(orchestration): chat-UI redesign — agent chat as normal chat window + connections sessions (#4713)
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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': 'Добавить',
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user