mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
[4/5] feat(intelligence): TinyPlace orchestration hub — attention zone + layout split (#4609)
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ChatMessage, ChatWindow } from '../../lib/orchestration/useOrchestrationChats';
|
||||
import { ChatListButton, MessageBubble } from './OrchestrationChatPrimitives';
|
||||
|
||||
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
|
||||
|
||||
const chat = (over: Partial<ChatWindow>): ChatWindow =>
|
||||
({
|
||||
id: 'sess-1',
|
||||
kind: 'session',
|
||||
title: 'Worker',
|
||||
subtitle: 'sub',
|
||||
preview: 'last message',
|
||||
pinned: false,
|
||||
active: true,
|
||||
unread: 0,
|
||||
lastTimestamp: '2026-07-01T12:00:00.000Z',
|
||||
...over,
|
||||
}) as ChatWindow;
|
||||
|
||||
describe('ChatListButton', () => {
|
||||
it('renders unread count + active badge for an active unread session', () => {
|
||||
render(<ChatListButton chat={chat({ unread: 4, active: true })} selected onSelect={() => {}} />);
|
||||
expect(screen.getByText('4')).toBeInTheDocument();
|
||||
expect(screen.getByText('tinyplaceOrchestration.active')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the inactive badge and a contact badge when provided', () => {
|
||||
render(
|
||||
<ChatListButton
|
||||
chat={chat({ active: false })}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
contactBadge="tinyplaceOrchestration.pairing.linked"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('tinyplaceOrchestration.inactive')).toBeInTheDocument();
|
||||
expect(screen.getByText('tinyplaceOrchestration.pairing.linked')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the subconscious badge and fires onSelect', () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<ChatListButton chat={chat({ kind: 'subconscious' })} selected={false} onSelect={onSelect} />);
|
||||
expect(screen.getByText('tinyplaceOrchestration.subconsciousBadge')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('tinyplace-chat-sess-1'));
|
||||
expect(onSelect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessageBubble', () => {
|
||||
const message = (over: Partial<ChatMessage>): ChatMessage =>
|
||||
({
|
||||
id: 'm1',
|
||||
from: '@peer',
|
||||
body: 'hello there',
|
||||
timestamp: '2026-07-01T12:00:00.000Z',
|
||||
encrypted: false,
|
||||
...over,
|
||||
}) as ChatMessage;
|
||||
|
||||
it('renders sender + body', () => {
|
||||
render(<MessageBubble message={message({})} />);
|
||||
expect(screen.getByText('@peer')).toBeInTheDocument();
|
||||
expect(screen.getByText('hello there')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('mutes an encrypted (undecryptable) message body', () => {
|
||||
render(<MessageBubble message={message({ encrypted: true, body: '••••' })} />);
|
||||
expect(screen.getByText('••••')).toHaveClass('text-content-muted');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Presentational primitives for the TinyPlace Orchestration tab's chat surface:
|
||||
* a sidebar list row ({@link ChatListButton}) and a message bubble
|
||||
* ({@link MessageBubble}). Extracted from the tab container so both the sidebar
|
||||
* and focus pane render chats identically.
|
||||
*/
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import type { ChatMessage, ChatWindow } from '../../lib/orchestration/useOrchestrationChats';
|
||||
import { formatTime } from './orchestrationTabHelpers';
|
||||
|
||||
export interface ChatListButtonProps {
|
||||
chat: ChatWindow;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
contactBadge?: string | null;
|
||||
}
|
||||
|
||||
export function ChatListButton({
|
||||
chat,
|
||||
selected,
|
||||
onSelect,
|
||||
contactBadge,
|
||||
}: ChatListButtonProps): ReactElement {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`tinyplace-chat-${chat.id}`}
|
||||
onClick={onSelect}
|
||||
className={`flex w-full items-start gap-3 border-b border-line-subtle px-3 py-3 text-left transition last:border-b-0 hover:bg-surface-hover ${
|
||||
selected ? 'bg-surface-muted' : ''
|
||||
}`}>
|
||||
<span className="mt-0.5 flex h-9 w-9 flex-none items-center justify-center rounded-lg border border-line bg-surface-strong text-xs font-semibold text-content-secondary">
|
||||
{chat.kind === 'subconscious' ? 'S' : chat.kind === 'master' ? 'M' : '#'}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-semibold text-content">{chat.title}</span>
|
||||
<span className="flex-none text-[10px] text-content-faint">
|
||||
{formatTime(chat.lastTimestamp)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[11px] text-content-muted">
|
||||
{chat.kind === 'subconscious'
|
||||
? t('tinyplaceOrchestration.subconsciousBadge')
|
||||
: chat.subtitle}
|
||||
</span>
|
||||
<span className="mt-1 flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-content-faint">{chat.preview}</span>
|
||||
{chat.unread > 0 ? (
|
||||
<span className="flex-none rounded-full bg-ocean-500 px-1.5 py-0.5 text-[10px] font-semibold text-content-inverted">
|
||||
{chat.unread}
|
||||
</span>
|
||||
) : null}
|
||||
{!chat.pinned ? (
|
||||
<span
|
||||
className={`flex-none rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||
chat.active
|
||||
? 'bg-sage-100 text-sage-700 dark:bg-sage-500/15 dark:text-sage-300'
|
||||
: 'bg-surface-strong text-content-faint'
|
||||
}`}>
|
||||
{chat.active
|
||||
? t('tinyplaceOrchestration.active')
|
||||
: t('tinyplaceOrchestration.inactive')}
|
||||
</span>
|
||||
) : null}
|
||||
{contactBadge ? (
|
||||
<span className="flex-none rounded-full bg-surface-strong px-1.5 py-0.5 text-[10px] font-medium text-content-faint">
|
||||
{t(contactBadge)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: { message: ChatMessage }): ReactElement {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="mt-1.5 h-2 w-2 flex-none rounded-full bg-ocean-500" />
|
||||
<div className="min-w-0 rounded-lg border border-line bg-surface px-3 py-2 shadow-soft">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-xs font-semibold text-content-secondary">{message.from}</span>
|
||||
<span className="text-[10px] text-content-faint">{formatTime(message.timestamp)}</span>
|
||||
</div>
|
||||
<p
|
||||
className={`mt-1 whitespace-pre-wrap break-words text-sm ${
|
||||
message.encrypted ? 'text-content-muted' : 'text-content'
|
||||
}`}>
|
||||
{message.body}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ChatWindow } from '../../lib/orchestration/useOrchestrationChats';
|
||||
import OrchestrationFocusPane, {
|
||||
type OrchestrationFocusPaneProps,
|
||||
} from './OrchestrationFocusPane';
|
||||
|
||||
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
|
||||
|
||||
const chat = (over: Partial<ChatWindow>): ChatWindow =>
|
||||
({
|
||||
id: 'sess-1',
|
||||
kind: 'session',
|
||||
title: 'Worker Alpha',
|
||||
subtitle: 'sub',
|
||||
pinned: false,
|
||||
active: true,
|
||||
unread: 0,
|
||||
messages: [],
|
||||
lastTimestamp: '2026-07-01T12:00:00.000Z',
|
||||
...over,
|
||||
}) as ChatWindow;
|
||||
|
||||
let refresh: ReturnType<typeof vi.fn>;
|
||||
let onRunSteeringReview: ReturnType<typeof vi.fn>;
|
||||
|
||||
const props = (over: Partial<OrchestrationFocusPaneProps>): OrchestrationFocusPaneProps =>
|
||||
({
|
||||
selected: chat({}),
|
||||
sessionsState: { status: 'ok' },
|
||||
messagesState: { status: 'ok' },
|
||||
status: null,
|
||||
masterError: null,
|
||||
refresh,
|
||||
steeringText: null,
|
||||
runningReview: false,
|
||||
onRunSteeringReview,
|
||||
canCompose: false,
|
||||
composerBody: '',
|
||||
onComposerChange: vi.fn(),
|
||||
sending: false,
|
||||
onSubmitComposer: vi.fn(),
|
||||
...over,
|
||||
}) as OrchestrationFocusPaneProps;
|
||||
|
||||
describe('OrchestrationFocusPane', () => {
|
||||
beforeEach(() => {
|
||||
refresh = vi.fn();
|
||||
onRunSteeringReview = vi.fn();
|
||||
});
|
||||
|
||||
it('renders the payment-required state', () => {
|
||||
render(<OrchestrationFocusPane {...props({ sessionsState: { status: 'payment_required' } })} />);
|
||||
expect(screen.getByText('tinyplaceOrchestration.paymentRequired')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a sessions load error and retries', () => {
|
||||
render(
|
||||
<OrchestrationFocusPane
|
||||
{...props({ sessionsState: { status: 'error', message: 'rpc down' } })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/tinyplaceOrchestration.failedToLoad/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/rpc down/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('common.retry'));
|
||||
expect(refresh).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders a messages load error', () => {
|
||||
render(
|
||||
<OrchestrationFocusPane
|
||||
{...props({ messagesState: { status: 'error', message: 'msg boom' } })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/msg boom/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the steering header with expiry + last review and runs a review', () => {
|
||||
render(
|
||||
<OrchestrationFocusPane
|
||||
{...props({
|
||||
selected: chat({ kind: 'subconscious', pinned: true }),
|
||||
steeringText: 'ship the migration',
|
||||
status: {
|
||||
steering: {
|
||||
text: 'ship the migration',
|
||||
createdAt: '2026-07-04T00:00:00.000Z',
|
||||
expiresAfterCycles: 12,
|
||||
},
|
||||
lastTickAt: 1_700_000_000,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
const header = screen.getByTestId('tinyplace-steering-header');
|
||||
expect(within(header).getByText('ship the migration')).toBeInTheDocument();
|
||||
fireEvent.click(within(header).getByText('tinyplaceOrchestration.steeringHeader.runReview'));
|
||||
expect(onRunSteeringReview).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the running label while a review is in flight', () => {
|
||||
render(
|
||||
<OrchestrationFocusPane
|
||||
{...props({ selected: chat({ kind: 'subconscious' }), runningReview: true })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('tinyplaceOrchestration.steeringHeader.running')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces a composer send error when composing', () => {
|
||||
render(
|
||||
<OrchestrationFocusPane {...props({ canCompose: true, masterError: 'send failed' })} />
|
||||
);
|
||||
expect(screen.getByTestId('tinyplace-master-composer-input')).toBeInTheDocument();
|
||||
expect(screen.getByText(/send failed/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders message bubbles for the selected chat', () => {
|
||||
render(
|
||||
<OrchestrationFocusPane
|
||||
{...props({
|
||||
selected: chat({
|
||||
messages: [
|
||||
{
|
||||
id: 'm1',
|
||||
from: '@peer',
|
||||
body: 'hi there',
|
||||
timestamp: '2026-07-01T12:00:00.000Z',
|
||||
encrypted: false,
|
||||
},
|
||||
] as never,
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
within(screen.getByTestId('tinyplace-chat-messages')).getByText('hi there')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* OrchestrationFocusPane — the right-hand focus column of the TinyPlace
|
||||
* Orchestration tab: the selected chat's header, the subconscious steering
|
||||
* status header, the message transcript (with load/error/empty states), and the
|
||||
* Master/session composer. Presentational: all state + handlers come from the
|
||||
* tab container.
|
||||
*/
|
||||
import type { FormEvent, ReactElement } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import type { useOrchestrationChats } from '../../lib/orchestration/useOrchestrationChats';
|
||||
import Button from '../ui/Button';
|
||||
import { MessageBubble } from './OrchestrationChatPrimitives';
|
||||
|
||||
type ChatsApi = ReturnType<typeof useOrchestrationChats>;
|
||||
|
||||
export interface OrchestrationFocusPaneProps {
|
||||
selected: ChatsApi['selected'];
|
||||
sessionsState: ChatsApi['sessionsState'];
|
||||
messagesState: ChatsApi['messagesState'];
|
||||
status: ChatsApi['status'];
|
||||
masterError: ChatsApi['masterError'];
|
||||
refresh: ChatsApi['refresh'];
|
||||
steeringText: string | null;
|
||||
runningReview: boolean;
|
||||
onRunSteeringReview: () => void;
|
||||
canCompose: boolean;
|
||||
composerBody: string;
|
||||
onComposerChange: (value: string) => void;
|
||||
sending: boolean;
|
||||
onSubmitComposer: (event: FormEvent<HTMLFormElement>) => void;
|
||||
}
|
||||
|
||||
export default function OrchestrationFocusPane({
|
||||
selected,
|
||||
sessionsState,
|
||||
messagesState,
|
||||
status,
|
||||
masterError,
|
||||
refresh,
|
||||
steeringText,
|
||||
runningReview,
|
||||
onRunSteeringReview,
|
||||
canCompose,
|
||||
composerBody,
|
||||
onComposerChange,
|
||||
sending,
|
||||
onSubmitComposer,
|
||||
}: OrchestrationFocusPaneProps): ReactElement {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<main className="flex min-w-0 flex-1 flex-col bg-surface">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-line px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-semibold text-content">{selected?.title}</h3>
|
||||
<p className="mt-0.5 truncate text-xs text-content-muted">{selected?.subtitle}</p>
|
||||
</div>
|
||||
{selected && !selected.pinned ? (
|
||||
<span
|
||||
className={`rounded-full px-2 py-1 text-xs font-medium ${
|
||||
selected.active
|
||||
? 'bg-sage-100 text-sage-700 dark:bg-sage-500/15 dark:text-sage-300'
|
||||
: 'bg-surface-strong text-content-muted'
|
||||
}`}>
|
||||
{selected.active
|
||||
? t('tinyplaceOrchestration.active')
|
||||
: t('tinyplaceOrchestration.inactive')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Steering status header — the tinyplace subconscious instance's output. */}
|
||||
{selected?.kind === 'subconscious' ? (
|
||||
<div
|
||||
data-testid="tinyplace-steering-header"
|
||||
className="flex items-center justify-between gap-3 border-b border-line bg-amber-50/40 px-5 py-3 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}
|
||||
<p className="mt-0.5 text-[11px] text-content-faint">
|
||||
{status?.steering
|
||||
? t('tinyplaceOrchestration.steeringHeader.expires').replace(
|
||||
'{n}',
|
||||
String(status.steering.expiresAfterCycles)
|
||||
)
|
||||
: ''}
|
||||
{status?.lastTickAt
|
||||
? `${status?.steering ? ' · ' : ''}${t(
|
||||
'tinyplaceOrchestration.steeringHeader.lastReview'
|
||||
)}: ${new Date(status.lastTickAt * 1000).toLocaleTimeString()}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void onRunSteeringReview()}
|
||||
disabled={runningReview}>
|
||||
{runningReview
|
||||
? t('tinyplaceOrchestration.steeringHeader.running')
|
||||
: t('tinyplaceOrchestration.steeringHeader.runReview')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sessionsState.status === 'loading' ? (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-content-muted">
|
||||
{t('tinyplaceOrchestration.loading')}
|
||||
</div>
|
||||
) : sessionsState.status === 'payment_required' ? (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-amber-600 dark:text-amber-300">
|
||||
{t('tinyplaceOrchestration.paymentRequired')}
|
||||
</div>
|
||||
) : sessionsState.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.message}
|
||||
</p>
|
||||
<Button variant="secondary" size="sm" onClick={() => void refresh()}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : messagesState.status === 'loading' ? (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-content-muted">
|
||||
{t('tinyplaceOrchestration.loading')}
|
||||
</div>
|
||||
) : 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')}: {messagesState.message}
|
||||
</p>
|
||||
<Button variant="secondary" size="sm" onClick={() => void refresh()}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : selected?.messages.length ? (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-surface-muted/20 p-5">
|
||||
<div className="space-y-3" data-testid="tinyplace-chat-messages">
|
||||
{selected.messages.map(message => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center px-6 text-center text-sm text-content-faint">
|
||||
{t('tinyplaceOrchestration.noMessages')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canCompose && sessionsState.status === 'ok' ? (
|
||||
<form
|
||||
className="flex flex-col gap-2 border-t border-line px-5 py-3"
|
||||
onSubmit={onSubmitComposer}>
|
||||
{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="tinyplace-master-composer-input"
|
||||
value={composerBody}
|
||||
onChange={event => onComposerChange(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-ocean-500 focus:ring-2 focus:ring-ocean-500/20"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
data-testid="tinyplace-master-composer-send"
|
||||
disabled={!composerBody.trim() || sending}>
|
||||
{t('tinyplaceOrchestration.composer.send')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { apiClient } from '../../agentworld/AgentWorldShell';
|
||||
import type { ContactView } from '../../lib/agentworld/invokeApiClient';
|
||||
import OrchestrationSidebar, { type OrchestrationSidebarProps } from './OrchestrationSidebar';
|
||||
|
||||
vi.mock('../../agentworld/AgentWorldShell', () => ({
|
||||
apiClient: {
|
||||
orchestrationPairing: {
|
||||
acceptRequest: vi.fn(async () => ({})),
|
||||
declineRequest: vi.fn(async () => ({})),
|
||||
blockRequest: vi.fn(async () => ({})),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
|
||||
|
||||
const acceptMock = vi.mocked(apiClient.orchestrationPairing.acceptRequest);
|
||||
const declineMock = vi.mocked(apiClient.orchestrationPairing.declineRequest);
|
||||
const blockMock = vi.mocked(apiClient.orchestrationPairing.blockRequest);
|
||||
|
||||
const REQUESTER = '3icjiLXhn6BMv43MsHjpKKxm7hEYBk7R5rvNXB1HUk7g';
|
||||
|
||||
const request = (): ContactView =>
|
||||
({ agentId: REQUESTER, status: 'pending', direction: 'incoming' }) as ContactView;
|
||||
|
||||
let onCreateSession: ReturnType<typeof vi.fn>;
|
||||
let onToggleContact: ReturnType<typeof vi.fn>;
|
||||
|
||||
const props = (over: Partial<OrchestrationSidebarProps>): OrchestrationSidebarProps =>
|
||||
({
|
||||
relayInfo: null,
|
||||
onRefreshAll: vi.fn(),
|
||||
refreshDisabled: false,
|
||||
steeringText: null,
|
||||
selfIdentity: null,
|
||||
identityLoading: false,
|
||||
attentionQueue: null,
|
||||
attentionLoading: false,
|
||||
onAttentionAction: vi.fn(),
|
||||
linkAgentId: '',
|
||||
onLinkAgentIdChange: vi.fn(),
|
||||
onSubmitLink: vi.fn(),
|
||||
pairingAction: null,
|
||||
contactStats: null,
|
||||
incomingRequests: [],
|
||||
outgoingCount: 0,
|
||||
pairingError: null,
|
||||
agentHandles: {},
|
||||
// Invoke the thunk so the underlying apiClient call is exercised.
|
||||
runPairingAction: vi.fn((_id: string, thunk: () => Promise<unknown>) => thunk()),
|
||||
pinned: [],
|
||||
selectedId: null,
|
||||
onSelectChat: vi.fn(),
|
||||
acceptedContactList: [],
|
||||
expandedContacts: {},
|
||||
onToggleContact,
|
||||
sessionsByContact: new Map(),
|
||||
creatingSession: null,
|
||||
onCreateSession,
|
||||
acceptedContacts: new Set<string>(),
|
||||
pendingContacts: new Set<string>(),
|
||||
ungroupedSessions: [],
|
||||
...over,
|
||||
}) as OrchestrationSidebarProps;
|
||||
|
||||
describe('OrchestrationSidebar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
onCreateSession = vi.fn();
|
||||
onToggleContact = vi.fn();
|
||||
});
|
||||
|
||||
it('runs accept / decline / block on an incoming request, resolving its address', () => {
|
||||
render(
|
||||
<OrchestrationSidebar
|
||||
{...props({ incomingRequests: [request()], agentHandles: { [REQUESTER]: 'peer' } })}
|
||||
/>
|
||||
);
|
||||
|
||||
// Handle is shown additively alongside the raw address.
|
||||
expect(screen.getByText('@peer')).toBeInTheDocument();
|
||||
expect(screen.getByText(REQUESTER)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('tinyplaceOrchestration.pairing.accept'));
|
||||
fireEvent.click(screen.getByText('tinyplaceOrchestration.pairing.decline'));
|
||||
fireEvent.click(screen.getByText('tinyplaceOrchestration.pairing.block'));
|
||||
|
||||
expect(acceptMock).toHaveBeenCalledWith(REQUESTER);
|
||||
expect(declineMock).toHaveBeenCalledWith(REQUESTER);
|
||||
expect(blockMock).toHaveBeenCalledWith(REQUESTER);
|
||||
});
|
||||
|
||||
it('expands a contact and starts a new session under it', () => {
|
||||
const contact = {
|
||||
agentId: REQUESTER,
|
||||
status: 'accepted',
|
||||
direction: 'incoming',
|
||||
} as ContactView;
|
||||
|
||||
render(
|
||||
<OrchestrationSidebar
|
||||
{...props({
|
||||
acceptedContactList: [contact],
|
||||
expandedContacts: { [REQUESTER]: true },
|
||||
agentHandles: { [REQUESTER]: 'peer' },
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId(`tinyplace-new-session-${REQUESTER}`));
|
||||
expect(onCreateSession).toHaveBeenCalledWith(REQUESTER);
|
||||
|
||||
fireEvent.click(
|
||||
within(screen.getByTestId(`tinyplace-contact-${REQUESTER}`)).getByText('@peer')
|
||||
);
|
||||
expect(onToggleContact).toHaveBeenCalledWith(REQUESTER);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* OrchestrationSidebar — the left rail of the TinyPlace Orchestration tab: the
|
||||
* topbar (title + relay badge + refresh + launch shell), the self-identity card,
|
||||
* the "Needs you" attention zone, the pairing panel (link form, stats, incoming
|
||||
* requests), and the roster tree (pinned chats + accepted contacts with their
|
||||
* nested sessions + ungrouped sessions).
|
||||
*
|
||||
* Presentational: all state + handlers come from the tab container. It imports
|
||||
* `apiClient` and the shared helpers directly so the request-action JSX stays
|
||||
* identical to the pre-extraction container.
|
||||
*/
|
||||
import debugFactory from 'debug';
|
||||
import type { FormEvent, ReactElement } from 'react';
|
||||
|
||||
import { apiClient } from '../../agentworld/AgentWorldShell';
|
||||
import type { ContactView, PairingSnapshot } from '../../lib/agentworld/invokeApiClient';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import type {
|
||||
AttentionAction,
|
||||
AttentionQueue,
|
||||
RelayInfo,
|
||||
SelfIdentity,
|
||||
} from '../../lib/orchestration/orchestrationClient';
|
||||
import type { ChatWindow } from '../../lib/orchestration/useOrchestrationChats';
|
||||
import Button from '../ui/Button';
|
||||
import AttentionQueueView from './AttentionQueue';
|
||||
import { ChatListButton } from './OrchestrationChatPrimitives';
|
||||
import { contactAddress, contactBadgeKey, truncate } from './orchestrationTabHelpers';
|
||||
import RelayBadge from './RelayBadge';
|
||||
import SelfIdentityCard from './SelfIdentityCard';
|
||||
|
||||
const debug = debugFactory('brain:tinyplace-orchestration');
|
||||
|
||||
export interface OrchestrationSidebarProps {
|
||||
relayInfo: RelayInfo | null;
|
||||
onRefreshAll: () => void;
|
||||
refreshDisabled: boolean;
|
||||
steeringText: string | null;
|
||||
selfIdentity: SelfIdentity | null;
|
||||
identityLoading: boolean;
|
||||
attentionQueue: AttentionQueue | null;
|
||||
attentionLoading: boolean;
|
||||
onAttentionAction: (action: AttentionAction) => void;
|
||||
linkAgentId: string;
|
||||
onLinkAgentIdChange: (value: string) => void;
|
||||
onSubmitLink: (event: FormEvent<HTMLFormElement>) => void;
|
||||
pairingAction: string | null;
|
||||
contactStats: PairingSnapshot['stats'] | null;
|
||||
incomingRequests: ContactView[];
|
||||
outgoingCount: number;
|
||||
pairingError: string | null;
|
||||
agentHandles: Record<string, string | null>;
|
||||
runPairingAction: (actionId: string, action: () => Promise<unknown>) => Promise<void>;
|
||||
pinned: ChatWindow[];
|
||||
selectedId: string | null;
|
||||
onSelectChat: (id: string) => void;
|
||||
acceptedContactList: ContactView[];
|
||||
expandedContacts: Record<string, boolean>;
|
||||
onToggleContact: (address: string) => void;
|
||||
sessionsByContact: Map<string, ChatWindow[]>;
|
||||
creatingSession: string | null;
|
||||
onCreateSession: (address: string) => void;
|
||||
acceptedContacts: Set<string>;
|
||||
pendingContacts: Set<string>;
|
||||
ungroupedSessions: ChatWindow[];
|
||||
}
|
||||
|
||||
export default function OrchestrationSidebar({
|
||||
relayInfo,
|
||||
onRefreshAll,
|
||||
refreshDisabled,
|
||||
steeringText,
|
||||
selfIdentity,
|
||||
identityLoading,
|
||||
attentionQueue,
|
||||
attentionLoading,
|
||||
onAttentionAction,
|
||||
linkAgentId,
|
||||
onLinkAgentIdChange,
|
||||
onSubmitLink,
|
||||
pairingAction,
|
||||
contactStats,
|
||||
incomingRequests,
|
||||
outgoingCount,
|
||||
pairingError,
|
||||
agentHandles,
|
||||
runPairingAction,
|
||||
pinned,
|
||||
selectedId,
|
||||
onSelectChat,
|
||||
acceptedContactList,
|
||||
expandedContacts,
|
||||
onToggleContact,
|
||||
sessionsByContact,
|
||||
creatingSession,
|
||||
onCreateSession,
|
||||
acceptedContacts,
|
||||
pendingContacts,
|
||||
ungroupedSessions,
|
||||
}: OrchestrationSidebarProps): ReactElement {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<aside className="flex w-80 flex-none flex-col border-r border-line bg-surface-muted/40">
|
||||
<div className="border-b border-line px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h3 className="truncate text-sm font-semibold text-content">
|
||||
{t('tinyplaceOrchestration.title')}
|
||||
</h3>
|
||||
<RelayBadge relay={relayInfo} />
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-[11px] text-content-muted">
|
||||
{t('tinyplaceOrchestration.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-none items-center gap-1.5">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onRefreshAll}
|
||||
aria-label={t('tinyplaceOrchestration.refresh')}
|
||||
disabled={refreshDisabled}>
|
||||
{t('tinyplaceOrchestration.refresh')}
|
||||
</Button>
|
||||
{/* Launch shell — external instance spawn is wired in a later PR. */}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
data-testid="tinyplace-new-instance"
|
||||
disabled
|
||||
title={t('tinyplaceOrchestration.newInstanceSoon')}>
|
||||
{t('tinyplaceOrchestration.newInstance')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{steeringText ? (
|
||||
<div
|
||||
data-testid="tinyplace-steering-chip"
|
||||
className="mt-2 flex items-start gap-1.5 rounded-md bg-amber-50 px-2 py-1 text-[11px] text-amber-700 dark:bg-amber-500/10 dark:text-amber-300">
|
||||
<span className="flex-none font-semibold uppercase tracking-wide">
|
||||
{t('tinyplaceOrchestration.steering.label')}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{truncate(steeringText, 72)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<SelfIdentityCard identity={selfIdentity} loading={identityLoading} />
|
||||
|
||||
<AttentionQueueView
|
||||
queue={attentionQueue}
|
||||
loading={attentionLoading}
|
||||
onAction={onAttentionAction}
|
||||
/>
|
||||
|
||||
<section className="border-b border-line px-4 py-3">
|
||||
<form className="space-y-2" onSubmit={onSubmitLink}>
|
||||
<label
|
||||
htmlFor="tinyplace-session-agent-id"
|
||||
className="block text-[10px] font-semibold uppercase tracking-wide text-content-muted">
|
||||
{t('tinyplaceOrchestration.pairing.linkLabel')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="tinyplace-session-agent-id"
|
||||
value={linkAgentId}
|
||||
onChange={event => onLinkAgentIdChange(event.target.value)}
|
||||
placeholder={t('tinyplaceOrchestration.pairing.linkPlaceholder')}
|
||||
className="min-w-0 flex-1 rounded-md border border-line bg-surface px-2 py-1.5 text-xs text-content outline-none transition focus:border-ocean-500 focus:ring-2 focus:ring-ocean-500/20"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={!linkAgentId.trim() || pairingAction !== null}>
|
||||
{t('tinyplaceOrchestration.pairing.linkAction')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 text-[10px] text-content-faint">
|
||||
<span className="rounded-full bg-surface-strong px-2 py-0.5">
|
||||
{t('tinyplaceOrchestration.pairing.linked')}: {contactStats?.contactCount ?? 0}
|
||||
</span>
|
||||
<span className="rounded-full bg-surface-strong px-2 py-0.5">
|
||||
{t('tinyplaceOrchestration.pairing.incoming')}: {incomingRequests.length}
|
||||
</span>
|
||||
<span className="rounded-full bg-surface-strong px-2 py-0.5">
|
||||
{t('tinyplaceOrchestration.pairing.outgoing')}: {outgoingCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{pairingError ? (
|
||||
<p className="mt-2 rounded-md bg-coral-50 px-2 py-1 text-xs text-coral-700 dark:bg-coral-500/10 dark:text-coral-300">
|
||||
{pairingError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{incomingRequests.length > 0 ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<h4 className="text-[10px] font-semibold uppercase tracking-wide text-content-muted">
|
||||
{t('tinyplaceOrchestration.pairing.requests')}
|
||||
</h4>
|
||||
{incomingRequests.map((request, index) => {
|
||||
const address = contactAddress(request);
|
||||
const handle = address ? agentHandles[address] : null;
|
||||
return (
|
||||
<div
|
||||
key={address || `request-${index}`}
|
||||
className="rounded-lg border border-line bg-surface px-2 py-2">
|
||||
{handle ? (
|
||||
<div className="truncate text-xs font-medium text-content">@{handle}</div>
|
||||
) : null}
|
||||
<div className="truncate font-mono text-[11px] text-content-muted">{address}</div>
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={pairingAction !== null || !address}
|
||||
onClick={() =>
|
||||
void runPairingAction(`accept:${address}`, () =>
|
||||
apiClient.orchestrationPairing.acceptRequest(address)
|
||||
)
|
||||
}>
|
||||
{t('tinyplaceOrchestration.pairing.accept')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={pairingAction !== null || !address}
|
||||
onClick={() =>
|
||||
void runPairingAction(`remove:${address}`, () =>
|
||||
apiClient.orchestrationPairing.declineRequest(address)
|
||||
)
|
||||
}>
|
||||
{t('tinyplaceOrchestration.pairing.decline')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={pairingAction !== null || !address}
|
||||
onClick={() =>
|
||||
void runPairingAction(`block:${address}`, () =>
|
||||
apiClient.orchestrationPairing.blockRequest(address)
|
||||
)
|
||||
}>
|
||||
{t('tinyplaceOrchestration.pairing.block')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<section>
|
||||
<h4 className="px-3 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wide text-content-muted">
|
||||
{t('tinyplaceOrchestration.pinned')}
|
||||
</h4>
|
||||
<div>
|
||||
{pinned.map(chat => (
|
||||
<ChatListButton
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
selected={selectedId === chat.id}
|
||||
onSelect={() => {
|
||||
debug('[tinyplace-orchestration] open pinned id=%s', chat.id);
|
||||
onSelectChat(chat.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 className="px-3 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wide text-content-muted">
|
||||
{t('tinyplaceOrchestration.contacts')}
|
||||
</h4>
|
||||
{acceptedContactList.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-content-faint">
|
||||
{t('tinyplaceOrchestration.noContacts')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 px-2 pb-2">
|
||||
{acceptedContactList.map((contact, index) => {
|
||||
const address = contactAddress(contact);
|
||||
const handle = address ? agentHandles[address] : null;
|
||||
const isOpen = !!expandedContacts[address];
|
||||
const contactSessions = address ? (sessionsByContact.get(address) ?? []) : [];
|
||||
return (
|
||||
<div
|
||||
key={address || `contact-${index}`}
|
||||
className="overflow-hidden rounded-lg border border-line bg-surface">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`tinyplace-contact-${address}`}
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => onToggleContact(address)}
|
||||
className="flex w-full items-center gap-2 px-2 py-2 text-left transition hover:bg-surface-hover">
|
||||
<span className="flex-none text-[10px] text-content-muted">
|
||||
{isOpen ? '▾' : '▸'}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
{handle ? (
|
||||
<span className="block truncate text-xs font-medium text-content">
|
||||
@{handle}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="block truncate font-mono text-[11px] text-content-muted">
|
||||
{address}
|
||||
</span>
|
||||
</span>
|
||||
{contactSessions.length > 0 ? (
|
||||
<span className="flex-none rounded-full bg-surface-strong px-1.5 py-0.5 text-[10px] font-medium text-content-faint">
|
||||
{contactSessions.length}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div className="border-t border-line-subtle">
|
||||
{contactSessions.map(chat => (
|
||||
<ChatListButton
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
selected={selectedId === chat.id}
|
||||
contactBadge={contactBadgeKey(chat, acceptedContacts, pendingContacts)}
|
||||
onSelect={() => {
|
||||
debug('[tinyplace-orchestration] open session id=%s', chat.id);
|
||||
onSelectChat(chat.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`tinyplace-new-session-${address}`}
|
||||
disabled={!address || creatingSession === address}
|
||||
onClick={() => onCreateSession(address)}
|
||||
className="flex w-full items-center gap-1 px-3 py-2 text-left text-[11px] font-medium text-ocean-500 transition hover:bg-surface-hover disabled:opacity-50">
|
||||
+ {t('tinyplaceOrchestration.newSession')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{ungroupedSessions.length > 0 ? (
|
||||
<section>
|
||||
<h4 className="px-3 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wide text-content-muted">
|
||||
{t('tinyplaceOrchestration.otherSessions')}
|
||||
</h4>
|
||||
<div>
|
||||
{ungroupedSessions.map(chat => (
|
||||
<ChatListButton
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
selected={selectedId === chat.id}
|
||||
contactBadge={contactBadgeKey(chat, acceptedContacts, pendingContacts)}
|
||||
onSelect={() => {
|
||||
debug('[tinyplace-orchestration] open session id=%s', chat.id);
|
||||
onSelectChat(chat.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ vi.mock('../../lib/orchestration/orchestrationClient', async importOriginal => {
|
||||
status: vi.fn(),
|
||||
selfIdentity: vi.fn(),
|
||||
relayInfo: vi.fn(),
|
||||
attention: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -54,6 +55,7 @@ const markReadMock = vi.mocked(orchestrationClient.markRead);
|
||||
const statusMock = vi.mocked(orchestrationClient.status);
|
||||
const selfIdentityMock = vi.mocked(orchestrationClient.selfIdentity);
|
||||
const relayInfoMock = vi.mocked(orchestrationClient.relayInfo);
|
||||
const attentionMock = vi.mocked(orchestrationClient.attention);
|
||||
|
||||
const pairingListMock = vi.mocked(apiClient.orchestrationPairing.list);
|
||||
const pairingLinkMock = vi.mocked(apiClient.orchestrationPairing.linkSession);
|
||||
@@ -121,6 +123,10 @@ describe('TinyPlaceOrchestrationTab', () => {
|
||||
baseUrl: 'https://staging-api.tiny.place',
|
||||
network: 'staging',
|
||||
});
|
||||
attentionMock.mockResolvedValue({
|
||||
items: [],
|
||||
counts: { total: 0, approvals: 0, needsInput: 0, unread: 0 },
|
||||
});
|
||||
pairingListMock.mockResolvedValue({
|
||||
records: [],
|
||||
contacts: { contacts: [] },
|
||||
@@ -519,6 +525,54 @@ describe('TinyPlaceOrchestrationTab', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the attention zone and routes an open-session item to that chat', async () => {
|
||||
sessionsListMock.mockResolvedValue({
|
||||
sessions: [
|
||||
...PINNED_SESSIONS,
|
||||
{
|
||||
sessionId: 'app-session-1',
|
||||
agentId: '@worker-alpha',
|
||||
source: 'openhuman-app',
|
||||
label: 'OpenHuman app session',
|
||||
chatKind: 'session',
|
||||
lastMessageAt: '2026-07-01T12:02:00.000Z',
|
||||
unread: 2,
|
||||
active: true,
|
||||
pinned: false,
|
||||
status: 'idle' as const,
|
||||
},
|
||||
],
|
||||
});
|
||||
attentionMock.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 'unread:app-session-1',
|
||||
kind: 'unread',
|
||||
instanceId: 'app-session-1',
|
||||
title: 'Worker Alpha',
|
||||
count: 2,
|
||||
action: { type: 'open-session', sessionId: 'app-session-1' },
|
||||
},
|
||||
],
|
||||
counts: { total: 1, approvals: 0, needsInput: 0, unread: 1 },
|
||||
});
|
||||
|
||||
render(<TinyPlaceOrchestrationTab />);
|
||||
|
||||
// The zone surfaces the item; acting on it opens the target session.
|
||||
await screen.findByTestId('attention-item-unread:app-session-1');
|
||||
fireEvent.click(screen.getByTestId('attention-item-action'));
|
||||
|
||||
await waitFor(() => expect(markReadMock).toHaveBeenCalledWith('app-session-1'));
|
||||
});
|
||||
|
||||
it('shows the New instance launch shell as a disabled affordance', async () => {
|
||||
render(<TinyPlaceOrchestrationTab />);
|
||||
|
||||
const launch = await screen.findByTestId('tinyplace-new-instance');
|
||||
expect(launch).toBeDisabled();
|
||||
});
|
||||
|
||||
it('threads a composed message under the selected session', async () => {
|
||||
pairingListMock.mockResolvedValue(acceptedContactSnapshot());
|
||||
sessionsListMock.mockResolvedValue({
|
||||
|
||||
@@ -2,162 +2,33 @@ import debugFactory from 'debug';
|
||||
import { type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { apiClient } from '../../agentworld/AgentWorldShell';
|
||||
import {
|
||||
type ContactRequestsResponse,
|
||||
type ContactView,
|
||||
type PairingSnapshot,
|
||||
PaymentRequiredError,
|
||||
} from '../../lib/agentworld/invokeApiClient';
|
||||
import { type PairingSnapshot, PaymentRequiredError } from '../../lib/agentworld/invokeApiClient';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import {
|
||||
type AttentionAction,
|
||||
type AttentionQueue,
|
||||
orchestrationClient,
|
||||
type RelayInfo,
|
||||
type SelfIdentity,
|
||||
} from '../../lib/orchestration/orchestrationClient';
|
||||
import {
|
||||
type ChatMessage,
|
||||
type ChatWindow,
|
||||
MASTER_CHAT_KEY,
|
||||
useOrchestrationChats,
|
||||
} from '../../lib/orchestration/useOrchestrationChats';
|
||||
import { subconsciousTrigger } from '../../utils/tauriCommands/subconscious';
|
||||
import Button from '../ui/Button';
|
||||
import RelayBadge from './RelayBadge';
|
||||
import SelfIdentityCard from './SelfIdentityCard';
|
||||
import OrchestrationFocusPane from './OrchestrationFocusPane';
|
||||
import OrchestrationSidebar from './OrchestrationSidebar';
|
||||
import {
|
||||
acceptedContactIds,
|
||||
chatTime,
|
||||
contactAddress,
|
||||
extractHandle,
|
||||
pendingContactIds,
|
||||
} from './orchestrationTabHelpers';
|
||||
|
||||
const debug = debugFactory('brain:tinyplace-orchestration');
|
||||
|
||||
function formatTime(timestamp: string | null): string {
|
||||
if (!timestamp) return '';
|
||||
const parsed = Date.parse(timestamp);
|
||||
if (!Number.isFinite(parsed)) return '';
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(parsed));
|
||||
}
|
||||
|
||||
function truncate(text: string, length = 96): string {
|
||||
if (text.length <= length) return text;
|
||||
return `${text.slice(0, length - 1)}…`;
|
||||
}
|
||||
|
||||
function acceptedContactIds(contacts: ContactView[]): Set<string> {
|
||||
return new Set(
|
||||
contacts
|
||||
.filter(contact => contact.status === 'accepted')
|
||||
.map(contactAddress)
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
function pendingContactIds(requests: ContactRequestsResponse): Set<string> {
|
||||
return new Set(
|
||||
[...requests.incoming, ...requests.outgoing]
|
||||
.filter(contact => contact.status === 'pending')
|
||||
.map(contactAddress)
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
function contactBadgeKey(
|
||||
chat: ChatWindow,
|
||||
accepted: Set<string>,
|
||||
pending: Set<string>
|
||||
): string | null {
|
||||
if (chat.pinned || !chat.peerAgentId) return null;
|
||||
if (accepted.has(chat.peerAgentId)) return 'tinyplaceOrchestration.pairing.linked';
|
||||
if (pending.has(chat.peerAgentId)) return 'tinyplaceOrchestration.pairing.pending';
|
||||
return 'tinyplaceOrchestration.pairing.unlinked';
|
||||
}
|
||||
|
||||
function ChatListButton({
|
||||
chat,
|
||||
selected,
|
||||
onSelect,
|
||||
contactBadge,
|
||||
}: {
|
||||
chat: ChatWindow;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
contactBadge?: string | null;
|
||||
}) {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`tinyplace-chat-${chat.id}`}
|
||||
onClick={onSelect}
|
||||
className={`flex w-full items-start gap-3 border-b border-line-subtle px-3 py-3 text-left transition last:border-b-0 hover:bg-surface-hover ${
|
||||
selected ? 'bg-surface-muted' : ''
|
||||
}`}>
|
||||
<span className="mt-0.5 flex h-9 w-9 flex-none items-center justify-center rounded-lg border border-line bg-surface-strong text-xs font-semibold text-content-secondary">
|
||||
{chat.kind === 'subconscious' ? 'S' : chat.kind === 'master' ? 'M' : '#'}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-semibold text-content">{chat.title}</span>
|
||||
<span className="flex-none text-[10px] text-content-faint">
|
||||
{formatTime(chat.lastTimestamp)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[11px] text-content-muted">
|
||||
{chat.kind === 'subconscious'
|
||||
? t('tinyplaceOrchestration.subconsciousBadge')
|
||||
: chat.subtitle}
|
||||
</span>
|
||||
<span className="mt-1 flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-content-faint">{chat.preview}</span>
|
||||
{chat.unread > 0 ? (
|
||||
<span className="flex-none rounded-full bg-ocean-500 px-1.5 py-0.5 text-[10px] font-semibold text-content-inverted">
|
||||
{chat.unread}
|
||||
</span>
|
||||
) : null}
|
||||
{!chat.pinned ? (
|
||||
<span
|
||||
className={`flex-none rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||
chat.active
|
||||
? 'bg-sage-100 text-sage-700 dark:bg-sage-500/15 dark:text-sage-300'
|
||||
: 'bg-surface-strong text-content-faint'
|
||||
}`}>
|
||||
{chat.active
|
||||
? t('tinyplaceOrchestration.active')
|
||||
: t('tinyplaceOrchestration.inactive')}
|
||||
</span>
|
||||
) : null}
|
||||
{contactBadge ? (
|
||||
<span className="flex-none rounded-full bg-surface-strong px-1.5 py-0.5 text-[10px] font-medium text-content-faint">
|
||||
{t(contactBadge)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ message }: { message: ChatMessage }) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="mt-1.5 h-2 w-2 flex-none rounded-full bg-ocean-500" />
|
||||
<div className="min-w-0 rounded-lg border border-line bg-surface px-3 py-2 shadow-soft">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-xs font-semibold text-content-secondary">{message.from}</span>
|
||||
<span className="text-[10px] text-content-faint">{formatTime(message.timestamp)}</span>
|
||||
</div>
|
||||
<p
|
||||
className={`mt-1 whitespace-pre-wrap break-words text-sm ${
|
||||
message.encrypted ? 'text-content-muted' : 'text-content'
|
||||
}`}>
|
||||
{message.body}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pairing (unchanged data source: apiClient.orchestrationPairing.*) ─────────
|
||||
|
||||
type PairingState =
|
||||
@@ -166,33 +37,6 @@ type PairingState =
|
||||
| { status: 'payment_required' }
|
||||
| { status: 'ok'; snapshot: PairingSnapshot };
|
||||
|
||||
/** Best-effort `@handle` for a tiny.place agent id (cryptoId) from a directory
|
||||
* reverse lookup — the registered username if any, else null. The raw address
|
||||
* is always shown; the handle is additive. */
|
||||
function extractHandle(res: {
|
||||
agents?: Array<{ username?: string }>;
|
||||
identities?: unknown[];
|
||||
}): string | null {
|
||||
const fromAgent = res.agents?.find(a => a.username)?.username;
|
||||
const fromIdentity = (res.identities as Array<{ username?: string }> | undefined)?.find(
|
||||
identity => identity?.username
|
||||
)?.username;
|
||||
const username = fromAgent ?? fromIdentity;
|
||||
return username ? username.replace(/^@+/, '') : null;
|
||||
}
|
||||
|
||||
// The counterpart agent address for a contact view (request or accepted
|
||||
// contact). The relay's `/contacts` and `/contacts/requests` payloads do not
|
||||
// always populate the top-level `agentId`, so fall back to the underlying
|
||||
// contact record: when we are the addressee the counterpart is the
|
||||
// `requester`, otherwise it is the `addressee`.
|
||||
function contactAddress(view: ContactView): string {
|
||||
if (view.agentId) return view.agentId;
|
||||
const contact = view.contact;
|
||||
if (!contact) return '';
|
||||
return view.direction === 'outgoing' ? (contact.addressee ?? '') : (contact.requester ?? '');
|
||||
}
|
||||
|
||||
export default function TinyPlaceOrchestrationTab() {
|
||||
const { t } = useT();
|
||||
const {
|
||||
@@ -226,6 +70,10 @@ export default function TinyPlaceOrchestrationTab() {
|
||||
const [selfIdentity, setSelfIdentity] = useState<SelfIdentity | null>(null);
|
||||
const [identityLoading, setIdentityLoading] = useState(true);
|
||||
const [relayInfo, setRelayInfo] = useState<RelayInfo | null>(null);
|
||||
// The aggregated "needs you" queue (approvals + blocked runs + unread). Read
|
||||
// independently of chats so a failure leaves the zone empty, never the tab.
|
||||
const [attentionQueue, setAttentionQueue] = useState<AttentionQueue | null>(null);
|
||||
const [attentionLoading, setAttentionLoading] = useState(true);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const toggleContact = useCallback((address: string) => {
|
||||
@@ -304,6 +152,34 @@ export default function TinyPlaceOrchestrationTab() {
|
||||
setIdentityLoading(false);
|
||||
}, []);
|
||||
|
||||
const loadAttention = useCallback(async () => {
|
||||
debug('[tinyplace-orchestration] attention load entry');
|
||||
try {
|
||||
const queue = await orchestrationClient.attention();
|
||||
if (!mountedRef.current) return;
|
||||
debug('[tinyplace-orchestration] attention load ok total=%d', queue.counts.total);
|
||||
setAttentionQueue(queue);
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
debug('[tinyplace-orchestration] attention load error %s', message);
|
||||
} finally {
|
||||
if (mountedRef.current) setAttentionLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Route an attention item to its target. Only orchestration sessions have an
|
||||
// in-tab surface today; approvals/threads/runs live elsewhere (wired later).
|
||||
const handleAttentionAction = useCallback(
|
||||
(action: AttentionAction) => {
|
||||
debug('[tinyplace-orchestration] attention action type=%s', action.type);
|
||||
if (action.type === 'open-session') {
|
||||
selectChat(action.sessionId);
|
||||
}
|
||||
},
|
||||
[selectChat]
|
||||
);
|
||||
|
||||
const runPairingAction = useCallback(
|
||||
async (actionId: string, action: () => Promise<unknown>) => {
|
||||
debug('[tinyplace-orchestration] pairing action entry id=%s', actionId);
|
||||
@@ -345,7 +221,8 @@ export default function TinyPlaceOrchestrationTab() {
|
||||
void refresh();
|
||||
void loadPairing();
|
||||
void loadIdentity();
|
||||
}, [refresh, loadPairing, loadIdentity]);
|
||||
void loadAttention();
|
||||
}, [refresh, loadPairing, loadIdentity, loadAttention]);
|
||||
|
||||
const submitComposer = useCallback(
|
||||
(event: FormEvent<HTMLFormElement>) => {
|
||||
@@ -367,12 +244,13 @@ export default function TinyPlaceOrchestrationTab() {
|
||||
const handle = window.setTimeout(() => {
|
||||
void loadPairing();
|
||||
void loadIdentity();
|
||||
void loadAttention();
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(handle);
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, [loadPairing, loadIdentity]);
|
||||
}, [loadPairing, loadIdentity, loadAttention]);
|
||||
|
||||
const pinned = chats.filter(chat => chat.pinned);
|
||||
const sessions = chats
|
||||
@@ -463,409 +341,56 @@ export default function TinyPlaceOrchestrationTab() {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[620px] overflow-hidden rounded-xl border border-line bg-surface shadow-soft">
|
||||
<aside className="flex w-80 flex-none flex-col border-r border-line bg-surface-muted/40">
|
||||
<div className="border-b border-line px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h3 className="truncate text-sm font-semibold text-content">
|
||||
{t('tinyplaceOrchestration.title')}
|
||||
</h3>
|
||||
<RelayBadge relay={relayInfo} />
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-[11px] text-content-muted">
|
||||
{t('tinyplaceOrchestration.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={refreshAll}
|
||||
aria-label={t('tinyplaceOrchestration.refresh')}
|
||||
disabled={sessionsState.status === 'loading'}>
|
||||
{t('tinyplaceOrchestration.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
{steeringText ? (
|
||||
<div
|
||||
data-testid="tinyplace-steering-chip"
|
||||
className="mt-2 flex items-start gap-1.5 rounded-md bg-amber-50 px-2 py-1 text-[11px] text-amber-700 dark:bg-amber-500/10 dark:text-amber-300">
|
||||
<span className="flex-none font-semibold uppercase tracking-wide">
|
||||
{t('tinyplaceOrchestration.steering.label')}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{truncate(steeringText, 72)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<OrchestrationSidebar
|
||||
relayInfo={relayInfo}
|
||||
onRefreshAll={refreshAll}
|
||||
refreshDisabled={sessionsState.status === 'loading'}
|
||||
steeringText={steeringText}
|
||||
selfIdentity={selfIdentity}
|
||||
identityLoading={identityLoading}
|
||||
attentionQueue={attentionQueue}
|
||||
attentionLoading={attentionLoading}
|
||||
onAttentionAction={handleAttentionAction}
|
||||
linkAgentId={linkAgentId}
|
||||
onLinkAgentIdChange={setLinkAgentId}
|
||||
onSubmitLink={submitLink}
|
||||
pairingAction={pairingAction}
|
||||
contactStats={contactStats}
|
||||
incomingRequests={incomingRequests}
|
||||
outgoingCount={pairingSnapshot?.requests.outgoing.length ?? 0}
|
||||
pairingError={pairingError}
|
||||
agentHandles={agentHandles}
|
||||
runPairingAction={runPairingAction}
|
||||
pinned={pinned}
|
||||
selectedId={selectedId}
|
||||
onSelectChat={selectChat}
|
||||
acceptedContactList={acceptedContactList}
|
||||
expandedContacts={expandedContacts}
|
||||
onToggleContact={toggleContact}
|
||||
sessionsByContact={sessionsByContact}
|
||||
creatingSession={creatingSession}
|
||||
onCreateSession={handleCreateSession}
|
||||
acceptedContacts={acceptedContacts}
|
||||
pendingContacts={pendingContacts}
|
||||
ungroupedSessions={ungroupedSessions}
|
||||
/>
|
||||
|
||||
<SelfIdentityCard identity={selfIdentity} loading={identityLoading} />
|
||||
|
||||
<section className="border-b border-line px-4 py-3">
|
||||
<form className="space-y-2" onSubmit={submitLink}>
|
||||
<label
|
||||
htmlFor="tinyplace-session-agent-id"
|
||||
className="block text-[10px] font-semibold uppercase tracking-wide text-content-muted">
|
||||
{t('tinyplaceOrchestration.pairing.linkLabel')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="tinyplace-session-agent-id"
|
||||
value={linkAgentId}
|
||||
onChange={event => setLinkAgentId(event.target.value)}
|
||||
placeholder={t('tinyplaceOrchestration.pairing.linkPlaceholder')}
|
||||
className="min-w-0 flex-1 rounded-md border border-line bg-surface px-2 py-1.5 text-xs text-content outline-none transition focus:border-ocean-500 focus:ring-2 focus:ring-ocean-500/20"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={!linkAgentId.trim() || pairingAction !== null}>
|
||||
{t('tinyplaceOrchestration.pairing.linkAction')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 text-[10px] text-content-faint">
|
||||
<span className="rounded-full bg-surface-strong px-2 py-0.5">
|
||||
{t('tinyplaceOrchestration.pairing.linked')}: {contactStats?.contactCount ?? 0}
|
||||
</span>
|
||||
<span className="rounded-full bg-surface-strong px-2 py-0.5">
|
||||
{t('tinyplaceOrchestration.pairing.incoming')}: {incomingRequests.length}
|
||||
</span>
|
||||
<span className="rounded-full bg-surface-strong px-2 py-0.5">
|
||||
{t('tinyplaceOrchestration.pairing.outgoing')}:{' '}
|
||||
{pairingSnapshot?.requests.outgoing.length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{pairingError ? (
|
||||
<p className="mt-2 rounded-md bg-coral-50 px-2 py-1 text-xs text-coral-700 dark:bg-coral-500/10 dark:text-coral-300">
|
||||
{pairingError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{incomingRequests.length > 0 ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<h4 className="text-[10px] font-semibold uppercase tracking-wide text-content-muted">
|
||||
{t('tinyplaceOrchestration.pairing.requests')}
|
||||
</h4>
|
||||
{incomingRequests.map((request, index) => {
|
||||
const address = contactAddress(request);
|
||||
const handle = address ? agentHandles[address] : null;
|
||||
return (
|
||||
<div
|
||||
key={address || `request-${index}`}
|
||||
className="rounded-lg border border-line bg-surface px-2 py-2">
|
||||
{handle ? (
|
||||
<div className="truncate text-xs font-medium text-content">@{handle}</div>
|
||||
) : null}
|
||||
<div className="truncate font-mono text-[11px] text-content-muted">
|
||||
{address}
|
||||
</div>
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={pairingAction !== null || !address}
|
||||
onClick={() =>
|
||||
void runPairingAction(`accept:${address}`, () =>
|
||||
apiClient.orchestrationPairing.acceptRequest(address)
|
||||
)
|
||||
}>
|
||||
{t('tinyplaceOrchestration.pairing.accept')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={pairingAction !== null || !address}
|
||||
onClick={() =>
|
||||
void runPairingAction(`remove:${address}`, () =>
|
||||
apiClient.orchestrationPairing.declineRequest(address)
|
||||
)
|
||||
}>
|
||||
{t('tinyplaceOrchestration.pairing.decline')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={pairingAction !== null || !address}
|
||||
onClick={() =>
|
||||
void runPairingAction(`block:${address}`, () =>
|
||||
apiClient.orchestrationPairing.blockRequest(address)
|
||||
)
|
||||
}>
|
||||
{t('tinyplaceOrchestration.pairing.block')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<section>
|
||||
<h4 className="px-3 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wide text-content-muted">
|
||||
{t('tinyplaceOrchestration.pinned')}
|
||||
</h4>
|
||||
<div>
|
||||
{pinned.map(chat => (
|
||||
<ChatListButton
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
selected={selectedId === chat.id}
|
||||
onSelect={() => {
|
||||
debug('[tinyplace-orchestration] open pinned id=%s', chat.id);
|
||||
selectChat(chat.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 className="px-3 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wide text-content-muted">
|
||||
{t('tinyplaceOrchestration.contacts')}
|
||||
</h4>
|
||||
{acceptedContactList.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-content-faint">
|
||||
{t('tinyplaceOrchestration.noContacts')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 px-2 pb-2">
|
||||
{acceptedContactList.map((contact, index) => {
|
||||
const address = contactAddress(contact);
|
||||
const handle = address ? agentHandles[address] : null;
|
||||
const isOpen = !!expandedContacts[address];
|
||||
const contactSessions = address ? (sessionsByContact.get(address) ?? []) : [];
|
||||
return (
|
||||
<div
|
||||
key={address || `contact-${index}`}
|
||||
className="overflow-hidden rounded-lg border border-line bg-surface">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`tinyplace-contact-${address}`}
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => toggleContact(address)}
|
||||
className="flex w-full items-center gap-2 px-2 py-2 text-left transition hover:bg-surface-hover">
|
||||
<span className="flex-none text-[10px] text-content-muted">
|
||||
{isOpen ? '▾' : '▸'}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
{handle ? (
|
||||
<span className="block truncate text-xs font-medium text-content">
|
||||
@{handle}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="block truncate font-mono text-[11px] text-content-muted">
|
||||
{address}
|
||||
</span>
|
||||
</span>
|
||||
{contactSessions.length > 0 ? (
|
||||
<span className="flex-none rounded-full bg-surface-strong px-1.5 py-0.5 text-[10px] font-medium text-content-faint">
|
||||
{contactSessions.length}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div className="border-t border-line-subtle">
|
||||
{contactSessions.map(chat => (
|
||||
<ChatListButton
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
selected={selectedId === chat.id}
|
||||
contactBadge={contactBadgeKey(
|
||||
chat,
|
||||
acceptedContacts,
|
||||
pendingContacts
|
||||
)}
|
||||
onSelect={() => {
|
||||
debug('[tinyplace-orchestration] open session id=%s', chat.id);
|
||||
selectChat(chat.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`tinyplace-new-session-${address}`}
|
||||
disabled={!address || creatingSession === address}
|
||||
onClick={() => handleCreateSession(address)}
|
||||
className="flex w-full items-center gap-1 px-3 py-2 text-left text-[11px] font-medium text-ocean-500 transition hover:bg-surface-hover disabled:opacity-50">
|
||||
+ {t('tinyplaceOrchestration.newSession')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{ungroupedSessions.length > 0 ? (
|
||||
<section>
|
||||
<h4 className="px-3 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wide text-content-muted">
|
||||
{t('tinyplaceOrchestration.otherSessions')}
|
||||
</h4>
|
||||
<div>
|
||||
{ungroupedSessions.map(chat => (
|
||||
<ChatListButton
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
selected={selectedId === chat.id}
|
||||
contactBadge={contactBadgeKey(chat, acceptedContacts, pendingContacts)}
|
||||
onSelect={() => {
|
||||
debug('[tinyplace-orchestration] open session id=%s', chat.id);
|
||||
selectChat(chat.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col bg-surface">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-line px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-semibold text-content">{selected?.title}</h3>
|
||||
<p className="mt-0.5 truncate text-xs text-content-muted">{selected?.subtitle}</p>
|
||||
</div>
|
||||
{selected && !selected.pinned ? (
|
||||
<span
|
||||
className={`rounded-full px-2 py-1 text-xs font-medium ${
|
||||
selected.active
|
||||
? 'bg-sage-100 text-sage-700 dark:bg-sage-500/15 dark:text-sage-300'
|
||||
: 'bg-surface-strong text-content-muted'
|
||||
}`}>
|
||||
{selected.active
|
||||
? t('tinyplaceOrchestration.active')
|
||||
: t('tinyplaceOrchestration.inactive')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Steering status header — the tinyplace subconscious instance's output. */}
|
||||
{selected?.kind === 'subconscious' ? (
|
||||
<div
|
||||
data-testid="tinyplace-steering-header"
|
||||
className="flex items-center justify-between gap-3 border-b border-line bg-amber-50/40 px-5 py-3 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}
|
||||
<p className="mt-0.5 text-[11px] text-content-faint">
|
||||
{status?.steering
|
||||
? t('tinyplaceOrchestration.steeringHeader.expires').replace(
|
||||
'{n}',
|
||||
String(status.steering.expiresAfterCycles)
|
||||
)
|
||||
: ''}
|
||||
{status?.lastTickAt
|
||||
? `${status?.steering ? ' · ' : ''}${t(
|
||||
'tinyplaceOrchestration.steeringHeader.lastReview'
|
||||
)}: ${new Date(status.lastTickAt * 1000).toLocaleTimeString()}`
|
||||
: ''}
|
||||
</p>
|
||||
</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' ? (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-content-muted">
|
||||
{t('tinyplaceOrchestration.loading')}
|
||||
</div>
|
||||
) : sessionsState.status === 'payment_required' ? (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-amber-600 dark:text-amber-300">
|
||||
{t('tinyplaceOrchestration.paymentRequired')}
|
||||
</div>
|
||||
) : sessionsState.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.message}
|
||||
</p>
|
||||
<Button variant="secondary" size="sm" onClick={() => void refresh()}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : messagesState.status === 'loading' ? (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-content-muted">
|
||||
{t('tinyplaceOrchestration.loading')}
|
||||
</div>
|
||||
) : 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')}: {messagesState.message}
|
||||
</p>
|
||||
<Button variant="secondary" size="sm" onClick={() => void refresh()}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : selected?.messages.length ? (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-surface-muted/20 p-5">
|
||||
<div className="space-y-3" data-testid="tinyplace-chat-messages">
|
||||
{selected.messages.map(message => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center px-6 text-center text-sm text-content-faint">
|
||||
{t('tinyplaceOrchestration.noMessages')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canCompose && 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="tinyplace-master-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-ocean-500 focus:ring-2 focus:ring-ocean-500/20"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
data-testid="tinyplace-master-composer-send"
|
||||
disabled={!composerBody.trim() || sending}>
|
||||
{t('tinyplaceOrchestration.composer.send')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
</main>
|
||||
<OrchestrationFocusPane
|
||||
selected={selected}
|
||||
sessionsState={sessionsState}
|
||||
messagesState={messagesState}
|
||||
status={status}
|
||||
masterError={masterError}
|
||||
refresh={refresh}
|
||||
steeringText={steeringText}
|
||||
runningReview={runningReview}
|
||||
onRunSteeringReview={() => void runSteeringReview()}
|
||||
canCompose={canCompose}
|
||||
composerBody={composerBody}
|
||||
onComposerChange={setComposerBody}
|
||||
sending={sending}
|
||||
onSubmitComposer={submitComposer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function chatTime(chat: ChatWindow): number {
|
||||
if (!chat.lastTimestamp) return 0;
|
||||
const parsed = Date.parse(chat.lastTimestamp);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ContactView } from '../../lib/agentworld/invokeApiClient';
|
||||
import type { ChatWindow } from '../../lib/orchestration/useOrchestrationChats';
|
||||
import {
|
||||
acceptedContactIds,
|
||||
chatTime,
|
||||
contactAddress,
|
||||
contactBadgeKey,
|
||||
extractHandle,
|
||||
formatTime,
|
||||
pendingContactIds,
|
||||
truncate,
|
||||
} from './orchestrationTabHelpers';
|
||||
|
||||
const contact = (over: Partial<ContactView>): ContactView =>
|
||||
({ agentId: '', status: 'accepted', direction: 'incoming', ...over }) as ContactView;
|
||||
|
||||
const chat = (over: Partial<ChatWindow>): ChatWindow =>
|
||||
({ id: 'c', pinned: false, peerAgentId: undefined, ...over }) as ChatWindow;
|
||||
|
||||
describe('orchestrationTabHelpers', () => {
|
||||
it('formatTime returns empty for null/invalid and formats a real timestamp', () => {
|
||||
expect(formatTime(null)).toBe('');
|
||||
expect(formatTime('not-a-date')).toBe('');
|
||||
expect(formatTime('2026-07-01T12:00:00.000Z')).not.toBe('');
|
||||
});
|
||||
|
||||
it('truncate leaves short text and ellipsises long text within the cap', () => {
|
||||
expect(truncate('short', 96)).toBe('short');
|
||||
const out = truncate('x'.repeat(50), 10);
|
||||
expect(out).toHaveLength(10);
|
||||
expect(out.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('chatTime parses a timestamp and falls back to 0', () => {
|
||||
expect(chatTime(chat({ lastTimestamp: null }))).toBe(0);
|
||||
expect(chatTime(chat({ lastTimestamp: 'nope' }))).toBe(0);
|
||||
expect(chatTime(chat({ lastTimestamp: '2026-07-01T12:00:00.000Z' }))).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('contactAddress prefers agentId then the contact record by direction', () => {
|
||||
expect(contactAddress(contact({ agentId: '@a' }))).toBe('@a');
|
||||
expect(
|
||||
contactAddress(
|
||||
contact({ agentId: '', direction: 'incoming', contact: { requester: '@req' } as never })
|
||||
)
|
||||
).toBe('@req');
|
||||
expect(
|
||||
contactAddress(
|
||||
contact({ agentId: '', direction: 'outgoing', contact: { addressee: '@to' } as never })
|
||||
)
|
||||
).toBe('@to');
|
||||
expect(contactAddress(contact({ agentId: '' }))).toBe('');
|
||||
});
|
||||
|
||||
it('accepted/pending id sets derive from the contact records', () => {
|
||||
const accepted = acceptedContactIds([
|
||||
contact({ agentId: '@ok', status: 'accepted' }),
|
||||
contact({ agentId: '@no', status: 'pending' }),
|
||||
]);
|
||||
expect(accepted.has('@ok')).toBe(true);
|
||||
expect(accepted.has('@no')).toBe(false);
|
||||
|
||||
const pending = pendingContactIds({
|
||||
incoming: [contact({ agentId: '@p', status: 'pending' })],
|
||||
outgoing: [],
|
||||
} as never);
|
||||
expect(pending.has('@p')).toBe(true);
|
||||
});
|
||||
|
||||
it('contactBadgeKey maps a session chat to linked/pending/unlinked', () => {
|
||||
const accepted = new Set(['@a']);
|
||||
const pending = new Set(['@b']);
|
||||
expect(contactBadgeKey(chat({ pinned: true }), accepted, pending)).toBeNull();
|
||||
expect(contactBadgeKey(chat({ peerAgentId: '@a' }), accepted, pending)).toBe(
|
||||
'tinyplaceOrchestration.pairing.linked'
|
||||
);
|
||||
expect(contactBadgeKey(chat({ peerAgentId: '@b' }), accepted, pending)).toBe(
|
||||
'tinyplaceOrchestration.pairing.pending'
|
||||
);
|
||||
expect(contactBadgeKey(chat({ peerAgentId: '@c' }), accepted, pending)).toBe(
|
||||
'tinyplaceOrchestration.pairing.unlinked'
|
||||
);
|
||||
});
|
||||
|
||||
it('extractHandle finds a username from agents or identities and strips @', () => {
|
||||
expect(extractHandle({ agents: [{ username: '@nick' }] })).toBe('nick');
|
||||
expect(extractHandle({ identities: [{ username: 'openhuman' }] })).toBe('openhuman');
|
||||
expect(extractHandle({})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Pure helpers for the TinyPlace Orchestration tab — time/label formatting,
|
||||
* contact-address resolution, and derived badge keys. Extracted so the tab
|
||||
* container and its presentational siblings share one implementation.
|
||||
*/
|
||||
import type {
|
||||
ContactRequestsResponse,
|
||||
ContactView,
|
||||
} from '../../lib/agentworld/invokeApiClient';
|
||||
import type { ChatWindow } from '../../lib/orchestration/useOrchestrationChats';
|
||||
|
||||
export function formatTime(timestamp: string | null): string {
|
||||
if (!timestamp) return '';
|
||||
const parsed = Date.parse(timestamp);
|
||||
if (!Number.isFinite(parsed)) return '';
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(parsed));
|
||||
}
|
||||
|
||||
export function truncate(text: string, length = 96): string {
|
||||
if (text.length <= length) return text;
|
||||
return `${text.slice(0, length - 1)}…`;
|
||||
}
|
||||
|
||||
export function chatTime(chat: ChatWindow): number {
|
||||
if (!chat.lastTimestamp) return 0;
|
||||
const parsed = Date.parse(chat.lastTimestamp);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
// The counterpart agent address for a contact view (request or accepted
|
||||
// contact). The relay's `/contacts` and `/contacts/requests` payloads do not
|
||||
// always populate the top-level `agentId`, so fall back to the underlying
|
||||
// contact record: when we are the addressee the counterpart is the
|
||||
// `requester`, otherwise it is the `addressee`.
|
||||
export function contactAddress(view: ContactView): string {
|
||||
if (view.agentId) return view.agentId;
|
||||
const contact = view.contact;
|
||||
if (!contact) return '';
|
||||
return view.direction === 'outgoing' ? (contact.addressee ?? '') : (contact.requester ?? '');
|
||||
}
|
||||
|
||||
export function acceptedContactIds(contacts: ContactView[]): Set<string> {
|
||||
return new Set(
|
||||
contacts
|
||||
.filter(contact => contact.status === 'accepted')
|
||||
.map(contactAddress)
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
export function pendingContactIds(requests: ContactRequestsResponse): Set<string> {
|
||||
return new Set(
|
||||
[...requests.incoming, ...requests.outgoing]
|
||||
.filter(contact => contact.status === 'pending')
|
||||
.map(contactAddress)
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
export function contactBadgeKey(
|
||||
chat: ChatWindow,
|
||||
accepted: Set<string>,
|
||||
pending: Set<string>
|
||||
): string | null {
|
||||
if (chat.pinned || !chat.peerAgentId) return null;
|
||||
if (accepted.has(chat.peerAgentId)) return 'tinyplaceOrchestration.pairing.linked';
|
||||
if (pending.has(chat.peerAgentId)) return 'tinyplaceOrchestration.pairing.pending';
|
||||
return 'tinyplaceOrchestration.pairing.unlinked';
|
||||
}
|
||||
|
||||
/** Best-effort `@handle` for a tiny.place agent id (cryptoId) from a directory
|
||||
* reverse lookup — the registered username if any, else null. The raw address
|
||||
* is always shown; the handle is additive. */
|
||||
export function extractHandle(res: {
|
||||
agents?: Array<{ username?: string }>;
|
||||
identities?: unknown[];
|
||||
}): string | null {
|
||||
const fromAgent = res.agents?.find(a => a.username)?.username;
|
||||
const fromIdentity = (res.identities as Array<{ username?: string }> | undefined)?.find(
|
||||
identity => identity?.username
|
||||
)?.username;
|
||||
const username = fromAgent ?? fromIdentity;
|
||||
return username ? username.replace(/^@+/, '') : null;
|
||||
}
|
||||
@@ -249,6 +249,8 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.title': 'مرحّل TinyPlace',
|
||||
'tinyplaceOrchestration.subtitle': 'قنوات الوكلاء المثبتة ودردشات جلسات التطبيق',
|
||||
'tinyplaceOrchestration.refresh': 'تحديث',
|
||||
'tinyplaceOrchestration.newInstance': 'نسخة جديدة',
|
||||
'tinyplaceOrchestration.newInstanceSoon': 'قريبًا',
|
||||
'tinyplaceOrchestration.pinned': 'مثبتة',
|
||||
'tinyplaceOrchestration.sessions': 'الجلسات',
|
||||
'tinyplaceOrchestration.contacts': 'جهات الاتصال',
|
||||
|
||||
@@ -256,6 +256,8 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.title': 'TinyPlace রিলে',
|
||||
'tinyplaceOrchestration.subtitle': 'পিন করা এজেন্ট চ্যানেল এবং অ্যাপ সেশন চ্যাট',
|
||||
'tinyplaceOrchestration.refresh': 'রিফ্রেশ',
|
||||
'tinyplaceOrchestration.newInstance': 'নতুন ইনস্ট্যান্স',
|
||||
'tinyplaceOrchestration.newInstanceSoon': 'শীঘ্রই আসছে',
|
||||
'tinyplaceOrchestration.pinned': 'পিন করা',
|
||||
'tinyplaceOrchestration.sessions': 'সেশন',
|
||||
'tinyplaceOrchestration.contacts': 'পরিচিতি',
|
||||
|
||||
@@ -262,6 +262,8 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.title': 'TinyPlace-Relay',
|
||||
'tinyplaceOrchestration.subtitle': 'Angepinnte Agentenkanäle und App-Sitzungs-Chats',
|
||||
'tinyplaceOrchestration.refresh': 'Aktualisieren',
|
||||
'tinyplaceOrchestration.newInstance': 'Neue Instanz',
|
||||
'tinyplaceOrchestration.newInstanceSoon': 'Demnächst verfügbar',
|
||||
'tinyplaceOrchestration.pinned': 'Angepinnt',
|
||||
'tinyplaceOrchestration.sessions': 'Sitzungen',
|
||||
'tinyplaceOrchestration.contacts': 'Kontakte',
|
||||
|
||||
@@ -4184,6 +4184,8 @@ const en: TranslationMap = {
|
||||
'tinyplaceOrchestration.title': 'TinyPlace relay',
|
||||
'tinyplaceOrchestration.subtitle': 'Pinned agent channels and app-session chats',
|
||||
'tinyplaceOrchestration.refresh': 'Refresh',
|
||||
'tinyplaceOrchestration.newInstance': 'New instance',
|
||||
'tinyplaceOrchestration.newInstanceSoon': 'Coming soon',
|
||||
'tinyplaceOrchestration.pinned': 'Pinned',
|
||||
'tinyplaceOrchestration.sessions': 'Sessions',
|
||||
'tinyplaceOrchestration.contacts': 'Contacts',
|
||||
|
||||
@@ -259,6 +259,8 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.title': 'Relay de TinyPlace',
|
||||
'tinyplaceOrchestration.subtitle': 'Canales de agentes fijados y chats de sesiones de app',
|
||||
'tinyplaceOrchestration.refresh': 'Actualizar',
|
||||
'tinyplaceOrchestration.newInstance': 'Nueva instancia',
|
||||
'tinyplaceOrchestration.newInstanceSoon': 'Próximamente',
|
||||
'tinyplaceOrchestration.pinned': 'Fijados',
|
||||
'tinyplaceOrchestration.sessions': 'Sesiones',
|
||||
'tinyplaceOrchestration.contacts': 'Contactos',
|
||||
|
||||
@@ -259,6 +259,8 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.title': 'Relais TinyPlace',
|
||||
'tinyplaceOrchestration.subtitle': "Canaux d'agents épinglés et chats de sessions app",
|
||||
'tinyplaceOrchestration.refresh': 'Actualiser',
|
||||
'tinyplaceOrchestration.newInstance': 'Nouvelle instance',
|
||||
'tinyplaceOrchestration.newInstanceSoon': 'Bientôt disponible',
|
||||
'tinyplaceOrchestration.pinned': 'Épinglés',
|
||||
'tinyplaceOrchestration.sessions': 'Sessions',
|
||||
'tinyplaceOrchestration.contacts': 'Contacts',
|
||||
|
||||
@@ -256,6 +256,8 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.title': 'TinyPlace रिले',
|
||||
'tinyplaceOrchestration.subtitle': 'पिन किए गए एजेंट चैनल और ऐप-सत्र चैट',
|
||||
'tinyplaceOrchestration.refresh': 'रीफ़्रेश',
|
||||
'tinyplaceOrchestration.newInstance': 'नया इंस्टेंस',
|
||||
'tinyplaceOrchestration.newInstanceSoon': 'जल्द आ रहा है',
|
||||
'tinyplaceOrchestration.pinned': 'पिन किए गए',
|
||||
'tinyplaceOrchestration.sessions': 'सत्र',
|
||||
'tinyplaceOrchestration.contacts': 'संपर्क',
|
||||
|
||||
@@ -257,6 +257,8 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.title': 'Relay TinyPlace',
|
||||
'tinyplaceOrchestration.subtitle': 'Kanal agen tersemat dan chat sesi aplikasi',
|
||||
'tinyplaceOrchestration.refresh': 'Segarkan',
|
||||
'tinyplaceOrchestration.newInstance': 'Instansi baru',
|
||||
'tinyplaceOrchestration.newInstanceSoon': 'Segera hadir',
|
||||
'tinyplaceOrchestration.pinned': 'Tersemat',
|
||||
'tinyplaceOrchestration.sessions': 'Sesi',
|
||||
'tinyplaceOrchestration.contacts': 'Kontak',
|
||||
|
||||
@@ -259,6 +259,8 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.title': 'Relay TinyPlace',
|
||||
'tinyplaceOrchestration.subtitle': 'Canali agente fissati e chat delle sessioni app',
|
||||
'tinyplaceOrchestration.refresh': 'Aggiorna',
|
||||
'tinyplaceOrchestration.newInstance': 'Nuova istanza',
|
||||
'tinyplaceOrchestration.newInstanceSoon': 'Prossimamente',
|
||||
'tinyplaceOrchestration.pinned': 'Fissati',
|
||||
'tinyplaceOrchestration.sessions': 'Sessioni',
|
||||
'tinyplaceOrchestration.contacts': 'Contatti',
|
||||
|
||||
@@ -253,6 +253,8 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.title': 'TinyPlace 릴레이',
|
||||
'tinyplaceOrchestration.subtitle': '고정된 에이전트 채널과 앱 세션 채팅',
|
||||
'tinyplaceOrchestration.refresh': '새로 고침',
|
||||
'tinyplaceOrchestration.newInstance': '새 인스턴스',
|
||||
'tinyplaceOrchestration.newInstanceSoon': '곧 제공 예정',
|
||||
'tinyplaceOrchestration.pinned': '고정됨',
|
||||
'tinyplaceOrchestration.sessions': '세션',
|
||||
'tinyplaceOrchestration.contacts': '연락처',
|
||||
|
||||
@@ -261,6 +261,8 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.title': 'Przekaźnik TinyPlace',
|
||||
'tinyplaceOrchestration.subtitle': 'Przypięte kanały agentów i czaty sesji aplikacji',
|
||||
'tinyplaceOrchestration.refresh': 'Odśwież',
|
||||
'tinyplaceOrchestration.newInstance': 'Nowa instancja',
|
||||
'tinyplaceOrchestration.newInstanceSoon': 'Wkrótce',
|
||||
'tinyplaceOrchestration.pinned': 'Przypięte',
|
||||
'tinyplaceOrchestration.sessions': 'Sesje',
|
||||
'tinyplaceOrchestration.contacts': 'Kontakty',
|
||||
|
||||
@@ -258,6 +258,8 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.title': 'Relay TinyPlace',
|
||||
'tinyplaceOrchestration.subtitle': 'Canais de agentes fixados e chats de sessões do app',
|
||||
'tinyplaceOrchestration.refresh': 'Atualizar',
|
||||
'tinyplaceOrchestration.newInstance': 'Nova instância',
|
||||
'tinyplaceOrchestration.newInstanceSoon': 'Em breve',
|
||||
'tinyplaceOrchestration.pinned': 'Fixados',
|
||||
'tinyplaceOrchestration.sessions': 'Sessões',
|
||||
'tinyplaceOrchestration.contacts': 'Contactos',
|
||||
|
||||
@@ -261,6 +261,8 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.title': 'Ретранслятор TinyPlace',
|
||||
'tinyplaceOrchestration.subtitle': 'Закрепленные каналы агентов и чаты сессий приложения',
|
||||
'tinyplaceOrchestration.refresh': 'Обновить',
|
||||
'tinyplaceOrchestration.newInstance': 'Новый экземпляр',
|
||||
'tinyplaceOrchestration.newInstanceSoon': 'Скоро',
|
||||
'tinyplaceOrchestration.pinned': 'Закрепленные',
|
||||
'tinyplaceOrchestration.sessions': 'Сессии',
|
||||
'tinyplaceOrchestration.contacts': 'Контакты',
|
||||
|
||||
@@ -237,6 +237,8 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.title': 'TinyPlace 中继',
|
||||
'tinyplaceOrchestration.subtitle': '固定的代理频道和应用会话聊天',
|
||||
'tinyplaceOrchestration.refresh': '刷新',
|
||||
'tinyplaceOrchestration.newInstance': '新建实例',
|
||||
'tinyplaceOrchestration.newInstanceSoon': '即将推出',
|
||||
'tinyplaceOrchestration.pinned': '固定',
|
||||
'tinyplaceOrchestration.sessions': '会话',
|
||||
'tinyplaceOrchestration.contacts': '联系人',
|
||||
|
||||
Reference in New Issue
Block a user