diff --git a/packages/ui/src/lib/components/RoomyThread.svelte b/packages/ui/src/lib/components/RoomyThread.svelte index e23f023..7bbfc55 100644 --- a/packages/ui/src/lib/components/RoomyThread.svelte +++ b/packages/ui/src/lib/components/RoomyThread.svelte @@ -20,11 +20,13 @@ let busy = $state('') let sourceInput = $state('') let attachOpen = $state(false) + let attachTrigger: HTMLButtonElement | undefined = $state() + let attachDialog: HTMLDialogElement | undefined = $state() 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 - // that short-lived, structurally older view take down the whole goal page. + // Older UI builds could typecheck core's `src` while executing a stale `dist`, producing goal + // views without these additive projections. New builds pin workspace sources in Vite; keep this + // boundary tolerant so a structurally older view still cannot take down the whole goal page. const threadSources = $derived(view.threadSources ?? []) const importedMessages = $derived(view.importedMessages ?? []) const source = $derived(threadSources[0]) @@ -32,7 +34,11 @@ 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(() => { + if (!attachOpen || !attachDialog) return + if (typeof attachDialog.showModal === 'function' && !attachDialog.open) attachDialog.showModal() + sourceField?.focus() + }) $effect(() => { refresh @@ -57,9 +63,15 @@ 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() } + function closeAttach(): void { + if (attachDialog?.open && typeof attachDialog.close === 'function') attachDialog.close() + attachOpen = false; error = ''; sourceInput = '' + queueMicrotask(() => attachTrigger?.focus()) + } + function backdrop(event: MouseEvent): void { + if (event.target === attachDialog) closeAttach() + } + function cancel(event: Event): void { event.preventDefault(); closeAttach() } async function importMessage(message: RoomyMessage): Promise { if (!source) return busy = message.id; error = '' @@ -75,10 +87,8 @@ } - - {#if !source} - + {:else}
Roomy thread {messages.length} @@ -107,24 +117,21 @@ {/if} {#if attachOpen} - -
- -
+ +

Add a Roomy thread

+

Paste its Roomy URL. The live conversation stays browser-only until a member imports a message.

+ {#if writable} +
{ event.preventDefault(); void attach() }}> + + + {#if error}{/if} +
+
+ {:else} +

Sign in as a space member to attach a thread.

+
+ {/if} +
{/if} {#if importedMessages.length} @@ -139,9 +146,9 @@ diff --git a/packages/ui/src/lib/components/RoomyThread.svelte.test.ts b/packages/ui/src/lib/components/RoomyThread.svelte.test.ts index 63e31b2..2491ddf 100644 --- a/packages/ui/src/lib/components/RoomyThread.svelte.test.ts +++ b/packages/ui/src/lib/components/RoomyThread.svelte.test.ts @@ -3,6 +3,7 @@ import type { GoalView } from '@radial/core' import { flushSync, mount, unmount } from 'svelte' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Space } from '$lib/space.js' +import { write } from '$lib/write.js' import RoomyThread from './RoomyThread.svelte' vi.mock('$lib/admin.js', () => ({ participates: () => true })) @@ -21,7 +22,7 @@ afterEach(() => { }) describe('Roomy thread', () => { - it('keeps an unattached goal compact and opens URL attachment on demand', () => { + it('submits a Roomy URL as the canonical thread attachment command', async () => { const legacyView = { target: { uri: 'at://did:plc:member/com.disnetdev.radial.goal/g1' }, } as GoalView @@ -34,11 +35,35 @@ describe('Roomy 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() + expect(target.querySelector('dialog')).toBeNull() + const trigger = target.querySelector('.add-thread') as HTMLButtonElement + trigger.click() flushSync() - expect(target.querySelector('[role="dialog"]')).not.toBeNull() - expect(target.querySelector('input')?.getAttribute('placeholder')).toContain('https://roomy.space/') + expect(target.querySelector('dialog')).not.toBeNull() + const input = target.querySelector('input') as HTMLInputElement + expect(input.getAttribute('placeholder')).toContain('https://roomy.space/') + input.value = 'https://roomy.space/did:plc:4moccs43r5v2xzkynae3xk2u/01KWF3TSMY18B4XR43M8NWC5WT' + input.dispatchEvent(new Event('input', { bubbles: true })) + ;(target.querySelector('form') as HTMLFormElement).requestSubmit() + await vi.waitFor(() => expect(write).toHaveBeenCalledWith([ + 'thread', 'attach', + '--goal', 'at://did:plc:member/com.disnetdev.radial.goal/g1', + '--service', 'did:web:api.roomy.space', + '--room', '01KWF3TSMY18B4XR43M8NWC5WT', + ])) expect(target.textContent).not.toContain('Imported into agent context') }) + + it('returns focus to the attachment trigger when cancelled', async () => { + const view = { target: { uri: 'at://did:plc:member/com.disnetdev.radial.goal/g1' } } as GoalView + const space = { fixture: false, directory: new Map() } as unknown as Space + target = document.createElement('div') + document.body.appendChild(target) + component = mount(RoomyThread, { target, props: { view, space } }) as Record + flushSync() + const trigger = target.querySelector('.add-thread') as HTMLButtonElement + trigger.click(); flushSync() + ;(target.querySelector('.acts .btn') as HTMLButtonElement).click() + await vi.waitFor(() => expect(document.activeElement).toBe(trigger)) + }) }) diff --git a/packages/ui/test/workspace-source.test.mjs b/packages/ui/test/workspace-source.test.mjs new file mode 100644 index 0000000..b45f742 --- /dev/null +++ b/packages/ui/test/workspace-source.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { describe, it } from 'node:test' + +describe('browser workspace packages', () => { + it('bundles current isomorphic sources instead of possibly stale dist builds', async () => { + const config = await readFile(new URL('../vite.config.ts', import.meta.url), 'utf8') + for (const [name, directory] of [ + ['core', 'core'], + ['atproto', 'atproto'], + ['ingest', 'ingest'], + ['sidecar', 'sidecar'], + ]) { + assert.match(config, new RegExp(`find: /\\^@radial\\\\/${name}\\$/, replacement: new URL\\('\\.\\./${directory}/src/index\\.ts'`)) + } + }) +}) diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts index be8eacd..dc35bcd 100644 --- a/packages/ui/vite.config.ts +++ b/packages/ui/vite.config.ts @@ -38,6 +38,18 @@ function clientMetadata(): Plugin { export default defineConfig({ plugins: [sveltekit(), clientMetadata()], + resolve: { + alias: [ + // Workspace package declarations expose `src` for types and `dist` at runtime. That split is + // right for Node consumers, but it lets the dev server typecheck a newly added command or fold + // projection and then execute an older build that does not contain it. The UI is the browser + // implementation of these isomorphic packages, so bundle their current sources directly. + { find: /^@radial\/core$/, replacement: new URL('../core/src/index.ts', import.meta.url).pathname }, + { find: /^@radial\/atproto$/, replacement: new URL('../atproto/src/index.ts', import.meta.url).pathname }, + { find: /^@radial\/ingest$/, replacement: new URL('../ingest/src/index.ts', import.meta.url).pathname }, + { find: /^@radial\/sidecar$/, replacement: new URL('../sidecar/src/index.ts', import.meta.url).pathname }, + ], + }, // Which build this is, for the stamp at the foot of the pane (`src/lib/build.ts`). Read once, here, // rather than by the app: a bundle is a set of hashed files served from a static host and there is // nothing left at runtime that knows where it came from. It is a `define` rather than an env var