Skip to content
Merged
18 changes: 18 additions & 0 deletions app/src/components/intelligence/HarnessGlyph.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
40 changes: 40 additions & 0 deletions app/src/components/intelligence/HarnessGlyph.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
56 changes: 56 additions & 0 deletions app/src/components/intelligence/InstanceCard.test.tsx
Original file line number Diff line number Diff line change
@@ -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');
});
});
74 changes: 74 additions & 0 deletions app/src/components/intelligence/InstanceCard.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
27 changes: 27 additions & 0 deletions app/src/components/intelligence/InstanceStatusDot.test.tsx
Original file line number Diff line number Diff line change
@@ -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');
});
});
42 changes: 42 additions & 0 deletions app/src/components/intelligence/InstanceStatusDot.tsx
Original file line number Diff line number Diff line change
@@ -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]}`}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const PINNED_SESSIONS = [
unread: 0,
active: true,
pinned: true,
status: 'idle' as const,
},
{
sessionId: 'subconscious',
Expand All @@ -79,6 +80,7 @@ const PINNED_SESSIONS = [
unread: 0,
active: true,
pinned: true,
status: 'idle' as const,
},
];

Expand All @@ -96,6 +98,7 @@ describe('TinyPlaceOrchestrationTab', () => {
unread: 0,
active: true,
pinned: false,
status: 'idle' as const,
},
});
messagesListMock.mockResolvedValue({ messages: [] });
Expand Down Expand Up @@ -175,6 +178,7 @@ describe('TinyPlaceOrchestrationTab', () => {
unread: 0,
active: true,
pinned: false,
status: 'idle' as const,
},
],
});
Expand All @@ -201,6 +205,7 @@ describe('TinyPlaceOrchestrationTab', () => {
unread: 0,
active: true,
pinned: false,
status: 'idle' as const,
},
],
});
Expand Down Expand Up @@ -254,6 +259,7 @@ describe('TinyPlaceOrchestrationTab', () => {
unread: 3,
active: true,
pinned: false,
status: 'idle' as const,
},
],
});
Expand Down Expand Up @@ -499,6 +505,7 @@ describe('TinyPlaceOrchestrationTab', () => {
unread: 0,
active: true,
pinned: false,
status: 'idle' as const,
},
],
});
Expand Down
69 changes: 69 additions & 0 deletions app/src/components/intelligence/TinyPlaceRoster.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading