[4/5] feat(intelligence): TinyPlace orchestration hub — attention zone + layout split (#4609)

This commit is contained in:
oxoxDev
2026-07-06 15:08:43 -07:00
committed by GitHub
parent d84519fc37
commit b1b60b6075
24 changed files with 1356 additions and 572 deletions
@@ -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;
}
+2
View File
@@ -249,6 +249,8 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.title': 'مرحّل TinyPlace',
'tinyplaceOrchestration.subtitle': 'قنوات الوكلاء المثبتة ودردشات جلسات التطبيق',
'tinyplaceOrchestration.refresh': 'تحديث',
'tinyplaceOrchestration.newInstance': 'نسخة جديدة',
'tinyplaceOrchestration.newInstanceSoon': 'قريبًا',
'tinyplaceOrchestration.pinned': 'مثبتة',
'tinyplaceOrchestration.sessions': 'الجلسات',
'tinyplaceOrchestration.contacts': 'جهات الاتصال',
+2
View File
@@ -256,6 +256,8 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.title': 'TinyPlace রিলে',
'tinyplaceOrchestration.subtitle': 'পিন করা এজেন্ট চ্যানেল এবং অ্যাপ সেশন চ্যাট',
'tinyplaceOrchestration.refresh': 'রিফ্রেশ',
'tinyplaceOrchestration.newInstance': 'নতুন ইনস্ট্যান্স',
'tinyplaceOrchestration.newInstanceSoon': 'শীঘ্রই আসছে',
'tinyplaceOrchestration.pinned': 'পিন করা',
'tinyplaceOrchestration.sessions': 'সেশন',
'tinyplaceOrchestration.contacts': 'পরিচিতি',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -256,6 +256,8 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.title': 'TinyPlace रिले',
'tinyplaceOrchestration.subtitle': 'पिन किए गए एजेंट चैनल और ऐप-सत्र चैट',
'tinyplaceOrchestration.refresh': 'रीफ़्रेश',
'tinyplaceOrchestration.newInstance': 'नया इंस्टेंस',
'tinyplaceOrchestration.newInstanceSoon': 'जल्द आ रहा है',
'tinyplaceOrchestration.pinned': 'पिन किए गए',
'tinyplaceOrchestration.sessions': 'सत्र',
'tinyplaceOrchestration.contacts': 'संपर्क',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -253,6 +253,8 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.title': 'TinyPlace 릴레이',
'tinyplaceOrchestration.subtitle': '고정된 에이전트 채널과 앱 세션 채팅',
'tinyplaceOrchestration.refresh': '새로 고침',
'tinyplaceOrchestration.newInstance': '새 인스턴스',
'tinyplaceOrchestration.newInstanceSoon': '곧 제공 예정',
'tinyplaceOrchestration.pinned': '고정됨',
'tinyplaceOrchestration.sessions': '세션',
'tinyplaceOrchestration.contacts': '연락처',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -261,6 +261,8 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.title': 'Ретранслятор TinyPlace',
'tinyplaceOrchestration.subtitle': 'Закрепленные каналы агентов и чаты сессий приложения',
'tinyplaceOrchestration.refresh': 'Обновить',
'tinyplaceOrchestration.newInstance': 'Новый экземпляр',
'tinyplaceOrchestration.newInstanceSoon': 'Скоро',
'tinyplaceOrchestration.pinned': 'Закрепленные',
'tinyplaceOrchestration.sessions': 'Сессии',
'tinyplaceOrchestration.contacts': 'Контакты',
+2
View File
@@ -237,6 +237,8 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.title': 'TinyPlace 中继',
'tinyplaceOrchestration.subtitle': '固定的代理频道和应用会话聊天',
'tinyplaceOrchestration.refresh': '刷新',
'tinyplaceOrchestration.newInstance': '新建实例',
'tinyplaceOrchestration.newInstanceSoon': '即将推出',
'tinyplaceOrchestration.pinned': '固定',
'tinyplaceOrchestration.sessions': '会话',
'tinyplaceOrchestration.contacts': '联系人',