diff --git a/packages/ui/src/lib/components/RoomyThread.svelte b/packages/ui/src/lib/components/RoomyThread.svelte index 8a6b5f0..e23f023 100644 --- a/packages/ui/src/lib/components/RoomyThread.svelte +++ b/packages/ui/src/lib/components/RoomyThread.svelte @@ -3,7 +3,7 @@ import { participates } from '$lib/admin.js' import { account } from '$lib/auth.svelte.js' import { stamp } from '$lib/format.js' - import { discoverRoomyThread, RoomyError, type RoomyMessage } from '$lib/roomy.js' + import { discoverRoomyThread, parseRoomySource, RoomyError, type RoomyMessage } from '$lib/roomy.js' import type { Space } from '$lib/space.js' import { toast } from '$lib/ui.svelte.js' import { write } from '$lib/write.js' @@ -18,8 +18,9 @@ let error = $state('') let confirming = $state('') let busy = $state('') - let service = $state('did:web:api.roomy.space') - let roomId = $state('') + let sourceInput = $state('') + let attachOpen = $state(false) + let sourceField: HTMLInputElement | undefined = $state() let refresh = $state(0) // A tab can still hold a goal view materialized by the previous bundle while the new bundle is // taking over. Treat the additive projections as empty until the next fold instead of letting @@ -31,6 +32,8 @@ const writable = $derived(!space.fixture && participates(space.directory, me)) const importedIds = $derived(new Set(importedMessages.map((entry) => entry.value.source.messageId))) + $effect(() => { if (attachOpen) sourceField?.focus() }) + $effect(() => { refresh if (!source) { messages = []; return } @@ -44,11 +47,19 @@ }) async function attach(): Promise { + const parsed = parseRoomySource(sourceInput) + if (!parsed) { error = 'Paste a Roomy thread URL or enter its room id.'; return } busy = 'attach'; error = '' - try { await write(['thread', 'attach', '--goal', view.target.uri, '--service', service, '--room', roomId]); toast('Roomy thread attached') } + try { + await write(['thread', 'attach', '--goal', view.target.uri, '--service', parsed.service, '--room', parsed.roomId]) + attachOpen = false; sourceInput = ''; toast('Roomy thread attached') + } catch (failure) { error = failure instanceof Error ? failure.message : String(failure) } finally { busy = '' } } + function closeAttach(): void { attachOpen = false; error = ''; sourceInput = '' } + function backdrop(event: MouseEvent): void { if (event.target === event.currentTarget) closeAttach() } + function keys(event: KeyboardEvent): void { if (attachOpen && event.key === 'Escape') closeAttach() } async function importMessage(message: RoomyMessage): Promise { if (!source) return busy = message.id; error = '' @@ -64,20 +75,16 @@ } -
- Roomy thread {#if source}{messages.length}{/if} - {#if source}{/if} -
-

The live thread is visible only here. Agents see only messages a member explicitly imports.

+ {#if !source} -
- - - - {#if !writable}

Sign in as a space member to attach a thread.

{/if} -
+ {:else} +
+ Roomy thread {messages.length} + +
+

The live thread is visible only here. Agents see only messages a member explicitly imports.

{name}{source.roomId}
{#if loading}

Reading Roomy…

{:else if error} @@ -99,6 +106,27 @@ {/if} {/if} +{#if attachOpen} + +
+ +
+{/if} + {#if importedMessages.length}

Imported into agent context

@@ -110,10 +138,10 @@ {#if error && !source}{/if} diff --git a/packages/ui/src/lib/components/RoomyThread.svelte.test.ts b/packages/ui/src/lib/components/RoomyThread.svelte.test.ts index 9d7bf5f..63e31b2 100644 --- a/packages/ui/src/lib/components/RoomyThread.svelte.test.ts +++ b/packages/ui/src/lib/components/RoomyThread.svelte.test.ts @@ -21,7 +21,7 @@ afterEach(() => { }) describe('Roomy thread', () => { - it('treats additive projections as empty while an older goal view is still active', () => { + it('keeps an unattached goal compact and opens URL attachment on demand', () => { const legacyView = { target: { uri: 'at://did:plc:member/com.disnetdev.radial.goal/g1' }, } as GoalView @@ -32,7 +32,13 @@ describe('Roomy thread', () => { component = mount(RoomyThread, { target, props: { view: legacyView, space } }) as Record flushSync() - expect(target.textContent).toContain('Attach thread') + expect(target.textContent).toContain('Add Roomy thread') + expect(target.textContent).not.toContain('The live thread is visible only here') + expect(target.querySelector('[role="dialog"]')).toBeNull() + ;(target.querySelector('.add-thread') as HTMLButtonElement).click() + flushSync() + expect(target.querySelector('[role="dialog"]')).not.toBeNull() + expect(target.querySelector('input')?.getAttribute('placeholder')).toContain('https://roomy.space/') expect(target.textContent).not.toContain('Imported into agent context') }) }) diff --git a/packages/ui/src/lib/roomy.test.ts b/packages/ui/src/lib/roomy.test.ts index 26de78c..a2c7516 100644 --- a/packages/ui/src/lib/roomy.test.ts +++ b/packages/ui/src/lib/roomy.test.ts @@ -1,9 +1,16 @@ import { describe, expect, it } from 'vitest' -import { discoverRoomyThread, parseRoomyMessage } from './roomy.js' +import { discoverRoomyThread, parseRoomyMessage, parseRoomySource } from './roomy.js' const response = (body: unknown, status = 200): Response => new Response(JSON.stringify(body), { status }) describe('Roomy browser reader', () => { + it('accepts a public Roomy thread URL or bare room id', () => { + expect(parseRoomySource('https://roomy.space/did:plc:4moccs43r5v2xzkynae3xk2u/01KWF3TSMY18B4XR43M8NWC5WT')).toEqual({ + service: 'did:web:api.roomy.space', roomId: '01KWF3TSMY18B4XR43M8NWC5WT', + }) + expect(parseRoomySource('01KWF3TSMY18B4XR43M8NWC5WT')?.roomId).toBe('01KWF3TSMY18B4XR43M8NWC5WT') + expect(parseRoomySource('https://example.com/did:plc:x/room')).toBeUndefined() + }) it('keeps content verbatim and rejects invalid timestamps', () => { expect(parseRoomyMessage({ id: 'm1', authorDid: 'did:plc:a', content: 'plain', timestamp: '2026-01-01T00:00:00Z' })?.content).toBe('plain') expect(parseRoomyMessage({ id: 'm1', authorDid: 'did:plc:a', content: 'x', timestamp: 'never' })).toBeUndefined() diff --git a/packages/ui/src/lib/roomy.ts b/packages/ui/src/lib/roomy.ts index 323d50e..7a499e0 100644 --- a/packages/ui/src/lib/roomy.ts +++ b/packages/ui/src/lib/roomy.ts @@ -20,6 +20,7 @@ export class RoomyError extends Error { } const origins = new Map>() +const roomIdPattern = /^[0-9A-Za-z_-]{1,128}$/ const text = (value: unknown, max: number): string | undefined => typeof value === 'string' && value.length <= max ? value : undefined @@ -35,6 +36,19 @@ export function parseRoomyMessage(value: unknown): RoomyMessage | undefined { return { id, authorDid, content, timestamp, ...(authorName ? { authorName } : {}) } } +/** Turn Roomy's public thread URL (or a bare room id) into the canonical source we persist. */ +export function parseRoomySource(value: string): { service: string; roomId: string } | undefined { + const input = value.trim() + if (roomIdPattern.test(input)) return { service: 'did:web:api.roomy.space', roomId: input } + let url: URL + try { url = new URL(input) } catch { return undefined } + if (url.protocol !== 'https:' || url.hostname !== 'roomy.space' || url.search || url.hash) return undefined + let parts: string[] + try { parts = url.pathname.split('/').filter(Boolean).map(decodeURIComponent) } catch { return undefined } + if (parts.length !== 2 || !parts[0]?.startsWith('did:') || !roomIdPattern.test(parts[1] ?? '')) return undefined + return { service: 'did:web:api.roomy.space', roomId: parts[1]! } +} + export function resolveRoomyOrigin(service: string, fetcher: typeof fetch = fetch): Promise { const cached = origins.get(service) if (cached) return cached