diff --git a/app/src/components/intelligence/OrchestrationChatPrimitives.test.tsx b/app/src/components/intelligence/OrchestrationChatPrimitives.test.tsx new file mode 100644 index 000000000..81b891d39 --- /dev/null +++ b/app/src/components/intelligence/OrchestrationChatPrimitives.test.tsx @@ -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 => + ({ + 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( {}} />); + expect(screen.getByText('4')).toBeInTheDocument(); + expect(screen.getByText('tinyplaceOrchestration.active')).toBeInTheDocument(); + }); + + it('renders the inactive badge and a contact badge when provided', () => { + render( + {}} + 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(); + expect(screen.getByText('tinyplaceOrchestration.subconsciousBadge')).toBeInTheDocument(); + fireEvent.click(screen.getByTestId('tinyplace-chat-sess-1')); + expect(onSelect).toHaveBeenCalled(); + }); +}); + +describe('MessageBubble', () => { + const message = (over: Partial): 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(); + expect(screen.getByText('@peer')).toBeInTheDocument(); + expect(screen.getByText('hello there')).toBeInTheDocument(); + }); + + it('mutes an encrypted (undecryptable) message body', () => { + render(); + expect(screen.getByText('••••')).toHaveClass('text-content-muted'); + }); +}); diff --git a/app/src/components/intelligence/OrchestrationChatPrimitives.tsx b/app/src/components/intelligence/OrchestrationChatPrimitives.tsx new file mode 100644 index 000000000..e7790231f --- /dev/null +++ b/app/src/components/intelligence/OrchestrationChatPrimitives.tsx @@ -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 ( + + ); +} + +export function MessageBubble({ message }: { message: ChatMessage }): ReactElement { + return ( +
+
+
+
+ {message.from} + {formatTime(message.timestamp)} +
+

+ {message.body} +

+
+
+ ); +} diff --git a/app/src/components/intelligence/OrchestrationFocusPane.test.tsx b/app/src/components/intelligence/OrchestrationFocusPane.test.tsx new file mode 100644 index 000000000..daff2264f --- /dev/null +++ b/app/src/components/intelligence/OrchestrationFocusPane.test.tsx @@ -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 => + ({ + 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; +let onRunSteeringReview: ReturnType; + +const props = (over: Partial): 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(); + expect(screen.getByText('tinyplaceOrchestration.paymentRequired')).toBeInTheDocument(); + }); + + it('renders a sessions load error and retries', () => { + render( + + ); + 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( + + ); + expect(screen.getByText(/msg boom/)).toBeInTheDocument(); + }); + + it('renders the steering header with expiry + last review and runs a review', () => { + render( + + ); + 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( + + ); + expect(screen.getByText('tinyplaceOrchestration.steeringHeader.running')).toBeInTheDocument(); + }); + + it('surfaces a composer send error when composing', () => { + render( + + ); + expect(screen.getByTestId('tinyplace-master-composer-input')).toBeInTheDocument(); + expect(screen.getByText(/send failed/)).toBeInTheDocument(); + }); + + it('renders message bubbles for the selected chat', () => { + render( + + ); + expect( + within(screen.getByTestId('tinyplace-chat-messages')).getByText('hi there') + ).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/intelligence/OrchestrationFocusPane.tsx b/app/src/components/intelligence/OrchestrationFocusPane.tsx new file mode 100644 index 000000000..4e30a1d0a --- /dev/null +++ b/app/src/components/intelligence/OrchestrationFocusPane.tsx @@ -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; + +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) => 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 ( +
+
+
+

{selected?.title}

+

{selected?.subtitle}

+
+ {selected && !selected.pinned ? ( + + {selected.active + ? t('tinyplaceOrchestration.active') + : t('tinyplaceOrchestration.inactive')} + + ) : null} +
+ + {/* Steering status header — the tinyplace subconscious instance's output. */} + {selected?.kind === 'subconscious' ? ( +
+
+

+ {steeringText + ? t('tinyplaceOrchestration.steeringHeader.current') + : t('tinyplaceOrchestration.steeringHeader.none')} +

+ {steeringText ? ( +

{steeringText}

+ ) : null} +

+ {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()}` + : ''} +

+
+ +
+ ) : null} + + {sessionsState.status === 'loading' ? ( +
+ {t('tinyplaceOrchestration.loading')} +
+ ) : sessionsState.status === 'payment_required' ? ( +
+ {t('tinyplaceOrchestration.paymentRequired')} +
+ ) : sessionsState.status === 'error' ? ( +
+

+ {t('tinyplaceOrchestration.failedToLoad')}: {sessionsState.message} +

+ +
+ ) : messagesState.status === 'loading' ? ( +
+ {t('tinyplaceOrchestration.loading')} +
+ ) : messagesState.status === 'error' ? ( +
+

+ {t('tinyplaceOrchestration.failedToLoad')}: {messagesState.message} +

+ +
+ ) : selected?.messages.length ? ( +
+
+ {selected.messages.map(message => ( + + ))} +
+
+ ) : ( +
+ {t('tinyplaceOrchestration.noMessages')} +
+ )} + + {canCompose && sessionsState.status === 'ok' ? ( +
+ {masterError ? ( +

+ {t('tinyplaceOrchestration.composer.sendFailed')}: {masterError} +

+ ) : null} +
+ 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" + /> + +
+
+ ) : null} +
+ ); +} diff --git a/app/src/components/intelligence/OrchestrationSidebar.test.tsx b/app/src/components/intelligence/OrchestrationSidebar.test.tsx new file mode 100644 index 000000000..364238412 --- /dev/null +++ b/app/src/components/intelligence/OrchestrationSidebar.test.tsx @@ -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; +let onToggleContact: ReturnType; + +const props = (over: Partial): 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) => thunk()), + pinned: [], + selectedId: null, + onSelectChat: vi.fn(), + acceptedContactList: [], + expandedContacts: {}, + onToggleContact, + sessionsByContact: new Map(), + creatingSession: null, + onCreateSession, + acceptedContacts: new Set(), + pendingContacts: new Set(), + 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( + + ); + + // 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( + + ); + + 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); + }); +}); diff --git a/app/src/components/intelligence/OrchestrationSidebar.tsx b/app/src/components/intelligence/OrchestrationSidebar.tsx new file mode 100644 index 000000000..7dd34823f --- /dev/null +++ b/app/src/components/intelligence/OrchestrationSidebar.tsx @@ -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) => void; + pairingAction: string | null; + contactStats: PairingSnapshot['stats'] | null; + incomingRequests: ContactView[]; + outgoingCount: number; + pairingError: string | null; + agentHandles: Record; + runPairingAction: (actionId: string, action: () => Promise) => Promise; + pinned: ChatWindow[]; + selectedId: string | null; + onSelectChat: (id: string) => void; + acceptedContactList: ContactView[]; + expandedContacts: Record; + onToggleContact: (address: string) => void; + sessionsByContact: Map; + creatingSession: string | null; + onCreateSession: (address: string) => void; + acceptedContacts: Set; + pendingContacts: Set; + 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 ( + + ); +} diff --git a/app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx b/app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx index ac8950ab4..4f2edea1e 100644 --- a/app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx +++ b/app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx @@ -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(); + + // 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(); + + 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({ diff --git a/app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx b/app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx index 14e9b0583..f394a280e 100644 --- a/app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx +++ b/app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx @@ -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 { - return new Set( - contacts - .filter(contact => contact.status === 'accepted') - .map(contactAddress) - .filter(Boolean) - ); -} - -function pendingContactIds(requests: ContactRequestsResponse): Set { - return new Set( - [...requests.incoming, ...requests.outgoing] - .filter(contact => contact.status === 'pending') - .map(contactAddress) - .filter(Boolean) - ); -} - -function contactBadgeKey( - chat: ChatWindow, - accepted: Set, - pending: Set -): 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 ( - - ); -} - -function MessageBubble({ message }: { message: ChatMessage }) { - return ( -
-
-
-
- {message.from} - {formatTime(message.timestamp)} -
-

- {message.body} -

-
-
- ); -} - // ── 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(null); const [identityLoading, setIdentityLoading] = useState(true); const [relayInfo, setRelayInfo] = useState(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(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) => { 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) => { @@ -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 (
- - -
-
-
-

{selected?.title}

-

{selected?.subtitle}

-
- {selected && !selected.pinned ? ( - - {selected.active - ? t('tinyplaceOrchestration.active') - : t('tinyplaceOrchestration.inactive')} - - ) : null} -
- - {/* Steering status header — the tinyplace subconscious instance's output. */} - {selected?.kind === 'subconscious' ? ( -
-
-

- {steeringText - ? t('tinyplaceOrchestration.steeringHeader.current') - : t('tinyplaceOrchestration.steeringHeader.none')} -

- {steeringText ? ( -

{steeringText}

- ) : null} -

- {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()}` - : ''} -

-
- -
- ) : null} - - {sessionsState.status === 'loading' ? ( -
- {t('tinyplaceOrchestration.loading')} -
- ) : sessionsState.status === 'payment_required' ? ( -
- {t('tinyplaceOrchestration.paymentRequired')} -
- ) : sessionsState.status === 'error' ? ( -
-

- {t('tinyplaceOrchestration.failedToLoad')}: {sessionsState.message} -

- -
- ) : messagesState.status === 'loading' ? ( -
- {t('tinyplaceOrchestration.loading')} -
- ) : messagesState.status === 'error' ? ( -
-

- {t('tinyplaceOrchestration.failedToLoad')}: {messagesState.message} -

- -
- ) : selected?.messages.length ? ( -
-
- {selected.messages.map(message => ( - - ))} -
-
- ) : ( -
- {t('tinyplaceOrchestration.noMessages')} -
- )} - - {canCompose && sessionsState.status === 'ok' ? ( -
- {masterError ? ( -

- {t('tinyplaceOrchestration.composer.sendFailed')}: {masterError} -

- ) : null} -
- 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" - /> - -
-
- ) : null} -
+ void runSteeringReview()} + canCompose={canCompose} + composerBody={composerBody} + onComposerChange={setComposerBody} + sending={sending} + onSubmitComposer={submitComposer} + />
); } - -function chatTime(chat: ChatWindow): number { - if (!chat.lastTimestamp) return 0; - const parsed = Date.parse(chat.lastTimestamp); - return Number.isFinite(parsed) ? parsed : 0; -} diff --git a/app/src/components/intelligence/orchestrationTabHelpers.test.ts b/app/src/components/intelligence/orchestrationTabHelpers.test.ts new file mode 100644 index 000000000..9d97abec3 --- /dev/null +++ b/app/src/components/intelligence/orchestrationTabHelpers.test.ts @@ -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 => + ({ agentId: '', status: 'accepted', direction: 'incoming', ...over }) as ContactView; + +const chat = (over: Partial): 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(); + }); +}); diff --git a/app/src/components/intelligence/orchestrationTabHelpers.ts b/app/src/components/intelligence/orchestrationTabHelpers.ts new file mode 100644 index 000000000..6c092de96 --- /dev/null +++ b/app/src/components/intelligence/orchestrationTabHelpers.ts @@ -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 { + return new Set( + contacts + .filter(contact => contact.status === 'accepted') + .map(contactAddress) + .filter(Boolean) + ); +} + +export function pendingContactIds(requests: ContactRequestsResponse): Set { + return new Set( + [...requests.incoming, ...requests.outgoing] + .filter(contact => contact.status === 'pending') + .map(contactAddress) + .filter(Boolean) + ); +} + +export function contactBadgeKey( + chat: ChatWindow, + accepted: Set, + pending: Set +): 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; +} diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 09cc2f755..fb3fe3616 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -249,6 +249,8 @@ const messages: TranslationMap = { 'tinyplaceOrchestration.title': 'مرحّل TinyPlace', 'tinyplaceOrchestration.subtitle': 'قنوات الوكلاء المثبتة ودردشات جلسات التطبيق', 'tinyplaceOrchestration.refresh': 'تحديث', + 'tinyplaceOrchestration.newInstance': 'نسخة جديدة', + 'tinyplaceOrchestration.newInstanceSoon': 'قريبًا', 'tinyplaceOrchestration.pinned': 'مثبتة', 'tinyplaceOrchestration.sessions': 'الجلسات', 'tinyplaceOrchestration.contacts': 'جهات الاتصال', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 24a5bee53..ac6a63e28 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -256,6 +256,8 @@ const messages: TranslationMap = { 'tinyplaceOrchestration.title': 'TinyPlace রিলে', 'tinyplaceOrchestration.subtitle': 'পিন করা এজেন্ট চ্যানেল এবং অ্যাপ সেশন চ্যাট', 'tinyplaceOrchestration.refresh': 'রিফ্রেশ', + 'tinyplaceOrchestration.newInstance': 'নতুন ইনস্ট্যান্স', + 'tinyplaceOrchestration.newInstanceSoon': 'শীঘ্রই আসছে', 'tinyplaceOrchestration.pinned': 'পিন করা', 'tinyplaceOrchestration.sessions': 'সেশন', 'tinyplaceOrchestration.contacts': 'পরিচিতি', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 61b3205e7..0210c6c34 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 7327cf162..67f45bf0b 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 661178470..0211d3f87 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index fea60a5fe..0a68fa89d 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 571869135..7f3a2f97d 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -256,6 +256,8 @@ const messages: TranslationMap = { 'tinyplaceOrchestration.title': 'TinyPlace रिले', 'tinyplaceOrchestration.subtitle': 'पिन किए गए एजेंट चैनल और ऐप-सत्र चैट', 'tinyplaceOrchestration.refresh': 'रीफ़्रेश', + 'tinyplaceOrchestration.newInstance': 'नया इंस्टेंस', + 'tinyplaceOrchestration.newInstanceSoon': 'जल्द आ रहा है', 'tinyplaceOrchestration.pinned': 'पिन किए गए', 'tinyplaceOrchestration.sessions': 'सत्र', 'tinyplaceOrchestration.contacts': 'संपर्क', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 337b82c1b..28e7e06d7 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 380548f40..e48e92b6b 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -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', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 60f700ee6..72aa35c74 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -253,6 +253,8 @@ const messages: TranslationMap = { 'tinyplaceOrchestration.title': 'TinyPlace 릴레이', 'tinyplaceOrchestration.subtitle': '고정된 에이전트 채널과 앱 세션 채팅', 'tinyplaceOrchestration.refresh': '새로 고침', + 'tinyplaceOrchestration.newInstance': '새 인스턴스', + 'tinyplaceOrchestration.newInstanceSoon': '곧 제공 예정', 'tinyplaceOrchestration.pinned': '고정됨', 'tinyplaceOrchestration.sessions': '세션', 'tinyplaceOrchestration.contacts': '연락처', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index e0aa7a201..f2a7b8ded 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 60d78425b..dab7f0cb5 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 5cb958434..f39d6d69d 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -261,6 +261,8 @@ const messages: TranslationMap = { 'tinyplaceOrchestration.title': 'Ретранслятор TinyPlace', 'tinyplaceOrchestration.subtitle': 'Закрепленные каналы агентов и чаты сессий приложения', 'tinyplaceOrchestration.refresh': 'Обновить', + 'tinyplaceOrchestration.newInstance': 'Новый экземпляр', + 'tinyplaceOrchestration.newInstanceSoon': 'Скоро', 'tinyplaceOrchestration.pinned': 'Закрепленные', 'tinyplaceOrchestration.sessions': 'Сессии', 'tinyplaceOrchestration.contacts': 'Контакты', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 6b374f5d3..2c7943948 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -237,6 +237,8 @@ const messages: TranslationMap = { 'tinyplaceOrchestration.title': 'TinyPlace 中继', 'tinyplaceOrchestration.subtitle': '固定的代理频道和应用会话聊天', 'tinyplaceOrchestration.refresh': '刷新', + 'tinyplaceOrchestration.newInstance': '新建实例', + 'tinyplaceOrchestration.newInstanceSoon': '即将推出', 'tinyplaceOrchestration.pinned': '固定', 'tinyplaceOrchestration.sessions': '会话', 'tinyplaceOrchestration.contacts': '联系人',