feat(orchestration): instance-shaped session model + harness roster components (#4589)

This commit is contained in:
oxoxDev
2026-07-06 10:27:27 -07:00
committed by GitHub
parent f88197a542
commit 0500aa9fb6
26 changed files with 709 additions and 3 deletions
@@ -0,0 +1,18 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import HarnessGlyph, { type GlyphKind } from './HarnessGlyph';
describe('HarnessGlyph', () => {
it.each<[GlyphKind, string]>([
['claude', 'C'],
['codex', 'Cx'],
['gemini', 'G'],
['openhuman', 'OH'],
])('renders the %s mark', (harness, label) => {
render(<HarnessGlyph harness={harness} />);
const glyph = screen.getByTestId('harness-glyph');
expect(glyph).toHaveAttribute('data-harness', harness);
expect(glyph).toHaveTextContent(label);
});
});
@@ -0,0 +1,40 @@
/**
* HarnessGlyph — a small brand mark for the agent harness driving an instance
* (Claude / Codex / Gemini), plus an OpenHuman mark for internal windows. Used
* as the leading glyph in roster rows.
*
* The colors here are deliberate brand/identity hues (not surface chrome), so
* they use literal palette classes per the project's "meaningful content color"
* guidance rather than semantic tokens.
*/
import type { HarnessType } from '../../lib/orchestration/orchestrationClient';
export type GlyphKind = HarnessType | 'openhuman';
export interface HarnessGlyphProps {
harness: GlyphKind;
className?: string;
}
const GLYPH: Record<GlyphKind, { label: string; tone: string }> = {
claude: { label: 'C', tone: 'bg-[#c96442] text-white' },
codex: { label: 'Cx', tone: 'bg-content text-surface' },
gemini: { label: 'G', tone: 'bg-ocean-500 text-white' },
openhuman: { label: 'OH', tone: 'bg-sage-500 text-white' },
};
export default function HarnessGlyph({
harness,
className,
}: HarnessGlyphProps): React.ReactElement {
const { label, tone } = GLYPH[harness];
return (
<span
aria-hidden
data-testid="harness-glyph"
data-harness={harness}
className={`flex h-6 w-6 flex-none items-center justify-center rounded-md font-mono text-[11px] font-bold ${tone} ${className ?? ''}`}>
{label}
</span>
);
}
@@ -0,0 +1,56 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { SessionSummary } from '../../lib/orchestration/orchestrationClient';
import InstanceCard from './InstanceCard';
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
function session(over: Partial<SessionSummary> = {}): SessionSummary {
return {
sessionId: 'w1',
agentId: '6wNaBJkatir4B86cw5ykHZWQ3xoNaKygX5vAU9MQbHSh',
source: 'claude',
harnessType: 'claude',
status: 'idle',
currentTask: 'drafting hub cards',
chatKind: 'session',
lastMessageAt: '2026-07-06T00:00:00Z',
unread: 0,
active: true,
pinned: false,
...over,
};
}
describe('InstanceCard', () => {
it('renders the harness glyph, status dot, task and shortened address', () => {
render(<InstanceCard session={session()} />);
expect(screen.getByTestId('harness-glyph')).toHaveAttribute('data-harness', 'claude');
expect(screen.getByTestId('instance-status-dot')).toHaveAttribute('data-status', 'idle');
expect(screen.getByText('drafting hub cards')).toBeInTheDocument();
expect(screen.getByText('6wNaBJ…QbHSh')).toBeInTheDocument();
});
it('prefers a resolved @handle over the address', () => {
render(<InstanceCard session={session()} handle="claudebot" />);
expect(screen.getByText('@claudebot')).toBeInTheDocument();
});
it('shows the unread pill only when unread > 0 and fires onSelect', () => {
const onSelect = vi.fn();
const { rerender } = render(<InstanceCard session={session()} onSelect={onSelect} />);
expect(screen.queryByTestId('instance-card-unread')).toBeNull();
rerender(<InstanceCard session={session({ unread: 3 })} onSelect={onSelect} />);
expect(screen.getByTestId('instance-card-unread')).toHaveTextContent('3');
fireEvent.click(screen.getByTestId('instance-card-w1'));
expect(onSelect).toHaveBeenCalledOnce();
});
it('falls back to the OpenHuman glyph when no harness is set', () => {
render(<InstanceCard session={session({ harnessType: undefined, source: 'user_created' })} />);
expect(screen.getByTestId('harness-glyph')).toHaveAttribute('data-harness', 'openhuman');
});
});
@@ -0,0 +1,74 @@
/**
* InstanceCard — one roster row for an agent instance: harness glyph + status
* dot + identity + one-line current task + unread pill.
*
* Presentational only; the parent supplies the {@link SessionSummary} and an
* optional resolved `@handle` (the raw address is the fallback).
*/
import { useT } from '../../lib/i18n/I18nContext';
import type { InstanceStatus, SessionSummary } from '../../lib/orchestration/orchestrationClient';
import HarnessGlyph, { type GlyphKind } from './HarnessGlyph';
import InstanceStatusDot from './InstanceStatusDot';
export interface InstanceCardProps {
session: SessionSummary;
selected?: boolean;
onSelect?: () => void;
/** Resolved `@handle` for the peer, if known (address is the fallback). */
handle?: string | null;
}
const STATUS_LABEL_KEY: Record<InstanceStatus, string> = {
running: 'tinyplaceOrchestration.status.running',
idle: 'tinyplaceOrchestration.status.idle',
'waiting-approval': 'tinyplaceOrchestration.status.waitingApproval',
errored: 'tinyplaceOrchestration.status.errored',
stopped: 'tinyplaceOrchestration.status.stopped',
};
function shortAddress(address: string): string {
if (address.length <= 14) return address;
return `${address.slice(0, 6)}${address.slice(-5)}`;
}
export default function InstanceCard({
session,
selected,
onSelect,
handle,
}: InstanceCardProps): React.ReactElement {
const { t } = useT();
const glyph: GlyphKind = session.harnessType ?? 'openhuman';
const identity = handle ? `@${handle}` : (session.label ?? shortAddress(session.agentId));
return (
<button
type="button"
data-testid={`instance-card-${session.sessionId}`}
data-selected={selected ? 'true' : 'false'}
onClick={onSelect}
className={`flex w-full items-center gap-3 border-l-2 px-3 py-2 text-left transition hover:bg-surface-hover ${
selected ? 'border-ocean-500 bg-surface-muted' : 'border-transparent'
}`}>
<HarnessGlyph harness={glyph} />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<InstanceStatusDot status={session.status} label={t(STATUS_LABEL_KEY[session.status])} />
<span className="truncate text-xs font-semibold text-content">{identity}</span>
</span>
{session.currentTask ? (
<span className="mt-0.5 block truncate text-[11px] text-content-muted">
{session.currentTask}
</span>
) : null}
</span>
{session.unread > 0 ? (
<span
data-testid="instance-card-unread"
className="flex-none rounded-full bg-ocean-500 px-1.5 py-0.5 text-[10px] font-semibold text-content-inverted">
{session.unread}
</span>
) : null}
</button>
);
}
@@ -0,0 +1,27 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { InstanceStatus } from '../../lib/orchestration/orchestrationClient';
import InstanceStatusDot from './InstanceStatusDot';
describe('InstanceStatusDot', () => {
it.each<InstanceStatus>(['running', 'idle', 'waiting-approval', 'errored', 'stopped'])(
'tags the %s status and pulses only when running',
status => {
render(<InstanceStatusDot status={status} label={status} />);
const dot = screen.getByTestId('instance-status-dot');
expect(dot).toHaveAttribute('data-status', status);
expect(dot.className.includes('animate-pulse')).toBe(status === 'running');
}
);
it('uses the status value as the accessible name when no label is given', () => {
render(<InstanceStatusDot status="errored" />);
expect(screen.getByTestId('instance-status-dot')).toHaveAttribute('aria-label', 'errored');
});
it('prefers a provided translated label', () => {
render(<InstanceStatusDot status="idle" label="Inactivo" />);
expect(screen.getByTestId('instance-status-dot')).toHaveAttribute('aria-label', 'Inactivo');
});
});
@@ -0,0 +1,42 @@
/**
* InstanceStatusDot — the core roster primitive: one colored dot that encodes an
* agent instance's status at a glance, scannable across many rows.
*
* Five states (color + motion), matching the core's {@link InstanceStatus}:
* running (ocean, pulsing) · idle (sage) · waiting-approval (amber) ·
* errored (coral) · stopped (faint). The core derives only idle/stopped today;
* the other three are wired ahead of the attention-queue / run-state work.
*
* Presentational only. Pass a translated `label` for the accessible name.
*/
import type { InstanceStatus } from '../../lib/orchestration/orchestrationClient';
export interface InstanceStatusDotProps {
status: InstanceStatus;
/** Accessible label (already translated by the caller). */
label?: string;
}
const TONE: Record<InstanceStatus, string> = {
running: 'bg-ocean-500 animate-pulse',
idle: 'bg-sage-500',
'waiting-approval': 'bg-amber-500',
errored: 'bg-coral-500',
stopped: 'bg-content-faint',
};
export default function InstanceStatusDot({
status,
label,
}: InstanceStatusDotProps): React.ReactElement {
return (
<span
role="img"
aria-label={label ?? status}
title={label ?? status}
data-testid="instance-status-dot"
data-status={status}
className={`inline-block h-2 w-2 flex-none rounded-full ${TONE[status]}`}
/>
);
}
@@ -69,6 +69,7 @@ const PINNED_SESSIONS = [
unread: 0,
active: true,
pinned: true,
status: 'idle' as const,
},
{
sessionId: 'subconscious',
@@ -79,6 +80,7 @@ const PINNED_SESSIONS = [
unread: 0,
active: true,
pinned: true,
status: 'idle' as const,
},
];
@@ -96,6 +98,7 @@ describe('TinyPlaceOrchestrationTab', () => {
unread: 0,
active: true,
pinned: false,
status: 'idle' as const,
},
});
messagesListMock.mockResolvedValue({ messages: [] });
@@ -175,6 +178,7 @@ describe('TinyPlaceOrchestrationTab', () => {
unread: 0,
active: true,
pinned: false,
status: 'idle' as const,
},
],
});
@@ -201,6 +205,7 @@ describe('TinyPlaceOrchestrationTab', () => {
unread: 0,
active: true,
pinned: false,
status: 'idle' as const,
},
],
});
@@ -254,6 +259,7 @@ describe('TinyPlaceOrchestrationTab', () => {
unread: 3,
active: true,
pinned: false,
status: 'idle' as const,
},
],
});
@@ -499,6 +505,7 @@ describe('TinyPlaceOrchestrationTab', () => {
unread: 0,
active: true,
pinned: false,
status: 'idle' as const,
},
],
});
@@ -0,0 +1,69 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { SessionSummary } from '../../lib/orchestration/orchestrationClient';
import TinyPlaceRoster from './TinyPlaceRoster';
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
function session(over: Partial<SessionSummary> = {}): SessionSummary {
return {
sessionId: 's',
agentId: '@peer',
source: 'claude',
harnessType: 'claude',
status: 'idle',
chatKind: 'session',
lastMessageAt: '2026-07-06T00:00:00Z',
unread: 0,
active: true,
pinned: false,
...over,
};
}
describe('TinyPlaceRoster', () => {
it('shows the empty state when there are no instance sessions', () => {
// Pinned windows are not instances and must not count.
const pinned = session({ sessionId: 'master', chatKind: 'master', pinned: true });
render(<TinyPlaceRoster sessions={[pinned]} />);
expect(screen.getByTestId('tinyplace-roster-empty')).toBeInTheDocument();
});
it('groups instances by harness and lists an Other group for harness-less sessions', () => {
const sessions = [
session({ sessionId: 'c1', harnessType: 'claude' }),
session({ sessionId: 'x1', harnessType: 'codex', source: 'codex' }),
session({ sessionId: 'u1', harnessType: undefined, source: 'user_created' }),
];
render(<TinyPlaceRoster sessions={sessions} />);
expect(screen.getByText('Claude')).toBeInTheDocument();
expect(screen.getByText('Codex')).toBeInTheDocument();
// Harness-less session lands under the (translated) Other group; no empty Gemini group.
expect(screen.getByText('tinyplaceOrchestration.roster.other')).toBeInTheDocument();
expect(screen.queryByText('Gemini')).toBeNull();
expect(screen.getByTestId('instance-card-c1')).toBeInTheDocument();
expect(screen.getByTestId('instance-card-u1')).toBeInTheDocument();
});
it('marks the selected instance and forwards selection', () => {
const onSelect = vi.fn();
const sessions = [session({ sessionId: 'c1' }), session({ sessionId: 'c2' })];
render(<TinyPlaceRoster sessions={sessions} selectedId="c1" onSelect={onSelect} />);
expect(screen.getByTestId('instance-card-c1')).toHaveAttribute('data-selected', 'true');
expect(screen.getByTestId('instance-card-c2')).toHaveAttribute('data-selected', 'false');
fireEvent.click(screen.getByTestId('instance-card-c2'));
expect(onSelect).toHaveBeenCalledWith('c2');
});
it('passes resolved handles down to the cards', () => {
render(
<TinyPlaceRoster
sessions={[session({ sessionId: 'c1', agentId: '@peer' })]}
handles={{ '@peer': 'claudebot' }}
/>
);
const card = screen.getByTestId('instance-card-c1');
expect(within(card).getByText('@claudebot')).toBeInTheDocument();
});
});
@@ -0,0 +1,80 @@
/**
* TinyPlaceRoster — the instance roster: external agent sessions grouped by
* harness (Claude / Codex / Gemini, then an "Other" catch-all), each a
* selectable {@link InstanceCard}. Pinned master/subconscious windows are not
* instances and are excluded here.
*
* Presentational only; the parent passes the session list, selection, and an
* optional address→@handle map.
*/
import { useT } from '../../lib/i18n/I18nContext';
import type { HarnessType, SessionSummary } from '../../lib/orchestration/orchestrationClient';
import InstanceCard from './InstanceCard';
export interface TinyPlaceRosterProps {
sessions: SessionSummary[];
selectedId?: string;
onSelect?: (sessionId: string) => void;
/** Resolved address → `@handle` map (best-effort; address is the fallback). */
handles?: Record<string, string | null>;
}
// Grouped in this order; brand names are identity, not translated UI copy.
const HARNESS_GROUPS: Array<{ key: HarnessType; label: string }> = [
{ key: 'claude', label: 'Claude' },
{ key: 'codex', label: 'Codex' },
{ key: 'gemini', label: 'Gemini' },
];
export default function TinyPlaceRoster({
sessions,
selectedId,
onSelect,
handles,
}: TinyPlaceRosterProps): React.ReactElement {
const { t } = useT();
// Instances are the non-pinned session windows.
const instances = sessions.filter(s => !s.pinned && s.chatKind === 'session');
const byHarness = (harness: HarnessType): SessionSummary[] =>
instances.filter(s => s.harnessType === harness);
const ungrouped = instances.filter(s => !s.harnessType);
const groups = [
...HARNESS_GROUPS.map(g => ({ label: g.label, rows: byHarness(g.key) })),
{ label: t('tinyplaceOrchestration.roster.other'), rows: ungrouped },
].filter(g => g.rows.length > 0);
return (
<section data-testid="tinyplace-roster" className="min-w-0">
<h4 className="px-3 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wide text-content-muted">
{t('tinyplaceOrchestration.roster.instances')}
</h4>
{instances.length === 0 ? (
<p
data-testid="tinyplace-roster-empty"
className="px-3 py-4 text-[11px] text-content-faint">
{t('tinyplaceOrchestration.roster.empty')}
</p>
) : (
groups.map(group => (
<div key={group.label}>
<div className="px-3 pb-0.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-content-faint">
{group.label}
</div>
{group.rows.map(session => (
<InstanceCard
key={session.sessionId}
session={session}
selected={session.sessionId === selectedId}
handle={handles?.[session.agentId]}
onSelect={onSelect ? () => onSelect(session.sessionId) : undefined}
/>
))}
</div>
))
)}
</section>
);
}
+8
View File
@@ -288,6 +288,14 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': 'إرسال',
'tinyplaceOrchestration.composer.sendFailed': 'فشل إرسال الرسالة',
'tinyplaceOrchestration.steering.label': 'التوجيه',
'tinyplaceOrchestration.roster.instances': 'مثيلات',
'tinyplaceOrchestration.roster.empty': 'لا توجد مثيلات وكيل بعد',
'tinyplaceOrchestration.roster.other': 'أخرى',
'tinyplaceOrchestration.status.running': 'قيد التشغيل',
'tinyplaceOrchestration.status.idle': 'خامل',
'tinyplaceOrchestration.status.waitingApproval': 'في انتظار الموافقة',
'tinyplaceOrchestration.status.errored': 'خطأ',
'tinyplaceOrchestration.status.stopped': 'متوقف',
'brain.empty': 'دماغك فارغ في الوقت الحالي — قم بربط مصدر لبدء بناء الذاكرة.',
'brain.error': 'تعذّر تحميل دماغك. يرجى المحاولة مرة أخرى.',
'common.cancel': 'إلغاء',
+8
View File
@@ -295,6 +295,14 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': 'পাঠান',
'tinyplaceOrchestration.composer.sendFailed': 'বার্তা পাঠাতে ব্যর্থ',
'tinyplaceOrchestration.steering.label': 'নির্দেশনা',
'tinyplaceOrchestration.roster.instances': 'ইনস্ট্যান্স',
'tinyplaceOrchestration.roster.empty': 'এখনও কোনো এজেন্ট ইনস্ট্যান্স নেই',
'tinyplaceOrchestration.roster.other': 'অন্যান্য',
'tinyplaceOrchestration.status.running': 'চলছে',
'tinyplaceOrchestration.status.idle': 'নিষ্ক্রিয়',
'tinyplaceOrchestration.status.waitingApproval': 'অনুমোদনের অপেক্ষায়',
'tinyplaceOrchestration.status.errored': 'ত্রুটিপূর্ণ',
'tinyplaceOrchestration.status.stopped': 'থেমে গেছে',
'brain.empty': 'আপনার ব্রেইন এখন খালি — মেমরি তৈরি শুরু করতে একটি উৎস সংযুক্ত করুন।',
'brain.error': 'আপনার ব্রেইন লোড করা যায়নি। অনুগ্রহ করে আবার চেষ্টা করুন।',
'common.cancel': 'বাতিল',
+8
View File
@@ -302,6 +302,14 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': 'Senden',
'tinyplaceOrchestration.composer.sendFailed': 'Nachricht konnte nicht gesendet werden',
'tinyplaceOrchestration.steering.label': 'Steuerung',
'tinyplaceOrchestration.roster.instances': 'Instanzen',
'tinyplaceOrchestration.roster.empty': 'Noch keine Agent-Instanzen',
'tinyplaceOrchestration.roster.other': 'Andere',
'tinyplaceOrchestration.status.running': 'Läuft',
'tinyplaceOrchestration.status.idle': 'Inaktiv',
'tinyplaceOrchestration.status.waitingApproval': 'Warten auf Freigabe',
'tinyplaceOrchestration.status.errored': 'Fehler',
'tinyplaceOrchestration.status.stopped': 'Gestoppt',
'brain.empty': 'Dein Gehirn ist noch leer verbinde eine Quelle, um Speicher aufzubauen.',
'brain.error': 'Dein Gehirn konnte nicht geladen werden. Bitte versuche es erneut.',
'common.cancel': 'Abbrechen',
+8
View File
@@ -4223,6 +4223,14 @@ const en: TranslationMap = {
'tinyplaceOrchestration.composer.send': 'Send',
'tinyplaceOrchestration.composer.sendFailed': 'Failed to send message',
'tinyplaceOrchestration.steering.label': 'Steering',
'tinyplaceOrchestration.roster.instances': 'Instances',
'tinyplaceOrchestration.roster.empty': 'No agent instances yet',
'tinyplaceOrchestration.roster.other': 'Other',
'tinyplaceOrchestration.status.running': 'Running',
'tinyplaceOrchestration.status.idle': 'Idle',
'tinyplaceOrchestration.status.waitingApproval': 'Waiting for approval',
'tinyplaceOrchestration.status.errored': 'Errored',
'tinyplaceOrchestration.status.stopped': 'Stopped',
'intelligence.teams.subtitle': 'Coordinated agent teams and the tasks they share.',
'intelligence.teams.loading': 'Loading teams…',
'intelligence.teams.failedToLoad': 'Failed to load teams',
+8
View File
@@ -299,6 +299,14 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': 'Enviar',
'tinyplaceOrchestration.composer.sendFailed': 'No se pudo enviar el mensaje',
'tinyplaceOrchestration.steering.label': 'Dirección',
'tinyplaceOrchestration.roster.instances': 'Instancias',
'tinyplaceOrchestration.roster.empty': 'Aún no hay instancias de agente',
'tinyplaceOrchestration.roster.other': 'Otros',
'tinyplaceOrchestration.status.running': 'En ejecución',
'tinyplaceOrchestration.status.idle': 'Inactivo',
'tinyplaceOrchestration.status.waitingApproval': 'Esperando aprobación',
'tinyplaceOrchestration.status.errored': 'Con error',
'tinyplaceOrchestration.status.stopped': 'Detenido',
'brain.empty':
'Tu cerebro está vacío por ahora: conecta una fuente para empezar a construir tu memoria.',
'brain.error': 'No se pudo cargar tu cerebro. Inténtalo de nuevo.',
+8
View File
@@ -299,6 +299,14 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': 'Envoyer',
'tinyplaceOrchestration.composer.sendFailed': 'Échec de lenvoi du message',
'tinyplaceOrchestration.steering.label': 'Pilotage',
'tinyplaceOrchestration.roster.instances': 'Instances',
'tinyplaceOrchestration.roster.empty': 'Aucune instance dagent',
'tinyplaceOrchestration.roster.other': 'Autre',
'tinyplaceOrchestration.status.running': 'En cours',
'tinyplaceOrchestration.status.idle': 'Inactif',
'tinyplaceOrchestration.status.waitingApproval': 'En attente dapprobation',
'tinyplaceOrchestration.status.errored': 'En erreur',
'tinyplaceOrchestration.status.stopped': 'Arrêté',
'brain.empty':
'Votre cerveau est vide pour linstant — connectez une source pour commencer à constituer votre mémoire.',
'brain.error': 'Impossible de charger votre cerveau. Veuillez réessayer.',
+8
View File
@@ -295,6 +295,14 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': 'भेजें',
'tinyplaceOrchestration.composer.sendFailed': 'संदेश भेजने में विफल',
'tinyplaceOrchestration.steering.label': 'मार्गदर्शन',
'tinyplaceOrchestration.roster.instances': 'इंस्टेंस',
'tinyplaceOrchestration.roster.empty': 'अभी तक कोई एजेंट इंस्टेंस नहीं',
'tinyplaceOrchestration.roster.other': 'अन्य',
'tinyplaceOrchestration.status.running': 'चल रहा है',
'tinyplaceOrchestration.status.idle': 'निष्क्रिय',
'tinyplaceOrchestration.status.waitingApproval': 'अनुमोदन की प्रतीक्षा में',
'tinyplaceOrchestration.status.errored': 'त्रुटि',
'tinyplaceOrchestration.status.stopped': 'रुका हुआ',
'brain.empty': 'आपका ब्रेन अभी खाली है — मेमोरी बनाना शुरू करने के लिए कोई स्रोत कनेक्ट करें।',
'brain.error': 'आपका ब्रेन लोड नहीं हो सका। कृपया फिर से प्रयास करें।',
'common.cancel': 'रद्द करें',
+8
View File
@@ -296,6 +296,14 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': 'Kirim',
'tinyplaceOrchestration.composer.sendFailed': 'Gagal mengirim pesan',
'tinyplaceOrchestration.steering.label': 'Pengarahan',
'tinyplaceOrchestration.roster.instances': 'Instansi',
'tinyplaceOrchestration.roster.empty': 'Belum ada instansi agen',
'tinyplaceOrchestration.roster.other': 'Lainnya',
'tinyplaceOrchestration.status.running': 'Berjalan',
'tinyplaceOrchestration.status.idle': 'Diam',
'tinyplaceOrchestration.status.waitingApproval': 'Menunggu persetujuan',
'tinyplaceOrchestration.status.errored': 'Bermasalah',
'tinyplaceOrchestration.status.stopped': 'Berhenti',
'brain.empty': 'Otak Anda masih kosong — hubungkan sumber untuk mulai membangun memori.',
'brain.error': 'Tidak dapat memuat otak Anda. Silakan coba lagi.',
'common.cancel': 'Batal',
+8
View File
@@ -299,6 +299,14 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': 'Invia',
'tinyplaceOrchestration.composer.sendFailed': 'Invio del messaggio non riuscito',
'tinyplaceOrchestration.steering.label': 'Direzione',
'tinyplaceOrchestration.roster.instances': 'Istanze',
'tinyplaceOrchestration.roster.empty': 'Nessuna istanza agente',
'tinyplaceOrchestration.roster.other': 'Altro',
'tinyplaceOrchestration.status.running': 'In esecuzione',
'tinyplaceOrchestration.status.idle': 'Inattivo',
'tinyplaceOrchestration.status.waitingApproval': 'In attesa di approvazione',
'tinyplaceOrchestration.status.errored': 'In errore',
'tinyplaceOrchestration.status.stopped': 'Arrestato',
'brain.empty':
'Il tuo cervello è ancora vuoto: collega una fonte per iniziare a costruire la memoria.',
'brain.error': 'Impossibile caricare il tuo cervello. Riprova.',
+8
View File
@@ -292,6 +292,14 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': '보내기',
'tinyplaceOrchestration.composer.sendFailed': '메시지를 보내지 못했습니다',
'tinyplaceOrchestration.steering.label': '조종',
'tinyplaceOrchestration.roster.instances': '인스턴스',
'tinyplaceOrchestration.roster.empty': '아직 에이전트 인스턴스가 없습니다',
'tinyplaceOrchestration.roster.other': '기타',
'tinyplaceOrchestration.status.running': '실행 중',
'tinyplaceOrchestration.status.idle': '유휴',
'tinyplaceOrchestration.status.waitingApproval': '승인 대기 중',
'tinyplaceOrchestration.status.errored': '오류',
'tinyplaceOrchestration.status.stopped': '중지됨',
'brain.empty': '아직 브레인이 비어 있습니다 — 소스를 연결하여 메모리를 만들어 보세요.',
'brain.error': '브레인을 불러올 수 없습니다. 다시 시도해 주세요.',
'common.cancel': '취소',
+8
View File
@@ -300,6 +300,14 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': 'Wyślij',
'tinyplaceOrchestration.composer.sendFailed': 'Nie udało się wysłać wiadomości',
'tinyplaceOrchestration.steering.label': 'Sterowanie',
'tinyplaceOrchestration.roster.instances': 'Instancje',
'tinyplaceOrchestration.roster.empty': 'Brak instancji agentów',
'tinyplaceOrchestration.roster.other': 'Inne',
'tinyplaceOrchestration.status.running': 'Działa',
'tinyplaceOrchestration.status.idle': 'Bezczynny',
'tinyplaceOrchestration.status.waitingApproval': 'Oczekuje na zatwierdzenie',
'tinyplaceOrchestration.status.errored': 'Błąd',
'tinyplaceOrchestration.status.stopped': 'Zatrzymany',
'brain.empty': 'Twój mózg jest na razie pusty — połącz źródło, aby zacząć budować pamięć.',
'brain.error': 'Nie udało się załadować Twojego mózgu. Spróbuj ponownie.',
'common.cancel': 'Anuluj',
+8
View File
@@ -298,6 +298,14 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': 'Enviar',
'tinyplaceOrchestration.composer.sendFailed': 'Falha ao enviar mensagem',
'tinyplaceOrchestration.steering.label': 'Direção',
'tinyplaceOrchestration.roster.instances': 'Instâncias',
'tinyplaceOrchestration.roster.empty': 'Nenhuma instância de agente ainda',
'tinyplaceOrchestration.roster.other': 'Outros',
'tinyplaceOrchestration.status.running': 'Em execução',
'tinyplaceOrchestration.status.idle': 'Inativo',
'tinyplaceOrchestration.status.waitingApproval': 'Aguardando aprovação',
'tinyplaceOrchestration.status.errored': 'Com erro',
'tinyplaceOrchestration.status.stopped': 'Parado',
'brain.empty':
'Seu cérebro está vazio por enquanto — conecte uma fonte para começar a construir a memória.',
'brain.error': 'Não foi possível carregar seu cérebro. Tente novamente.',
+8
View File
@@ -300,6 +300,14 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': 'Отправить',
'tinyplaceOrchestration.composer.sendFailed': 'Не удалось отправить сообщение',
'tinyplaceOrchestration.steering.label': 'Управление',
'tinyplaceOrchestration.roster.instances': 'Экземпляры',
'tinyplaceOrchestration.roster.empty': 'Пока нет экземпляров агентов',
'tinyplaceOrchestration.roster.other': 'Другое',
'tinyplaceOrchestration.status.running': 'Выполняется',
'tinyplaceOrchestration.status.idle': 'Простаивает',
'tinyplaceOrchestration.status.waitingApproval': 'Ожидает подтверждения',
'tinyplaceOrchestration.status.errored': 'Ошибка',
'tinyplaceOrchestration.status.stopped': 'Остановлен',
'brain.empty': 'Ваш мозг пока пуст — подключите источник, чтобы начать формировать память.',
'brain.error': 'Не удалось загрузить ваш мозг. Пожалуйста, попробуйте ещё раз.',
'common.cancel': 'Отмена',
+8
View File
@@ -276,6 +276,14 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.composer.send': '发送',
'tinyplaceOrchestration.composer.sendFailed': '消息发送失败',
'tinyplaceOrchestration.steering.label': '引导',
'tinyplaceOrchestration.roster.instances': '实例',
'tinyplaceOrchestration.roster.empty': '暂无代理实例',
'tinyplaceOrchestration.roster.other': '其他',
'tinyplaceOrchestration.status.running': '运行中',
'tinyplaceOrchestration.status.idle': '空闲',
'tinyplaceOrchestration.status.waitingApproval': '等待批准',
'tinyplaceOrchestration.status.errored': '出错',
'tinyplaceOrchestration.status.stopped': '已停止',
'brain.empty': '你的大脑暂时是空的——连接一个来源即可开始构建记忆。',
'brain.error': '无法加载你的大脑,请重试。',
'common.cancel': '取消',
@@ -20,10 +20,27 @@ export { PaymentRequiredError };
export type OrchestrationChatKind = 'master' | 'subconscious' | 'session';
/** External agent harness that emits a session (drives the roster grouping). */
export type HarnessType = 'claude' | 'codex' | 'gemini';
/**
* Coarse instance status for the roster dot. Peer instances carry no true
* run-state yet, so the core derives only `idle` / `stopped` today; the
* remaining states are modelled here (and by `InstanceStatusDot`) for the
* attention-queue and run-state follow-ups.
*/
export type InstanceStatus = 'running' | 'idle' | 'waiting-approval' | 'errored' | 'stopped';
export interface SessionSummary {
sessionId: string;
agentId: string;
source: string;
/** Emitting harness when this is an external instance; absent for master/subconscious/user-created. */
harnessType?: HarnessType;
/** Coarse status for the roster dot (see {@link InstanceStatus}). */
status: InstanceStatus;
/** One-line current activity (latest message preview) for the roster. */
currentTask?: string;
label?: string;
workspace?: string;
chatKind: OrchestrationChatKind;
+122 -3
View File
@@ -132,6 +132,15 @@ struct SessionSummary {
session_id: String,
agent_id: String,
source: String,
/// The emitting harness (claude/codex/gemini) when this is an external agent
/// instance; absent for the pinned master/subconscious/user-created windows.
#[serde(skip_serializing_if = "Option::is_none")]
harness_type: Option<String>,
/// Coarse instance status for the roster status dot (see `derive_status`).
status: String,
/// One-line current activity (latest message preview) for the roster.
#[serde(skip_serializing_if = "Option::is_none")]
current_task: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -196,14 +205,60 @@ fn is_active(last_message_at: &str) -> bool {
}
}
fn summarize(session: OrchestrationSession, unread: i64, pinned: bool) -> SessionSummary {
/// The harness provider for a session, when its `source` names one. Session
/// windows persist the emitting harness (claude/codex/gemini) in `source` (see
/// `ingest.rs`); the sentinel windows (master/subconscious/user_created/
/// orchestration) carry no harness and yield `None`.
fn harness_type_for(source: &str) -> Option<String> {
matches!(source, "claude" | "codex" | "gemini").then(|| source.to_string())
}
/// Coarse instance status for the roster dot, derived from activity. Peer
/// instances carry no true run-state yet, so today an instance is `idle` when it
/// has recent traffic and `stopped` otherwise. The richer
/// running/waiting-approval/errored states are reserved for the attention-queue
/// and run-state follow-ups; the renderer's `InstanceStatusDot` already models
/// all five.
fn derive_status(active: bool) -> &'static str {
if active {
"idle"
} else {
"stopped"
}
}
/// One-line, UTF-8-safe preview of a message body for the roster task line.
/// Truncates on a char boundary and reserves room for the ellipsis so the result
/// never exceeds `MAX` chars (avoids the byte-slice panics noted in the codebase).
fn task_preview(body: &str) -> String {
const MAX: usize = 80;
let trimmed = body.trim();
if trimmed.chars().count() <= MAX {
return trimmed.to_string();
}
let mut out: String = trimmed.chars().take(MAX - 1).collect();
out.push('…');
out
}
fn summarize(
session: OrchestrationSession,
unread: i64,
pinned: bool,
current_task: Option<String>,
) -> SessionSummary {
let chat_kind = chat_kind_for_session(&session.session_id);
let active = pinned || is_active(&session.last_message_at);
let harness_type = harness_type_for(&session.source);
let status = derive_status(active).to_string();
SessionSummary {
chat_kind: chat_kind.as_str().to_string(),
active,
unread,
pinned,
harness_type,
status,
current_task,
session_id: session.session_id,
agent_id: session.agent_id,
source: session.source,
@@ -231,7 +286,15 @@ fn handle_sessions_list(_params: Map<String, Value>) -> ControllerFuture {
_ => {}
}
let pinned = matches!(session.session_id.as_str(), "master" | "subconscious");
out.push(summarize(session, unread, pinned));
// Roster task line: latest message preview for real instance
// windows; the pinned windows don't need one.
let current_task = if pinned {
None
} else {
store::latest_message_preview(conn, &session.agent_id, &session.session_id)?
.map(|body| task_preview(&body))
};
out.push(summarize(session, unread, pinned, current_task));
}
// Ensure the pinned windows always exist even before any traffic.
if !have_master {
@@ -278,7 +341,7 @@ fn handle_sessions_create(params: Map<String, Value>) -> ControllerFuture {
})
.map_err(|e| format!("sessions_create: {e}"))?;
super::bus::notify_orchestration_message(&agent_id, &session_id, "session");
to_json(serde_json::json!({ "session": summarize(session, 0, false) }))
to_json(serde_json::json!({ "session": summarize(session, 0, false, None) }))
})
}
@@ -287,6 +350,9 @@ fn pinned_placeholder(session_id: &str) -> SessionSummary {
session_id: session_id.to_string(),
agent_id: session_id.to_string(),
source: "orchestration".to_string(),
harness_type: None,
status: derive_status(true).to_string(),
current_task: None,
label: None,
workspace: None,
chat_kind: chat_kind_for_session(session_id).as_str().to_string(),
@@ -649,4 +715,57 @@ mod tests {
params.insert("chat".to_string(), Value::String(" ".to_string()));
assert!(required_param(&params, "chat").is_err());
}
#[test]
fn harness_type_only_for_known_providers() {
assert_eq!(harness_type_for("claude").as_deref(), Some("claude"));
assert_eq!(harness_type_for("codex").as_deref(), Some("codex"));
assert_eq!(harness_type_for("gemini").as_deref(), Some("gemini"));
// Sentinel / origin sources are not harnesses.
assert_eq!(harness_type_for("master"), None);
assert_eq!(harness_type_for("user_created"), None);
assert_eq!(harness_type_for("orchestration"), None);
}
#[test]
fn status_is_idle_when_active_else_stopped() {
assert_eq!(derive_status(true), "idle");
assert_eq!(derive_status(false), "stopped");
}
#[test]
fn task_preview_trims_and_caps_on_char_boundary() {
assert_eq!(task_preview(" hello "), "hello");
// A multibyte string longer than the cap truncates with an ellipsis and
// never exceeds MAX chars (no mid-codepoint panic).
let long = "é".repeat(200);
let preview = task_preview(&long);
assert_eq!(preview.chars().count(), 80);
assert!(preview.ends_with('…'));
}
#[test]
fn summarize_derives_harness_status_and_carries_task() {
let session = OrchestrationSession {
session_id: "w1".to_string(),
agent_id: "@peer".to_string(),
source: "claude".to_string(),
label: None,
workspace: None,
last_seq: 3,
created_at: "2020-01-01T00:00:00Z".to_string(),
// Stale timestamp → not active → stopped.
last_message_at: "2020-01-01T00:00:00Z".to_string(),
};
let summary = summarize(session, 2, false, Some("drafting cards".to_string()));
assert_eq!(summary.harness_type.as_deref(), Some("claude"));
assert_eq!(summary.status, "stopped");
assert_eq!(summary.current_task.as_deref(), Some("drafting cards"));
assert!(!summary.active);
// A pinned window is always active → idle, and carries no harness/task.
let pinned = pinned_placeholder("master");
assert_eq!(pinned.status, "idle");
assert!(pinned.harness_type.is_none());
}
}
+45
View File
@@ -230,6 +230,26 @@ pub fn session_last_seq(
.optional()?)
}
/// The most recent message body for a session — the roster task line's "current
/// activity". Newest by timestamp then seq; `None` when the session has no
/// messages yet. Body is decrypted plaintext (workspace-internal, like the rest
/// of this store).
pub fn latest_message_preview(
conn: &Connection,
agent_id: &str,
session_id: &str,
) -> Result<Option<String>> {
Ok(conn
.query_row(
"SELECT body FROM messages
WHERE agent_id = ?1 AND session_id = ?2
ORDER BY timestamp DESC, seq DESC LIMIT 1",
params![agent_id, session_id],
|row| row.get(0),
)
.optional()?)
}
/// List every persisted session row, newest activity first (stage-7 read surface).
pub fn list_sessions(conn: &Connection) -> Result<Vec<OrchestrationSession>> {
let mut stmt = conn.prepare(
@@ -748,6 +768,31 @@ mod tests {
.unwrap();
}
#[test]
fn latest_message_preview_returns_newest_or_none() {
let tmp = tempfile::tempdir().unwrap();
with_connection(tmp.path(), |conn| {
upsert_session(conn, &session("@a", "h1", 1))?;
// No messages yet.
assert_eq!(latest_message_preview(conn, "@a", "h1")?, None);
// Same timestamp → newest is decided by seq DESC.
insert_message(conn, &msg("m1", "@a", "h1", 1))?;
let mut newer = msg("m2", "@a", "h1", 2);
newer.body = "later line".into();
insert_message(conn, &newer)?;
assert_eq!(
latest_message_preview(conn, "@a", "h1")?.as_deref(),
Some("later line")
);
// Scoped to (agent, session): a different session is not returned.
assert_eq!(latest_message_preview(conn, "@a", "other")?, None);
Ok(())
})
.unwrap();
}
#[test]
fn world_diff_is_append_only_with_monotonic_seq_and_idempotent_cycles() {
let tmp = tempfile::tempdir().unwrap();