diff --git a/app/src/components/intelligence/RelayBadge.test.tsx b/app/src/components/intelligence/RelayBadge.test.tsx new file mode 100644 index 000000000..87d959408 --- /dev/null +++ b/app/src/components/intelligence/RelayBadge.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import RelayBadge from './RelayBadge'; + +vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); + +describe('RelayBadge', () => { + it('renders nothing when relay info is absent', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('shows the staging label + base url and tags the network', () => { + render( + + ); + const badge = screen.getByTestId('tinyplace-relay-badge'); + expect(badge).toHaveAttribute('data-network', 'staging'); + expect(badge).toHaveAttribute('title', 'https://staging-api.tiny.place'); + expect(badge).toHaveTextContent('tinyplaceOrchestration.relay.staging'); + }); + + it('shows the production label for the prod relay', () => { + render(); + const badge = screen.getByTestId('tinyplace-relay-badge'); + expect(badge).toHaveAttribute('data-network', 'prod'); + expect(badge).toHaveTextContent('tinyplaceOrchestration.relay.prod'); + }); +}); diff --git a/app/src/components/intelligence/RelayBadge.tsx b/app/src/components/intelligence/RelayBadge.tsx new file mode 100644 index 000000000..2ee82ba89 --- /dev/null +++ b/app/src/components/intelligence/RelayBadge.tsx @@ -0,0 +1,44 @@ +/** + * RelayBadge — a small always-visible pill naming the tiny.place relay the core + * is talking to (STAGING / PRODUCTION). + * + * Why it exists: during live debugging, claudebot sat on `staging-api.tiny.place` + * while the core silently defaulted to prod `api.tiny.place`, producing a mystery + * 404. Nothing on screen said which relay either side used. Surfacing the target + * relay as a first-class chip makes a mismatch obvious instead of opaque. + * + * Presentational only — the parent fetches {@link RelayInfo} via + * `orchestrationClient.relayInfo()` and passes it down. + */ +import { useT } from '../../lib/i18n/I18nContext'; +import type { RelayInfo } from '../../lib/orchestration/orchestrationClient'; + +export interface RelayBadgeProps { + relay: RelayInfo | null; +} + +export default function RelayBadge({ relay }: RelayBadgeProps): React.ReactElement | null { + const { t } = useT(); + if (!relay) return null; + + const isStaging = relay.network === 'staging'; + const label = isStaging + ? t('tinyplaceOrchestration.relay.staging') + : t('tinyplaceOrchestration.relay.prod'); + + // Staging is the everyday dev target (amber, "you're on the test relay"); + // production is the live one (sage). Colour, not just text, so it's scannable. + const tone = isStaging + ? 'border-amber-400 text-amber-700 dark:border-amber-500/60 dark:text-amber-300' + : 'border-sage-400 text-sage-700 dark:border-sage-500/60 dark:text-sage-300'; + + return ( + + {label} + + ); +} diff --git a/app/src/components/intelligence/SelfIdentityCard.test.tsx b/app/src/components/intelligence/SelfIdentityCard.test.tsx new file mode 100644 index 000000000..ed1739332 --- /dev/null +++ b/app/src/components/intelligence/SelfIdentityCard.test.tsx @@ -0,0 +1,73 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { SelfIdentity } from '../../lib/orchestration/orchestrationClient'; +import SelfIdentityCard from './SelfIdentityCard'; + +vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); + +const discoverable: SelfIdentity = { + agentId: '6wNaBJkatir4B86cw5ykHZWQ3xoNaKygX5vAU9MQbHSh', + handles: [{ username: 'openhuman', primary: true }], + primaryHandle: 'openhuman', + cardPublished: true, + keyPublished: true, + discoverable: true, +}; + +describe('SelfIdentityCard', () => { + beforeEach(() => { + Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } }); + }); + + it('shows a loading state before the identity resolves', () => { + render(); + expect(screen.getByTestId('tinyplace-self-identity')).toHaveTextContent( + 'tinyplaceOrchestration.identity.loading' + ); + }); + + it('renders nothing once loaded with no identity', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders the primary handle, shortened address and discoverable status', () => { + render(); + expect(screen.getByText('@openhuman')).toBeInTheDocument(); + // Address is shortened but the full value is preserved in the title. + expect(screen.getByText('6wNaBJ…QbHSh')).toHaveAttribute('title', discoverable.agentId); + const status = screen.getByTestId('tinyplace-self-identity-status'); + expect(status).toHaveAttribute('data-discoverable', 'true'); + }); + + it('flags an un-messageable identity and shows the register hint', () => { + const undiscoverable: SelfIdentity = { + agentId: 'addrWithNoCardPublishedYet', + handles: [], + cardPublished: false, + keyPublished: false, + discoverable: false, + }; + render(); + expect(screen.getByText('tinyplaceOrchestration.identity.noHandle')).toBeInTheDocument(); + expect(screen.getByTestId('tinyplace-self-identity-status')).toHaveAttribute( + 'data-discoverable', + 'false' + ); + expect( + screen.getByText('tinyplaceOrchestration.identity.undiscoverableHint') + ).toBeInTheDocument(); + }); + + it('copies the address to the clipboard on click', async () => { + render(); + fireEvent.click(screen.getByTestId('tinyplace-self-identity-copy')); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(discoverable.agentId); + await waitFor(() => + expect(screen.getByTestId('tinyplace-self-identity-copy')).toHaveTextContent( + 'tinyplaceOrchestration.identity.copied' + ) + ); + }); +}); diff --git a/app/src/components/intelligence/SelfIdentityCard.tsx b/app/src/components/intelligence/SelfIdentityCard.tsx new file mode 100644 index 000000000..59ad8b69a --- /dev/null +++ b/app/src/components/intelligence/SelfIdentityCard.tsx @@ -0,0 +1,134 @@ +/** + * SelfIdentityCard — pinned at the top of the roster, shows OpenHuman's *own* + * tiny.place identity so the user can hand it to a peer to be messaged inbound. + * + * Why it exists: the tab previously showed peers/contacts but never OpenHuman's + * own agent id, so there was no way to say "here's my address, DM me". Worse, a + * fresh identity can accept contacts yet stay un-messageable until it registers a + * @handle (which is what publishes its directory card + Signal key) — every send + * to it 404s on `/directory/agents/`. This card surfaces both the address + * and the discoverability gap instead of leaving it a mystery. + * + * Presentational only — the parent fetches {@link SelfIdentity} via + * `orchestrationClient.selfIdentity()` and owns refresh. + */ +import { useCallback, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import type { SelfIdentity } from '../../lib/orchestration/orchestrationClient'; + +export interface SelfIdentityCardProps { + identity: SelfIdentity | null; + loading: boolean; +} + +function shortAddress(address: string): string { + if (address.length <= 14) return address; + return `${address.slice(0, 6)}…${address.slice(-5)}`; +} + +export default function SelfIdentityCard({ + identity, + loading, +}: SelfIdentityCardProps): React.ReactElement { + const { t } = useT(); + const [copied, setCopied] = useState(false); + + const address = identity?.agentId ?? ''; + const onCopy = useCallback(() => { + if (!address) return; + void navigator.clipboard?.writeText(address).then( + () => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + }, + () => setCopied(false) + ); + }, [address]); + + if (loading && !identity) { + return ( +
+ {t('tinyplaceOrchestration.identity.loading')} +
+ ); + } + + if (!identity) return <>; + + const primaryHandle = identity.primaryHandle ?? identity.handles[0]?.username; + + return ( +
+
+ + OH + +
+
+ {primaryHandle ? `@${primaryHandle}` : t('tinyplaceOrchestration.identity.noHandle')} +
+
+ {shortAddress(address)} +
+
+ +
+ +
+ {identity.discoverable ? ( + + {t('tinyplaceOrchestration.identity.discoverable')} + + ) : ( + + {t('tinyplaceOrchestration.identity.undiscoverable')} + + )} + + {t('tinyplaceOrchestration.identity.card')}:{' '} + {identity.cardPublished + ? t('tinyplaceOrchestration.identity.published') + : t('tinyplaceOrchestration.identity.notPublished')} + + + {t('tinyplaceOrchestration.identity.key')}:{' '} + {identity.keyPublished + ? t('tinyplaceOrchestration.identity.published') + : t('tinyplaceOrchestration.identity.notPublished')} + +
+ + {!identity.discoverable ? ( +

+ {t('tinyplaceOrchestration.identity.undiscoverableHint')} +

+ ) : null} +
+ ); +} diff --git a/app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx b/app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx index 8a8249086..ac8950ab4 100644 --- a/app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx +++ b/app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx @@ -30,6 +30,8 @@ vi.mock('../../lib/orchestration/orchestrationClient', async importOriginal => { sendMasterMessage: vi.fn(), markRead: vi.fn(), status: vi.fn(), + selfIdentity: vi.fn(), + relayInfo: vi.fn(), }, }; }); @@ -50,6 +52,8 @@ const messagesListMock = vi.mocked(orchestrationClient.messagesList); const sendMasterMock = vi.mocked(orchestrationClient.sendMasterMessage); 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 pairingListMock = vi.mocked(apiClient.orchestrationPairing.list); const pairingLinkMock = vi.mocked(apiClient.orchestrationPairing.linkSession); @@ -105,6 +109,18 @@ describe('TinyPlaceOrchestrationTab', () => { sendMasterMock.mockResolvedValue({ ok: true, messageId: 'm-1' }); markReadMock.mockResolvedValue({ ok: true }); statusMock.mockResolvedValue({}); + selfIdentityMock.mockResolvedValue({ + agentId: '6wNaBJkatir4B86cw5ykHZWQ3xoNaKygX5vAU9MQbHSh', + handles: [{ username: 'openhuman', primary: true }], + primaryHandle: 'openhuman', + cardPublished: true, + keyPublished: true, + discoverable: true, + }); + relayInfoMock.mockResolvedValue({ + baseUrl: 'https://staging-api.tiny.place', + network: 'staging', + }); pairingListMock.mockResolvedValue({ records: [], contacts: { contacts: [] }, @@ -191,6 +207,19 @@ describe('TinyPlaceOrchestrationTab', () => { expect(screen.getByText('OpenHuman app session')).toBeInTheDocument(); }); + it('keeps the relay badge visible when identity discovery fails (locked wallet)', async () => { + // selfIdentity() builds the tiny.place client from the wallet and can reject; + // relayInfo() only reads the base URL and must stay visible regardless. + selfIdentityMock.mockRejectedValue(new Error('wallet locked')); + + render(); + + const badge = await screen.findByTestId('tinyplace-relay-badge'); + expect(badge).toHaveAttribute('data-network', 'staging'); + // Identity read failed → its card must not render. + expect(screen.queryByTestId('tinyplace-self-identity')).not.toBeInTheDocument(); + }); + it('loads and renders messages for the opened chat', async () => { sessionsListMock.mockResolvedValue({ sessions: [ diff --git a/app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx b/app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx index 686d8b36c..14e9b0583 100644 --- a/app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx +++ b/app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx @@ -9,6 +9,11 @@ import { PaymentRequiredError, } from '../../lib/agentworld/invokeApiClient'; import { useT } from '../../lib/i18n/I18nContext'; +import { + orchestrationClient, + type RelayInfo, + type SelfIdentity, +} from '../../lib/orchestration/orchestrationClient'; import { type ChatMessage, type ChatWindow, @@ -17,6 +22,8 @@ import { } from '../../lib/orchestration/useOrchestrationChats'; import { subconsciousTrigger } from '../../utils/tauriCommands/subconscious'; import Button from '../ui/Button'; +import RelayBadge from './RelayBadge'; +import SelfIdentityCard from './SelfIdentityCard'; const debug = debugFactory('brain:tinyplace-orchestration'); @@ -213,6 +220,12 @@ export default function TinyPlaceOrchestrationTab() { // Which contact rows are expanded to reveal their nested sessions. const [expandedContacts, setExpandedContacts] = useState>({}); const [creatingSession, setCreatingSession] = useState(null); + // Own tiny.place identity (discoverability) + the relay the core is on. Both + // best-effort: a failed read leaves the card/badge hidden rather than erroring + // the whole tab. + const [selfIdentity, setSelfIdentity] = useState(null); + const [identityLoading, setIdentityLoading] = useState(true); + const [relayInfo, setRelayInfo] = useState(null); const mountedRef = useRef(true); const toggleContact = useCallback((address: string) => { @@ -257,6 +270,40 @@ export default function TinyPlaceOrchestrationTab() { } }, []); + const loadIdentity = useCallback(async () => { + debug('[tinyplace-orchestration] identity load entry'); + // Identity and relay are independent reads: selfIdentity() builds the + // tiny.place client from the wallet and can reject (locked/unconfigured + // wallet), but relayInfo() only reads the configured base URL and must + // stay visible regardless. Settle them separately so one failure never + // hides the other. Neither failure may break the chat surface. + const [identityResult, relayResult] = await Promise.allSettled([ + orchestrationClient.selfIdentity(), + orchestrationClient.relayInfo(), + ]); + if (!mountedRef.current) return; + if (identityResult.status === 'fulfilled') { + debug( + '[tinyplace-orchestration] identity load ok discoverable=%s', + identityResult.value.discoverable + ); + setSelfIdentity(identityResult.value); + } else { + const reason = identityResult.reason; + const message = reason instanceof Error ? reason.message : String(reason); + debug('[tinyplace-orchestration] identity load error %s', message); + } + if (relayResult.status === 'fulfilled') { + debug('[tinyplace-orchestration] relay load ok network=%s', relayResult.value.network); + setRelayInfo(relayResult.value); + } else { + const reason = relayResult.reason; + const message = reason instanceof Error ? reason.message : String(reason); + debug('[tinyplace-orchestration] relay load error %s', message); + } + setIdentityLoading(false); + }, []); + const runPairingAction = useCallback( async (actionId: string, action: () => Promise) => { debug('[tinyplace-orchestration] pairing action entry id=%s', actionId); @@ -297,7 +344,8 @@ export default function TinyPlaceOrchestrationTab() { const refreshAll = useCallback(() => { void refresh(); void loadPairing(); - }, [refresh, loadPairing]); + void loadIdentity(); + }, [refresh, loadPairing, loadIdentity]); const submitComposer = useCallback( (event: FormEvent) => { @@ -316,12 +364,15 @@ export default function TinyPlaceOrchestrationTab() { useEffect(() => { mountedRef.current = true; - const handle = window.setTimeout(() => void loadPairing(), 0); + const handle = window.setTimeout(() => { + void loadPairing(); + void loadIdentity(); + }, 0); return () => { window.clearTimeout(handle); mountedRef.current = false; }; - }, [loadPairing]); + }, [loadPairing, loadIdentity]); const pinned = chats.filter(chat => chat.pinned); const sessions = chats @@ -416,9 +467,12 @@ export default function TinyPlaceOrchestrationTab() {
-

- {t('tinyplaceOrchestration.title')} -

+
+

+ {t('tinyplaceOrchestration.title')} +

+ +

{t('tinyplaceOrchestration.subtitle')}

@@ -444,6 +498,8 @@ export default function TinyPlaceOrchestrationTab() { ) : null}
+ +