diff --git a/.agents/rules/typescript.md b/.agents/rules/typescript.md index bd5454cd..78052bdf 100644 --- a/.agents/rules/typescript.md +++ b/.agents/rules/typescript.md @@ -1,10 +1,11 @@ --- -description: TypeScript conventions for props, types, and type safety. +description: TypeScript conventions for component filenames, props, types, and type safety. globs: '*.ts, *.tsx' alwaysApply: false --- - Always define a named `type` for component props above the component. Never use inline object types for props. Name it after the component: `type ChatPanelProps = { ... }`. +- Use kebab-case for new React component filenames and their tests, such as `chat-panel.tsx` and `chat-panel.test.tsx`. Keep component and props identifiers in PascalCase: `ChatPanel`, `ChatPanelProps`. - Never use `any`. Use `unknown` when the type is truly unknown, then narrow it. - Never cast with `as any`. Prefer proper typing or `as unknown as T` with a comment explaining why. - Reuse existing types from `lib/types.ts` instead of redefining equivalent shapes. diff --git a/bun.lock b/bun.lock index 29f5ab71..93dd1e03 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,7 @@ "frimousse": "^0.3.0", "highlight.js": "^10.7.3", "hono": "^4.13.7", + "jose": "^6.2.12", "lru-cache": "^11.5.2", "modern-screenshot": "^4.7.0", "motion": "^12.43.0", @@ -1187,7 +1188,7 @@ "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="], + "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -1909,6 +1910,8 @@ "@modelcontextprotocol/sdk/hono": ["hono@4.12.7", "", {}, "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw=="], + "@modelcontextprotocol/sdk/jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="], + "@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "@radix-ui/react-popper/@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], diff --git a/client/api/app-config.test.ts b/client/api/app-config.test.ts new file mode 100644 index 00000000..e3dbc5fc --- /dev/null +++ b/client/api/app-config.test.ts @@ -0,0 +1,29 @@ +import { afterEach, expect, test } from 'bun:test' +import type { ClientAppConfig } from '@/lib/types' + +import { loadAppConfig } from './app-config' + +const originalFetch = Object.getOwnPropertyDescriptor(globalThis, 'fetch') + +afterEach(() => { + if (originalFetch) Object.defineProperty(globalThis, 'fetch', originalFetch) + else Reflect.deleteProperty(globalThis, 'fetch') +}) + +test('startup receives confirmed config while a failed refresh cannot enable a local profile from cached config', async () => { + const config: ClientAppConfig = { + cloudDemo: false, + experimental: { collab: true }, + demoInstallUrl: 'https://moi.computer' + } + Object.defineProperty(globalThis, 'fetch', { + configurable: true, + value: async () => Response.json(config) + }) + expect(await loadAppConfig()).toEqual(config) + Object.defineProperty(globalThis, 'fetch', { + configurable: true, + value: async () => Response.json({}, { status: 503 }) + }) + expect(await loadAppConfig()).toBeUndefined() +}) diff --git a/client/api/app-config.ts b/client/api/app-config.ts index 1a5ce6a7..b1a7a4a2 100644 --- a/client/api/app-config.ts +++ b/client/api/app-config.ts @@ -1,6 +1,7 @@ import { useSyncExternalStore } from 'react' import type { ClientAppConfig } from '@/lib/types' +import { EXPERIMENTAL_DEFAULTS } from '@/lib/experimental' import { onWorkspaceEventsReconnect } from '@/client/runtime/useWorkspaceEvents' @@ -14,7 +15,7 @@ import { requestJson } from './http' const DEFAULTS: ClientAppConfig = { cloudDemo: false, - experiments: [], + experimental: { ...EXPERIMENTAL_DEFAULTS }, demoInstallUrl: 'https://moi.computer' } @@ -30,20 +31,22 @@ function subscribe(listener: () => void): () => void { // Failures keep the last known value (defaults on the very first fetch): // non-demo is the safe presentation, and the server still enforces its gates. -async function fetchAppConfig(): Promise { +async function fetchAppConfig(): Promise { try { current = await requestJson('/api/config') for (const listener of listeners) listener() + return current } catch {} } // Called once from init() before the app mounts; never rejects, so a config // hiccup cannot block the app from starting. -export async function loadAppConfig(): Promise { - await fetchAppConfig() +export async function loadAppConfig(): Promise { + const config = await fetchAppConfig() onWorkspaceEventsReconnect(() => { void fetchAppConfig() }) + return config } export function useAppConfig(): ClientAppConfig { diff --git a/client/app/routes/WorkspaceRoute.tsx b/client/app/routes/WorkspaceRoute.tsx index 4a2432c7..0e59e0a0 100644 --- a/client/app/routes/WorkspaceRoute.tsx +++ b/client/app/routes/WorkspaceRoute.tsx @@ -1,6 +1,8 @@ +import { CollabProvider, useCollabEnabled } from '@/client/features/collab' import { useQueryClient } from '@tanstack/react-query' import { workspaceKeys } from '@/client/api/workspace-keys' +import { useAppConfig } from '@/client/api/app-config' import { LedLogo } from '@/client/components/shared/LedLogo' import { SidebarLayout } from '@/client/app/shell/SidebarLayout' import { useSelectedSession } from '@/client/features/chat/sessions/useSelectedSession' @@ -19,19 +21,24 @@ type WorkspaceRouteProps = { id: string } -// The URL's tab segment is not threaded down — useWorkspaceNavigation reads it -// off the matched route with wouter's `useParams`. +// Navigation reads the raw browser address so encoded paths and query params +// are decoded exactly once; the route only owns the workspace ID. export function WorkspaceRoute({ id }: WorkspaceRouteProps) { + const { experimental } = useAppConfig() + return ( - + + + ) } -function WorkspaceLoader({ id }: WorkspaceRouteProps) { +function WorkspaceContent({ id }: WorkspaceRouteProps) { + const collabEnabled = useCollabEnabled() const queryClient = useQueryClient() const [selectedSessionId] = useSelectedSession() const { isLoading: layoutLoading } = useWorkspaceLayoutCtx() @@ -62,20 +69,21 @@ function WorkspaceLoader({ id }: WorkspaceRouteProps) { builders.isLoading return ( - <> - - {fresh ? ( -
- -
- ) : ( - - )} -
- + + {fresh ? ( +
+ +
+ ) : ( + // Browser tab state is read when the screen mounts. Reset the screen + // when it switches between shared workspace tabs and browser tab state. + + )} +
) } diff --git a/client/features/applets/AppletMount.tsx b/client/features/applets/AppletContainer.tsx similarity index 67% rename from client/features/applets/AppletMount.tsx rename to client/features/applets/AppletContainer.tsx index dcdd0c3c..9a7493a5 100644 --- a/client/features/applets/AppletMount.tsx +++ b/client/features/applets/AppletContainer.tsx @@ -1,11 +1,12 @@ import { type ReactNode } from 'react' +import { AppletPresenceProvider } from '@/client/features/collab' import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' -import { type AppletSegment, appletScope, appletStyleKey } from './applet-cache' +import { type AppletSegment, appletId, appletStyleKey } from './applet-cache' import { useAppletStyle } from './applet-styles' -type AppletMountProps = { +type AppletContainerProps = { segment: AppletSegment name: string version: number @@ -19,13 +20,14 @@ type AppletMountProps = { // page. This is the widget path. Views don't use it: ViewManager parks a view's // DOM offscreen instead of unmounting it, so it holds the styles itself, above // the boundary that hides the view. -export function AppletMount({ segment, name, version, children }: AppletMountProps) { +export function AppletContainer({ segment, name, version, children }: AppletContainerProps) { const workspaceId = useWorkspaceId() + const id = appletId(segment, name) useAppletStyle(appletStyleKey(segment, workspaceId, name), version) return ( -
- {children} +
+ {children}
) } diff --git a/client/features/applets/WidgetShell.tsx b/client/features/applets/WidgetShell.tsx index 582217a2..350fc325 100644 --- a/client/features/applets/WidgetShell.tsx +++ b/client/features/applets/WidgetShell.tsx @@ -1,6 +1,6 @@ import { AnimatePresence, motion } from 'motion/react' -import { AppletMount } from '@/client/features/applets/AppletMount' +import { AppletContainer } from '@/client/features/applets/AppletContainer' import { useWidget } from '@/client/features/applets/useApplet' import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' @@ -31,9 +31,9 @@ export function WidgetShell({ name }: WidgetShellProps) { workspaceId={workspaceId} resetKey={widget.version} > - + - + )} diff --git a/client/features/applets/applet-cache.ts b/client/features/applets/applet-cache.ts index 6cde5624..7cf8d9be 100644 --- a/client/features/applets/applet-cache.ts +++ b/client/features/applets/applet-cache.ts @@ -54,11 +54,9 @@ export function appletStyleKey(segment: AppletSegment, workspaceId: string, name return `/api/workspaces/${workspaceId}/${segment}/${name}` } -// The `data-applet` value the bundle's scoped CSS selectors key off (see -// server/applets/applet-css.ts). It goes on the container wrapping a mounted -// applet — AppletMount for widgets, the view slot for views. -export function appletScope(segment: AppletSegment, name: string): string { - return `${segment === 'widgets' ? 'widget' : 'view'}:${name}` +// Stable applet identity used by scoped CSS, presence, and thumbnail capture. +export function appletId(segment: AppletSegment, name: string): string { + return `${segment}/${name}` } export function getCachedApplet(key: string): Promise | undefined { diff --git a/client/features/applets/applet-runtime.test.ts b/client/features/applets/applet-runtime.test.ts index 476331af..3472d30d 100644 --- a/client/features/applets/applet-runtime.test.ts +++ b/client/features/applets/applet-runtime.test.ts @@ -1,6 +1,7 @@ import * as appletLog from './applet-log' import { describe, expect, test, spyOn } from 'bun:test' import { toast } from '@/client/components/ui/toast' +import type { AppletBridge } from '@/lib/types' import { appletKey, @@ -9,7 +10,6 @@ import { setCachedApplet } from './applet-cache' import { - type AppletBridge, type AppletChatMessage, type AppletIdentity, appletRuntime, @@ -45,27 +45,39 @@ describe('bridge validation', () => { const log = spyOn(appletLog, 'reportAppletError').mockImplementation(() => {}) bridge.navigate('moi:/views/orders?order=o-1') bridge.navigate('moi:/overview') + bridge.navigate('moi:/files/clips/video.mp4#t=5') bridge.navigate(['invalid']) bridge.navigate('javascript:alert(1)') - expect(calls).toEqual(['moi:/views/orders?order=o-1', 'moi:/overview']) - expect(log).toHaveBeenCalledTimes(2) + bridge.navigate('moi:/files/clips/%2Fvideo.mp4') + expect(calls).toEqual([ + 'moi:/views/orders?order=o-1', + 'moi:/overview', + 'moi:/files/clips/video.mp4#t=5' + ]) + expect(log).toHaveBeenCalledTimes(3) log.mockRestore() }) test('resolves native anchor hrefs in the source workspace and disposes safely', () => { const { bridge, dispose } = appletRuntime('ws-1').connect(VIEW) - expect(bridge.resolveHref('moi:/views/orders?order=o-1')).toBe( + expect(bridge.resolveUrl('moi:/views/orders?order=o-1')).toBe( '/workspace/ws-1/views/orders?order=o-1' ) - expect(bridge.resolveHref('https://example.com/')).toBe('https://example.com/') - expect(() => bridge.resolveHref('javascript:alert(1)')).toThrow() + expect(bridge.resolveUrl('https://example.com/')).toBe('https://example.com/') + expect(bridge.resolveUrl('moi:/files/clips/a%20b.mp4')).toBe( + '/api/workspaces/ws-1/files/clips/a%20b.mp4' + ) + expect(() => bridge.resolveUrl('javascript:alert(1)')).toThrow() dispose() - expect(bridge.resolveHref('moi:/overview')).toBe('') + expect(bridge.resolveUrl('moi:/overview')).toBe('') }) test('resolves applet links with the host router base', () => { const { bridge } = appletRuntime('prefixed').connect(VIEW, '/prefix') - expect(bridge.resolveHref('moi:/views/orders')).toBe('/prefix/workspace/prefixed/views/orders') + expect(bridge.resolveUrl('moi:/views/orders')).toBe('/prefix/workspace/prefixed/views/orders') + expect(bridge.resolveUrl('moi:/files/photo.png')).toBe( + '/api/workspaces/prefixed/files/photo.png' + ) }) test('drops calls with malformed addresses instead of emitting', () => { @@ -334,6 +346,22 @@ describe('sendChatMessage rate limiting', () => { }) describe('disposal', () => { + test('collaboration shares the host API and is revoked with its connection', () => { + const runtime = appletRuntime(`ws-${crypto.randomUUID()}`) + const view = runtime.connect(VIEW) + const widget = runtime.connect(WIDGET) + const api = view.bridge.collab + + expect(typeof api?.usePeers).toBe('function') + expect(typeof api?.useWorkspaceUsers).toBe('function') + expect(widget.bridge.collab).toBe(api) + + view.dispose() + expect(view.bridge.collab).toBeUndefined() + expect(widget.bridge.collab).toBe(api) + widget.dispose() + }) + test('a disposed connection is inert even while subscribers are live', () => { const ws = `ws-${crypto.randomUUID()}` const { calls } = subscribeNavigation(ws) diff --git a/client/features/applets/applet-runtime.ts b/client/features/applets/applet-runtime.ts index 4ad836ce..1397e4b7 100644 --- a/client/features/applets/applet-runtime.ts +++ b/client/features/applets/applet-runtime.ts @@ -14,12 +14,18 @@ // — no central handlers object assembled by the screen. Applet → host only; // if a host → applet direction is ever added (`moi.on(...)`), `dispose` must // also unbind those listeners or a disposed module leaks. +import { appletCollabApi, type AppletCollabApi } from '@/client/features/collab/applet-api' import { MAX_ATTACHMENT_LABEL_CHARS, MAX_TEXT_ATTACHMENT_CHARS, snapshotTextAttachment } from '@/lib/moi-attachments' -import type { AppletBridge, AppletKind, AttachmentInput, AttachmentOrigin } from '@/lib/types' +import type { + AppletBridge as SharedAppletBridge, + AppletKind, + AttachmentInput, + AttachmentOrigin +} from '@/lib/types' import { isWorkspaceAttachmentPath, MAX_UPLOAD_BYTES } from '@/lib/message-attachments' import { useEffect } from 'react' @@ -30,9 +36,11 @@ import { toast } from '@/client/components/ui/toast' import { createRateLimiter, type RateLimiter } from '@/client/lib/rate-limit' import { useLatestRef } from '@/client/lib/use-latest-ref' import { isParamsRecord } from '@/lib/workspace-tabs' -import { resolveWorkspaceHref } from '@/lib/navigation' +import { resolveUrl, workspacePath } from '@/lib/navigation' -export type { AppletBridge } from '@/lib/types' +type HostAppletBridge = SharedAppletBridge & { + readonly collab: AppletCollabApi | undefined +} // Which applet a bridge belongs to, supplied by the host at attach time. export type AppletIdentity = { kind: AppletKind; name: string } @@ -118,7 +126,14 @@ function createRuntime(workspaceId: string) { connect(identity: AppletIdentity, base = '') { let alive = true const source = appletSource(identity) - const bridge: AppletBridge = { + const urlContext = { + apiBase: `/api/workspaces/${encodeURIComponent(workspaceId)}`, + workspacePath: workspacePath(workspaceId, base) + } + const bridge: HostAppletBridge = { + get collab() { + return alive ? appletCollabApi : undefined + }, addChatAttachment(input) { if (!alive) return try { @@ -130,20 +145,20 @@ function createRuntime(workspaceId: string) { drop(identity, `addChatAttachment() was dropped: ${message}`) } }, - navigate(href) { + navigate(url) { if (!alive) return try { - if (typeof href !== 'string') throw new Error('Navigation needs a URL') - resolveWorkspaceHref(workspaceId, href, base) - emitter.emit('navigate', href) + if (typeof url !== 'string') throw new Error('Navigation needs a URL') + resolveUrl(url, urlContext) + emitter.emit('navigate', url) } catch (error) { drop(identity, `navigate() was dropped: ${errorMessage(error)}`) } }, - resolveHref(href) { + resolveUrl(url) { if (!alive) return '' - if (typeof href !== 'string') throw new Error('A URL is required') - return resolveWorkspaceHref(workspaceId, href, base) + if (typeof url !== 'string') throw new Error('A URL is required') + return resolveUrl(url, urlContext) }, sendChatMessage(input, legacyContext) { if (!alive) return @@ -268,7 +283,7 @@ export function useAppletEvent( // The shape of the host wiring every bundle entry re-exports (see the entry // plugin in server/applets/build-applet.ts). type BridgeModule = { - __attachBridge?: (bridge: AppletBridge) => void + __attachBridge?: (bridge: Partial) => void } // Live connections keyed by applet cache key (`${segment}/${workspaceId}/${name}`) diff --git a/client/features/applets/applet-thumbnail.ts b/client/features/applets/applet-thumbnail.ts index de744353..8691f2b2 100644 --- a/client/features/applets/applet-thumbnail.ts +++ b/client/features/applets/applet-thumbnail.ts @@ -4,6 +4,7 @@ import { useAppletThumbnailRecords, useSaveAppletThumbnails } from '@/client/fea import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' import { useLatestRef } from '@/client/lib/use-latest-ref' import type { AppletKind, AppletThumbnail, AppletThumbnailUpdate } from '@/lib/types' +import { appletId } from './applet-cache' export const THUMBNAIL_MAX_EDGE = 1_000 export const THUMBNAIL_PIXEL_RATIO = 3 @@ -269,7 +270,7 @@ export function useAppletThumbnails({ kind, enabled, targets }: UseAppletThumbna targetsRef.current.map(target => ({ ...target, element: document.querySelector( - `[data-applet-thumbnail="${CSS.escape(`${kind}:${target.id}`)}"]` + `[data-applet-thumbnail="${CSS.escape(appletId(kind === 'view' ? 'views' : 'widgets', target.id))}"]` ) })), () => diff --git a/client/features/chat/applet-chat-intents.test.ts b/client/features/chat/applet-chat-intents.test.ts index 3820f7f2..3b510997 100644 --- a/client/features/chat/applet-chat-intents.test.ts +++ b/client/features/chat/applet-chat-intents.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, mock, spyOn } from 'bun:test' import { QueryClient } from '@tanstack/react-query' -import { appUiKeys } from '@/client/api/app-ui-keys' +import { selectedSessionKey } from '@/client/features/chat/sessions/useSelectedSession' import { toast } from '@/client/components/ui/toast' import * as appletLog from '@/client/features/applets/applet-log' import type { AppletChatMessage } from '@/client/features/applets/applet-runtime' @@ -66,7 +66,7 @@ describe('appletSendBlockedReason', () => { // Exercise the actual async handler with deferred uploads and the real selection // cache, including changes that happen without an intervening React render. -describe('immediate applet sends', () => { +describe.each(['shared', 'browser-tab'] as const)('applet sends, selection scope=%p', scope => { const workspaceId = 'immediate-send-test' const originalFetch = globalThis.fetch let handler: ReturnType @@ -79,7 +79,9 @@ describe('immediate applet sends', () => { let log: ReturnType> function select(sessionId: string | null) { - queryClient.setQueryData(appUiKeys.selectedSession(workspaceId), { sessionId }) + queryClient.setQueryData(selectedSessionKey(workspaceId, scope), { + sessionId + }) } function deferredUpload() { const deferred = Promise.withResolvers() @@ -108,14 +110,19 @@ describe('immediate applet sends', () => { notices = spyOn(toast, 'add').mockImplementation(() => crypto.randomUUID()) close = spyOn(toast, 'close').mockImplementation(() => {}) log = spyOn(appletLog, 'reportAppletError').mockImplementation(() => {}) - handler = createAppletMessageHandler(workspaceId, queryClient, () => ({ - sessionId: - queryClient.getQueryData(appUiKeys.selectedSession(workspaceId)) - ?.sessionId ?? null, - send, - revealChat: reveal, - agentAvailability: availability - })) + handler = createAppletMessageHandler( + workspaceId, + queryClient, + () => ({ + sessionId: + queryClient.getQueryData(selectedSessionKey(workspaceId, scope)) + ?.sessionId ?? null, + send, + revealChat: reveal, + agentAvailability: availability + }), + scope + ) }) afterEach(() => { handler.dispose() @@ -137,6 +144,21 @@ describe('immediate applet sends', () => { expect(notices).not.toHaveBeenCalled() }) + test('ignores selection changes in the other scope while attachments are prepared', async () => { + const deferred = deferredUpload() + const pending = handler.handle(event) + queryClient.setQueryData( + selectedSessionKey(workspaceId, scope === 'shared' ? 'browser-tab' : 'shared'), + { + sessionId: 'another-chat' + } + ) + deferred.resolve(Response.json(upload)) + await pending + expect(send).toHaveBeenCalledTimes(1) + expect(notices.mock.calls.some(([notice]) => notice.title === 'Message canceled')).toBe(false) + }) + test('prepares all three inputs in order without deduplication or draft mutations', async () => { const delayed = Promise.withResolvers() const requests: [string, RequestInit | undefined][] = [] @@ -291,12 +313,17 @@ describe('immediate applet sends', () => { test('a stale rendered send callback cannot target a previously selected chat', async () => { handler.dispose() - handler = createAppletMessageHandler(workspaceId, queryClient, () => ({ - sessionId: 'old-session', - send, - revealChat: reveal, - agentAvailability: availability - })) + handler = createAppletMessageHandler( + workspaceId, + queryClient, + () => ({ + sessionId: 'old-session', + send, + revealChat: reveal, + agentAvailability: availability + }), + scope + ) const fetch = mock(() => Promise.reject(new Error('unexpected upload'))) globalThis.fetch = fetch as unknown as typeof globalThis.fetch await handler.handle(event) diff --git a/client/features/chat/applet-chat-intents.ts b/client/features/chat/applet-chat-intents.ts index c2643909..0c1f6e61 100644 --- a/client/features/chat/applet-chat-intents.ts +++ b/client/features/chat/applet-chat-intents.ts @@ -12,10 +12,11 @@ import { import { prepareChatAttachments, type ChatSendOptions, type PreparedAttachments } from './chat-send' import { useLayoutEffect } from 'react' import { useQueryClient, type QueryClient } from '@tanstack/react-query' -import { appUiKeys } from '@/client/api/app-ui-keys' import { toast } from '@/client/components/ui/toast' +import { selectedSessionKey } from '@/client/features/chat/sessions/useSelectedSession' +import { useCollabEnabled } from '@/client/features/collab' import { useLatestRef } from '@/client/lib/use-latest-ref' -import type { SelectedSessionState } from '@/lib/types' +import type { SelectedSessionScope, SelectedSessionState } from '@/lib/types' import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' import type { AgentAvailability } from '@/client/lib/agent-availability' @@ -51,12 +52,13 @@ export function appletSendBlockedReason(availability: AgentAvailability): string export function createAppletMessageHandler( workspaceId: string, queryClient: QueryClient, - getOptions: () => UseAppletChatMessageOptions + getOptions: () => UseAppletChatMessageOptions, + scope: SelectedSessionScope = 'shared' ) { const pending = new Set<() => void>() let disposed = false const selectedSession = () => - queryClient.getQueryData(appUiKeys.selectedSession(workspaceId)) + queryClient.getQueryData(selectedSessionKey(workspaceId, scope)) ?.sessionId ?? null async function handle(event: AppletChatMessage): Promise { @@ -148,9 +150,15 @@ export function createAppletMessageHandler( export function useAppletChatMessage(options: UseAppletChatMessageOptions): void { const workspaceId = useWorkspaceId() const queryClient = useQueryClient() + const collabEnabled = useCollabEnabled() const latest = useLatestRef(options) useLayoutEffect(() => { - const handler = createAppletMessageHandler(workspaceId, queryClient, () => latest.current) + const handler = createAppletMessageHandler( + workspaceId, + queryClient, + () => latest.current, + collabEnabled ? 'browser-tab' : 'shared' + ) const unsubscribe = appletRuntime(workspaceId).on('sendChatMessage', event => { void handler.handle(event) }) @@ -158,7 +166,7 @@ export function useAppletChatMessage(options: UseAppletChatMessageOptions): void unsubscribe() handler.dispose() } - }, [workspaceId, queryClient, latest]) + }, [workspaceId, queryClient, latest, collabEnabled]) } export function useAppletChatAttachment(sessionId: string | null, revealChat: () => void): void { diff --git a/client/features/chat/messages/MarkdownContent.test.tsx b/client/features/chat/messages/MarkdownContent.test.tsx index 4dcea3e6..9a33cd67 100644 --- a/client/features/chat/messages/MarkdownContent.test.tsx +++ b/client/features/chat/messages/MarkdownContent.test.tsx @@ -26,3 +26,14 @@ test('invalid moi links and executable URLs stay sanitized, including images', ( expect(html).not.toContain('javascript:') expect(html).not.toContain('src="moi:') }) + +test('chat resolves workspace file links and sanitizes invalid file paths', () => { + const html = renderToStaticMarkup( + + + + ) + expect(html).toContain('href="/api/workspaces/abc/files/clips/a%20b.mp4#t=5"') + expect(html).not.toContain('href="moi:') + expect(html).not.toContain('/files/../') +}) diff --git a/client/features/chat/messages/MarkdownContent.tsx b/client/features/chat/messages/MarkdownContent.tsx index 1cd309c9..6f907c64 100644 --- a/client/features/chat/messages/MarkdownContent.tsx +++ b/client/features/chat/messages/MarkdownContent.tsx @@ -6,7 +6,7 @@ import rehypeHighlight from 'rehype-highlight' import remarkGfm from 'remark-gfm' import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' -import { resolveWorkspaceHref } from '@/lib/navigation' +import { resolveUrl, workspacePath } from '@/lib/navigation' import { cn } from '@/client/lib/cn' const remarkPlugins = [remarkGfm] @@ -63,7 +63,10 @@ export function MarkdownContent({ size = 'sm', content }: MarkdownContentProps) urlTransform={(url, key, node) => { if (node.tagName === 'a' && key === 'href' && url.startsWith('moi:')) { try { - return resolveWorkspaceHref(workspaceId, url, base) + return resolveUrl(url, { + apiBase: `/api/workspaces/${encodeURIComponent(workspaceId)}`, + workspacePath: workspacePath(workspaceId, base) + }) } catch { return '' } diff --git a/client/features/chat/sessions/browser-tab-state.ts b/client/features/chat/sessions/browser-tab-state.ts new file mode 100644 index 00000000..03160519 --- /dev/null +++ b/client/features/chat/sessions/browser-tab-state.ts @@ -0,0 +1,18 @@ +const keyFor = (workspaceId: string) => `moi:collab:${workspaceId}:session` + +export function readSelectedSession(workspaceId: string): string | null { + try { + return sessionStorage.getItem(keyFor(workspaceId)) + } catch { + return null + } +} + +export function writeSelectedSession(workspaceId: string, sessionId: string | null): void { + try { + if (sessionId === null) sessionStorage.removeItem(keyFor(workspaceId)) + else sessionStorage.setItem(keyFor(workspaceId), sessionId) + } catch { + /* A private browser can still keep the active query in memory. */ + } +} diff --git a/client/features/chat/sessions/useSelectedSession.ts b/client/features/chat/sessions/useSelectedSession.ts index c1ed38d2..8d07adea 100644 --- a/client/features/chat/sessions/useSelectedSession.ts +++ b/client/features/chat/sessions/useSelectedSession.ts @@ -1,3 +1,8 @@ +import { useCollabEnabled } from '@/client/features/collab' +import { + readSelectedSession, + writeSelectedSession +} from '@/client/features/chat/sessions/browser-tab-state' import { useCallback, useMemo } from 'react' import { useIsMutating, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' @@ -8,7 +13,7 @@ import { jsonRequest, requestJson } from '@/client/api/http' import { toast } from '@/client/components/ui/toast' import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' import { useWorkspaceEvent } from '@/client/runtime/useWorkspaceEvents' -import type { SelectedSessionState } from '@/lib/types' +import type { SelectedSessionScope, SelectedSessionState } from '@/lib/types' type SaveSelectedSessionInput = { sessionId: string | null @@ -24,16 +29,22 @@ type SelectedSessionResult = readonly [ export type SelectedSessionSaveResult = 'applied' | 'conflict' | 'ignored' -function selectedSessionMutationKey(workspaceId: string) { - return [...appUiKeys.selectedSession(workspaceId), 'save'] as const +export function selectedSessionKey(workspaceId: string, scope: SelectedSessionScope = 'shared') { + const key = appUiKeys.selectedSession(workspaceId) + return scope === 'browser-tab' ? ([...key, 'browser-tab'] as const) : key +} + +function selectedSessionMutationKey(workspaceId: string, scope: SelectedSessionScope) { + return [...selectedSessionKey(workspaceId, scope), 'save'] as const } export function optimisticallySetSelectedSession( queryClient: QueryClient, workspaceId: string, - sessionId: string | null + sessionId: string | null, + scope: SelectedSessionScope = 'shared' ): SaveSelectedSessionInput | null { - const queryKey = appUiKeys.selectedSession(workspaceId) + const queryKey = selectedSessionKey(workspaceId, scope) const current = queryClient.getQueryData(queryKey) const previousSessionId = current?.sessionId ?? null if (current && previousSessionId === sessionId) return null @@ -46,9 +57,10 @@ export function settleSelectedSessionSave( queryClient: QueryClient, workspaceId: string, saved: SelectedSessionState, - input: SaveSelectedSessionInput + input: SaveSelectedSessionInput, + scope: SelectedSessionScope = 'shared' ): SelectedSessionSaveResult { - const queryKey = appUiKeys.selectedSession(workspaceId) + const queryKey = selectedSessionKey(workspaceId, scope) const current = queryClient.getQueryData(queryKey) if (saved.sessionId !== input.sessionId && current?.sessionId !== saved.sessionId) { @@ -80,24 +92,36 @@ export function renameSelectedSessionInCache( from: string, to: string ): void { - queryClient.setQueryData(appUiKeys.selectedSession(workspaceId), current => - current?.sessionId === from ? { sessionId: to } : current - ) + for (const scope of ['shared', 'browser-tab'] as const) { + queryClient.setQueryData( + selectedSessionKey(workspaceId, scope), + current => (current?.sessionId === from ? { sessionId: to } : current) + ) + } + if (readSelectedSession(workspaceId) === from) writeSelectedSession(workspaceId, to) } export function useSelectedSession(): SelectedSessionResult { const workspaceId = useWorkspaceId() + const collabEnabled = useCollabEnabled() + const scope: SelectedSessionScope = collabEnabled ? 'browser-tab' : 'shared' const queryClient = useQueryClient() - const queryKey = useMemo(() => appUiKeys.selectedSession(workspaceId), [workspaceId]) - const mutationKey = useMemo(() => selectedSessionMutationKey(workspaceId), [workspaceId]) + const queryKey = useMemo(() => selectedSessionKey(workspaceId, scope), [workspaceId, scope]) + const mutationKey = useMemo( + () => selectedSessionMutationKey(workspaceId, scope), + [workspaceId, scope] + ) const pendingSaves = useIsMutating({ mutationKey, exact: true }) - // WorkspaceLoader remains an observer for the active workspace. Nested hook + // WorkspaceContent remains an observer for the active workspace. Nested hook // users reuse its result without refetching; once the route unmounts, dropping // the cache makes the next visit load the server-owned selection again. const query = useQuery({ queryKey, - queryFn: () => requestJson(`/api/workspaces/${workspaceId}/selected-session`), + queryFn: () => + scope === 'browser-tab' + ? Promise.resolve({ sessionId: readSelectedSession(workspaceId) }) + : requestJson(`/api/workspaces/${workspaceId}/selected-session`), staleTime: Infinity, gcTime: 0, refetchOnMount: false, @@ -111,14 +135,19 @@ export function useSelectedSession(): SelectedSessionResult { >({ mutationKey, scope: { id: `selected-session:${workspaceId}` }, - mutationFn: input => - requestJson( + mutationFn: input => { + if (scope === 'browser-tab') { + writeSelectedSession(workspaceId, input.sessionId) + return Promise.resolve({ sessionId: input.sessionId }) + } + return requestJson( `/api/workspaces/${workspaceId}/selected-session`, jsonRequest('PUT', input), 'Couldn’t save selected chat' - ), + ) + }, onSuccess: (saved, input) => { - const result = settleSelectedSessionSave(queryClient, workspaceId, saved, input) + const result = settleSelectedSessionSave(queryClient, workspaceId, saved, input, scope) if (result !== 'conflict') return toast.add({ title: 'Couldn’t save selected chat', type: 'error' }) @@ -135,14 +164,19 @@ export function useSelectedSession(): SelectedSessionResult { const setSelectedSessionId = useCallback( sessionId => { - const input = optimisticallySetSelectedSession(queryClient, workspaceId, sessionId) + const input = optimisticallySetSelectedSession(queryClient, workspaceId, sessionId, scope) if (input) saveSelectedSession(input) }, - [queryClient, saveSelectedSession, workspaceId] + [queryClient, saveSelectedSession, workspaceId, scope] ) useWorkspaceEvent(event => { - if (event.type !== 'selected-session:updated' || event.workspaceId !== workspaceId) return + if ( + scope === 'browser-tab' || + event.type !== 'selected-session:updated' || + event.workspaceId !== workspaceId + ) + return applySelectedSessionEvent(queryClient, workspaceId, event.sessionId, pendingSaves > 0) }) diff --git a/client/features/chat/useChat.ts b/client/features/chat/useChat.ts index 4fae69e0..71b16f04 100644 --- a/client/features/chat/useChat.ts +++ b/client/features/chat/useChat.ts @@ -10,6 +10,7 @@ import { } from '@/client/features/chat/sessions/api' import { useWorkspaceAgent } from '@/client/features/workspace/api' import { useSelectedSession } from '@/client/features/chat/sessions/useSelectedSession' +import { useCollabEnabled } from '@/client/features/collab' import { type WorkspaceTabAddress, useMoiUserMessageContext @@ -53,6 +54,7 @@ export function useChat(address: WorkspaceTabAddress) { const qc = useQueryClient() const { layout } = useWorkspaceLayoutCtx() const [selectedSession, selectSession] = useSelectedSession() + const collabEnabled = useCollabEnabled() const modelsData = useWorkspaceAgent(workspaceId).data const sessions = useWorkspaceSessions(workspaceId).data const selectedSessionMissing = @@ -173,6 +175,7 @@ export function useChat(address: WorkspaceTabAddress) { content: text, sessionId: sid, isNew, + selectedSessionScope: collabEnabled ? 'browser-tab' : 'shared', optimisticId, model, effort, @@ -205,6 +208,7 @@ export function useChat(address: WorkspaceTabAddress) { sessionConfig?.effort, sessionConfig?.fastMode, selectSession, + collabEnabled, modelsData ] ) diff --git a/client/features/collab/applet-api.ts b/client/features/collab/applet-api.ts new file mode 100644 index 00000000..e85c3ea1 --- /dev/null +++ b/client/features/collab/applet-api.ts @@ -0,0 +1,34 @@ +import type * as CollabApi from 'moi/collab' +import { UserAvatarGroup } from './components/user-avatar-group' +import { User } from './components/user' +import { UserAvatar } from './components/user-avatar' +import { Cursors } from './components/cursors' +import { PresenceFrame } from './components/presence-frame' +import { PresenceGroup } from './components/presence-group' +import { PresenceAvatars } from './components/presence-avatars' +import { + useMe, + usePeers, + usePresence, + usePublishPresence, + useUser, + useWorkspaceUsers +} from './hooks' + +export type AppletCollabApi = typeof CollabApi + +export const appletCollabApi = { + Cursors, + User, + UserAvatar, + UserAvatarGroup, + PresenceFrame, + PresenceAvatars, + PresenceGroup, + useMe, + usePeers, + useUser, + useWorkspaceUsers, + usePresence, + usePublishPresence +} satisfies AppletCollabApi diff --git a/client/features/collab/client.test.ts b/client/features/collab/client.test.ts new file mode 100644 index 00000000..3fd54434 --- /dev/null +++ b/client/features/collab/client.test.ts @@ -0,0 +1,343 @@ +import { afterEach, beforeEach, expect, spyOn, test } from 'bun:test' + +import { colorForId } from '@/lib/collab/colors' +import { COLLAB_MAX_CONNECTIONS, isCollabClientMessage } from '@/lib/collab/protocol' +import type { CollabClientMessage } from '@/lib/collab/types' +import { CollabService } from '@/server/collab/service' + +import { CollabClient, NO_ENGINE } from './client' +import { getCurrentUser, setHostState } from './host-state' + +const originalLocation = Object.getOwnPropertyDescriptor(globalThis, 'location') +const originalWebSocket = Object.getOwnPropertyDescriptor(globalThis, 'WebSocket') +let service: CollabService +let sockets: TestSocket[] +let stop: (() => void) | undefined + +class TestSocket { + static OPEN = 1 + readyState = 0 + onopen: (() => void) | null = null + onmessage: ((event: { data: string }) => void) | null = null + onclose: (() => void) | null = null + onerror: (() => void) | null = null + readonly sent: CollabClientMessage[] = [] + readonly id: string + constructor(readonly url: string) { + this.id = `connection-${sockets.length}` + sockets.push(this) + } + open() { + this.readyState = TestSocket.OPEN + this.onopen?.() + } + send(data: string) { + const message: unknown = JSON.parse(data) + if (!isCollabClientMessage(message)) throw new Error('Invalid collab message') + this.sent.push(message) + try { + service.receive(this.id, message) + } catch (error) { + // Match the worker: service failures arrive as messages on an open socket. + this.onmessage?.({ + data: JSON.stringify({ + type: 'error', + code: 'invalid_request', + message: error instanceof Error ? error.message : 'Collab request failed' + }) + }) + } + } + close() { + this.readyState = 3 + service.leave(this.id) + this.onclose?.() + } +} + +beforeEach(() => { + setHostState({ currentUser: undefined, workspaces: {} }) + sockets = [] + service = new CollabService((connectionId, message) => { + sockets + .find(socket => socket.id === connectionId) + ?.onmessage?.({ data: JSON.stringify(message) }) + }) + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: { protocol: 'http:', host: 'localhost' } + }) + Object.defineProperty(globalThis, 'WebSocket', { configurable: true, value: TestSocket }) +}) + +afterEach(() => { + stop?.() + stop = undefined + service.close() + setHostState({ currentUser: undefined, workspaces: {} }) + if (originalLocation) Object.defineProperty(globalThis, 'location', originalLocation) + else Reflect.deleteProperty(globalThis, 'location') + if (originalWebSocket) Object.defineProperty(globalThis, 'WebSocket', originalWebSocket) + else Reflect.deleteProperty(globalThis, 'WebSocket') +}) + +test('runtime without a current user stays idle and never joins anonymously', () => { + const engine = new CollabClient('workspace') + stop = engine.start() + engine.setLocation({ page: 'views/board', status: 'active' }) + engine.setPresence({ + registrationId: 'field', + appletId: 'views/board', + channel: 'focus', + value: 'title' + }) + expect(sockets).toHaveLength(0) + expect(getCurrentUser()).toBeUndefined() + expect(engine.getSnapshot().status).toBe('disconnected') +}) + +test('an explicit user enables workspace presence without an applet and clearing it disconnects', () => { + const engine = new CollabClient('workspace') + stop = engine.start() + const profile = { id: 'alice', name: 'Alice', color: 'emerald' } as const + setHostState({ currentUser: profile, workspaces: {} }) + expect(sockets).toHaveLength(1) + const socket = sockets[0]! + socket.open() + expect(socket.sent[0]).toMatchObject({ type: 'join', profile }) + expect(engine.getSnapshot().connections[0]?.userId).toEqual(profile.id) + setHostState({ currentUser: { ...profile, name: 'Alicia' }, workspaces: {} }) + expect(socket.sent.at(-1)).toMatchObject({ type: 'profile', profile: { name: 'Alicia' } }) + expect(sockets).toHaveLength(1) + setHostState({ currentUser: undefined, workspaces: {} }) + expect(socket.readyState).toBe(3) + expect(engine.getSnapshot().status).toBe('disconnected') +}) + +test('two tabs send the same resolved color for a host user without one', () => { + setHostState({ currentUser: { id: 'alice' }, workspaces: {} }) + const first = new CollabClient('workspace') + const second = new CollabClient('workspace') + stop = first.start() + const stopSecond = second.start() + try { + expect(sockets).toHaveLength(2) + sockets.forEach(socket => socket.open()) + for (const socket of sockets) { + expect(socket.sent[0]).toMatchObject({ + type: 'join', + profile: { id: 'alice', color: colorForId('alice') } + }) + } + } finally { + stopSecond() + } +}) + +test('reconnecting restores current presence and removed registrations stay gone', async () => { + setHostState({ currentUser: { id: 'alice', name: 'Alice', color: 'emerald' }, workspaces: {} }) + const engine = new CollabClient('workspace') + stop = engine.start() + sockets[0]!.open() + engine.setPresence({ + registrationId: 'kept', + appletId: 'views/board', + channel: 'focus', + value: true + }) + engine.setPresence({ + registrationId: 'removed', + appletId: 'views/board', + channel: 'focus', + value: true + }) + engine.deletePresence('removed') + sockets[0]!.close() + await Bun.sleep(550) + sockets[1]!.open() + await Bun.sleep(70) + expect(sockets[1]!.sent.filter(message => message.type === 'presence:set')).toEqual([ + { + type: 'presence:set', + registrationId: 'kept', + appletId: 'views/board', + channel: 'focus', + value: true + } + ]) + expect(sockets[1]!.sent[0]).toMatchObject({ type: 'join', version: 1 }) +}) + +test('a rejected join retries after capacity frees and cleanup cancels pending work', async () => { + for (let index = 0; index < COLLAB_MAX_CONNECTIONS; index++) { + service.receive(`occupied-${index}`, { + type: 'join', + version: 1, + profile: { id: `occupied-${index}`, color: 'blue' } + }) + } + setHostState({ currentUser: { id: 'alice', color: 'emerald' }, workspaces: {} }) + const heartbeat = spyOn(globalThis, 'setInterval') + const clearHeartbeat = spyOn(globalThis, 'clearInterval') + const timer = spyOn(globalThis, 'setTimeout') + const clearTimer = spyOn(globalThis, 'clearTimeout') + try { + const client = new CollabClient('workspace') + stop = client.start() + const rejected = sockets[0]! + rejected.open() + expect(rejected.readyState).toBe(3) + expect(client.getSnapshot().status).toBe('disconnected') + expect(clearHeartbeat).toHaveBeenCalledWith(heartbeat.mock.results.at(-1)!.value) + + service.leave('occupied-0') + await Bun.sleep(550) + expect(sockets).toHaveLength(2) + const recovered = sockets[1]! + recovered.open() + expect(client.getSnapshot().status).toBe('connected') + expect(recovered.sent[0]).toMatchObject({ type: 'join', profile: { id: 'alice' } }) + + client.setPresence({ + registrationId: 'queued', + appletId: 'views/board', + channel: 'focus', + value: true + }) + const publicationTimer = timer.mock.results.at(-1)!.value + stop() + expect(clearTimer).toHaveBeenCalledWith(publicationTimer) + expect(clearHeartbeat).toHaveBeenCalledWith(heartbeat.mock.results.at(-1)!.value) + const sent = recovered.sent.length + await Bun.sleep(550) + expect(recovered.sent).toHaveLength(sent) + expect(sockets).toHaveLength(2) + + stop = client.start() + sockets[2]!.open() + sockets[2]!.close() + const retryTimer = timer.mock.results.at(-1)!.value + stop() + expect(clearTimer).toHaveBeenCalledWith(retryTimer) + await Bun.sleep(550) + expect(sockets).toHaveLength(3) + } finally { + stop?.() + heartbeat.mockRestore() + clearHeartbeat.mockRestore() + timer.mockRestore() + clearTimer.mockRestore() + } +}) + +test('errors after joining keep the connection available for further presence', () => { + setHostState({ currentUser: { id: 'alice', color: 'emerald' }, workspaces: {} }) + const client = new CollabClient('workspace') + stop = client.start() + const socket = sockets[0]! + socket.open() + socket.onmessage?.({ + data: JSON.stringify({ type: 'error', code: 'invalid_request', message: 'Rejected update' }) + }) + expect(socket.readyState).toBe(TestSocket.OPEN) + expect(client.getSnapshot()).toMatchObject({ status: 'connected', error: 'Rejected update' }) + client.setLocation({ page: 'views/board', status: 'active' }) + expect(socket.sent.at(-1)).toEqual({ + type: 'location', + location: { page: 'views/board', status: 'active' } + }) +}) + +test('profile edits publish immediately without changing the user id or reconnecting', () => { + const alice = { id: 'alice', name: 'Alice', color: 'emerald' } as const + setHostState({ currentUser: alice, workspaces: {} }) + const engine = new CollabClient('workspace') + stop = engine.start() + sockets[0]!.open() + const edited = { ...alice, name: 'Ada Lovelace', color: 'cyan' } as const + setHostState({ currentUser: edited, workspaces: {} }) + expect(sockets).toHaveLength(1) + expect(sockets[0]!.readyState).toBe(TestSocket.OPEN) + expect(sockets[0]!.sent.at(-1)).toEqual({ type: 'profile', profile: edited }) + expect(engine.getSnapshot().users).toEqual([edited]) +}) + +test('changing user reconnects and never sends the host directory over the socket', async () => { + setHostState({ currentUser: { id: 'alice', name: 'Alice', color: 'emerald' }, workspaces: {} }) + const engine = new CollabClient('workspace') + stop = engine.start() + sockets[0]!.open() + setHostState({ currentUser: { id: 'bob', name: 'Bob', color: 'blue' }, workspaces: {} }) + expect(sockets[0]!.readyState).toBe(3) + await Bun.sleep(550) + sockets[1]!.open() + expect(sockets[1]!.sent[0]).toMatchObject({ type: 'join', profile: { id: 'bob' } }) + expect(sockets[1]!.sent.every(message => !('users' in message))).toBe(true) +}) + +test('disabled engine exposes host profiles and readiness without starting a transport', () => { + const alice = { id: 'alice', name: 'Alice', color: 'emerald' } as const + setHostState({ + currentUser: alice, + workspaces: { workspace: { status: 'loading' } } + }) + const engine = new CollabClient('workspace', false) + stop = engine.start() + expect(engine.enabled).toBe(false) + expect(engine.workspaceId).toBe('workspace') + expect(engine.getCurrentUser()).toEqual(alice) + expect(engine.getWorkspaceDirectory()).toEqual({ status: 'loading' }) + let updates = 0 + const unsubscribe = engine.subscribeWorkspaceUsers(() => updates++) + setHostState({ + currentUser: alice, + workspaces: { workspace: { status: 'ready', users: [alice] } } + }) + expect(engine.getWorkspaceDirectory()).toEqual({ status: 'ready', users: [alice] }) + expect(updates).toBe(1) + engine.setLocation({ page: 'overview', status: 'active' }) + engine.setPresence({ + registrationId: 'focus', + appletId: 'views/board', + channel: 'field:title', + value: true + }) + engine.deletePresence('focus') + expect(sockets).toHaveLength(0) + expect(engine.getSnapshot()).toMatchObject({ status: 'disconnected', connections: [] }) + expect(engine.getUsersSnapshot()).toBe(engine.getSnapshot()) + expect(engine.getPresenceSnapshot('views/board', 'field:title')).toEqual([]) + unsubscribe() +}) + +test('engine cleanup survives remounting and does not leave user listeners or retry sockets', async () => { + const alice = { id: 'alice', name: 'Alice', color: 'emerald' } as const + setHostState({ currentUser: alice, workspaces: {} }) + const engine = new CollabClient('workspace') + stop = engine.start() + engine.start()() + sockets[0]!.open() + setHostState({ currentUser: { ...alice, name: 'Alicia' }, workspaces: {} }) + expect(sockets[0]!.sent.filter(message => message.type === 'profile')).toHaveLength(1) + stop() + setHostState({ currentUser: { ...alice, name: 'Alice again' }, workspaces: {} }) + await Bun.sleep(550) + expect(sockets).toHaveLength(1) + stop = engine.start() + expect(sockets).toHaveLength(2) + sockets[1]!.open() + expect(engine.getSnapshot().status).toBe('connected') +}) + +test('the default engine keeps the current user readable while workspace operations stay inert', () => { + const alice = { id: 'alice', name: 'Alice', color: 'emerald' } as const + setHostState({ currentUser: alice, workspaces: {} }) + expect(NO_ENGINE.getCurrentUser()).toEqual(alice) + expect(NO_ENGINE.getWorkspaceDirectory()).toBeUndefined() + expect(NO_ENGINE.getPresenceSnapshot('one', 'field')).toBe( + NO_ENGINE.getPresenceSnapshot('two', 'cursor') + ) + NO_ENGINE.start()() + NO_ENGINE.setLocation({ page: 'overview', status: 'active' }) + expect(sockets).toHaveLength(0) +}) diff --git a/client/features/collab/client.ts b/client/features/collab/client.ts new file mode 100644 index 00000000..467a8f52 --- /dev/null +++ b/client/features/collab/client.ts @@ -0,0 +1,239 @@ +import { wsUrl } from '@/client/lib/ws-url' +import { COLLAB_PROTOCOL_VERSION } from '@/lib/collab/protocol' +import type { + CollabClientMessage, + CollabServerMessage, + UserProfile, + ConnectionLocation, + PresenceRegistration +} from '@/lib/collab/types' + +import { + getCurrentUser, + getWorkspaceDirectory, + subscribeCurrentUserStore, + subscribeWorkspaceUsersStore +} from './host-state' +import type { WorkspaceDirectory } from './host-state' +import { CollabStore, DISCONNECTED_STATE } from './store' +import type { ClientState, PresenceEntry } from './store' + +type Unsubscribe = () => void + +// A workspace exposes one engine to React. The playground implements this same +// contract; sockets, timers, and registration storage stay behind it. +export type CollabEngineApi = { + readonly workspaceId: string + readonly enabled: boolean + start: () => Unsubscribe + getSnapshot: () => ClientState + getUsersSnapshot: () => ClientState + getPresenceSnapshot: (appletId: string, channel: string) => readonly PresenceEntry[] + subscribe: (listener: () => void) => Unsubscribe + getCurrentUser: () => UserProfile | undefined + subscribeCurrentUser: (listener: () => void) => Unsubscribe + getWorkspaceDirectory: () => WorkspaceDirectory | undefined + subscribeWorkspaceUsers: (listener: () => void) => Unsubscribe + getLocation: () => ConnectionLocation | null + setLocation: (location: ConnectionLocation | null) => void + setPresence: (registration: PresenceRegistration) => void + deletePresence: (registrationId: string) => void +} + +const noop = () => {} +const EMPTY_PRESENCE: readonly PresenceEntry[] = [] + +export const NO_ENGINE: CollabEngineApi = { + workspaceId: '', + enabled: false, + start: () => noop, + getSnapshot: () => DISCONNECTED_STATE, + getUsersSnapshot: () => DISCONNECTED_STATE, + getPresenceSnapshot: () => EMPTY_PRESENCE, + subscribe: () => noop, + getCurrentUser, + subscribeCurrentUser: subscribeCurrentUserStore, + getWorkspaceDirectory: () => undefined, + subscribeWorkspaceUsers: () => noop, + getLocation: () => null, + setLocation: noop, + setPresence: noop, + deletePresence: noop +} + +// One presence connection per mounted workspace with an explicit profile. +export class CollabClient implements CollabEngineApi { + readonly store = new CollabStore() + private socket: WebSocket | null = null + private retry: ReturnType | undefined + private heartbeat: ReturnType | undefined + private unsubscribeCurrentUser: (() => void) | undefined + private presenceTimer: ReturnType | undefined + private queuedPresence = new Map() + private stopped = true + private attempts = 0 + private lastMessageAt = 0 + private userId = getCurrentUser()?.id + + constructor( + readonly workspaceId: string, + readonly enabled = true + ) { + this.store.setSender(message => this.send(message)) + } + + getSnapshot = (): ClientState => (this.enabled ? this.store.getSnapshot() : DISCONNECTED_STATE) + getUsersSnapshot = (): ClientState => + this.enabled ? this.store.getUsersSnapshot() : DISCONNECTED_STATE + getPresenceSnapshot = (appletId: string, channel: string): readonly PresenceEntry[] => + this.enabled ? this.store.getPresenceSnapshot(appletId, channel) : EMPTY_PRESENCE + subscribe = (listener: () => void): Unsubscribe => + this.enabled ? this.store.subscribe(listener) : noop + getCurrentUser = getCurrentUser + subscribeCurrentUser = subscribeCurrentUserStore + getWorkspaceDirectory = (): WorkspaceDirectory | undefined => + getWorkspaceDirectory(this.workspaceId) + subscribeWorkspaceUsers = (listener: () => void): Unsubscribe => + subscribeWorkspaceUsersStore(this.workspaceId, listener) + getLocation = (): ConnectionLocation | null => (this.enabled ? this.store.getLocation() : null) + setLocation = (location: ConnectionLocation | null): void => { + this.store.setLocation(location) + } + setPresence = (registration: PresenceRegistration): void => { + if (this.enabled) this.store.setPresence(registration) + } + deletePresence = (registrationId: string): void => { + if (this.enabled) this.store.deletePresence(registrationId) + } + + start(): () => void { + if (!this.enabled) return noop + if (!this.stopped) return () => {} + this.stopped = false + this.userId = getCurrentUser()?.id + this.unsubscribeCurrentUser = subscribeCurrentUserStore(() => { + const profile = getCurrentUser() + if (profile?.id !== this.userId) { + this.userId = profile?.id + this.store.disconnect() + clearTimeout(this.retry) + if (this.socket) this.socket.close() + else this.connect() + } else if (profile) this.send({ type: 'profile', profile }) + }) + this.connect() + return () => this.stop() + } + + private wantsConnection(): boolean { + return !this.stopped && getCurrentUser() !== undefined + } + + private connect(): void { + if (!this.wantsConnection()) { + this.store.disconnect() + return + } + if (this.socket) return + clearTimeout(this.retry) + this.store.connecting() + const socket = new WebSocket( + wsUrl(`/api/workspaces/${encodeURIComponent(this.workspaceId)}/collab/ws`) + ) + this.socket = socket + socket.onopen = () => { + if (socket !== this.socket) return + this.lastMessageAt = Date.now() + const profile = getCurrentUser() + if (!profile) { + socket.close() + return + } + this.heartbeat = setInterval(() => { + if (Date.now() - this.lastMessageAt > 45_000) socket.close() + else this.rawSend({ type: 'ping' }) + }, 15_000) + this.rawSend({ + type: 'join', + version: COLLAB_PROTOCOL_VERSION, + profile, + location: this.store.getLocation() + }) + } + socket.onmessage = event => { + if (socket !== this.socket) return + this.lastMessageAt = Date.now() + try { + const message = JSON.parse(String(event.data)) as CollabServerMessage + if (message.type === 'welcome') this.attempts = 0 + const rejectedJoin = + message.type === 'error' && this.store.getSnapshot().status !== 'connected' + this.store.receive(message) + if (rejectedJoin) socket.close() + } catch { + this.store.disconnect('The collaboration connection returned an invalid message.') + socket.close() + } + } + socket.onerror = () => socket.close() + socket.onclose = () => { + if (socket !== this.socket) return + clearInterval(this.heartbeat) + clearTimeout(this.presenceTimer) + this.presenceTimer = undefined + this.queuedPresence.clear() + this.socket = null + this.store.disconnect() + if (this.wantsConnection()) { + const delay = Math.min(500 * 2 ** this.attempts++, 10_000) + this.retry = setTimeout(() => this.connect(), delay) + } + } + } + + private send(message: CollabClientMessage): void { + if ( + !getCurrentUser() && + (message.type === 'location' || + message.type === 'presence:set' || + message.type === 'presence:delete') + ) + return + if (message.type === 'presence:set') { + this.queuedPresence.set(message.registrationId, message) + if (!this.presenceTimer) { + this.presenceTimer = setTimeout(() => { + this.presenceTimer = undefined + for (const update of this.queuedPresence.values()) this.rawSend(update) + this.queuedPresence.clear() + }, 50) + } + return + } + if (message.type === 'presence:delete') this.queuedPresence.delete(message.registrationId) + this.rawSend(message) + } + + private rawSend(message: CollabClientMessage): void { + if (this.socket?.readyState === WebSocket.OPEN) { + try { + this.socket.send(JSON.stringify(message)) + } catch { + this.socket.close() + } + } + } + + private stop(): void { + this.stopped = true + this.unsubscribeCurrentUser?.() + clearTimeout(this.retry) + clearTimeout(this.presenceTimer) + this.presenceTimer = undefined + clearInterval(this.heartbeat) + this.queuedPresence.clear() + this.socket?.close() + this.socket = null + this.store.disconnect() + } +} diff --git a/client/features/collab/components/cursors.test.tsx b/client/features/collab/components/cursors.test.tsx new file mode 100644 index 00000000..5869160e --- /dev/null +++ b/client/features/collab/components/cursors.test.tsx @@ -0,0 +1,48 @@ +import { expect, test } from 'bun:test' +import type { JsonValue } from 'moi' +import { renderToStaticMarkup } from 'react-dom/server' + +import { Cursors } from './cursors' +import { presenceChannels } from '../hooks' +import { createRoom, namelessUsers, renderCollab } from '../testing/component-fixtures' + +test('remote cursors resolve a usable label for nameless user', () => { + const [profile, label] = namelessUsers[0]! + const engine = createRoom(profile, [ + { + registrationId: 'cursor', + appletId: 'views/board', + channel: presenceChannels.cursor('board'), + value: { x: 10, y: 20 } + } + ]) + expect(renderCollab(engine, Board)).toContain(`>${label}<`) +}) + +test('cursors retain content outside a collaboration provider', () => { + const html = renderToStaticMarkup(Local content) + expect(html).toContain('Local content') +}) + +test('remote cursors ignore invalid coordinates and presence for another area', () => { + const cases = [ + [{ x: 10, y: 20 }, 'board', true], + [{ x: 10, y: 20 }, 'other-area', false], + [null, 'board', false], + [{ x: '10', y: 20 }, 'board', false], + [{ x: Infinity, y: 20 }, 'board', false] + ] satisfies Array<[JsonValue, string, boolean]> + for (const [value, area, visible] of cases) { + const room = createRoom(undefined, [ + { + registrationId: 'cursor', + appletId: 'views/board', + channel: presenceChannels.cursor(area), + value + } + ]) + const html = renderCollab(room, Board) + expect(html.includes('>Ada<')).toBe(visible) + expect(html).toContain('Board') + } +}) diff --git a/client/features/collab/components/cursors.tsx b/client/features/collab/components/cursors.tsx new file mode 100644 index 00000000..91c79f96 --- /dev/null +++ b/client/features/collab/components/cursors.tsx @@ -0,0 +1,199 @@ +import type { CursorsProps } from 'moi/collab' +import type { JsonValue } from 'moi' +import { useLayoutEffect, useRef } from 'react' +import type { PointerEvent, Ref, RefObject } from 'react' +import { IconPointer2 } from '@tabler/icons-react' + +import { cn } from '@/client/lib/cn' +import { Badge } from '@/ui-components/badge' + +import { presenceChannels, usePresenceChannel, usePresencePublisher, useUser } from '../hooks' +import { userDisplayName } from '../users' +import { hasPresence } from './presence-helpers' + +type PointerPosition = { x: number; y: number; target?: string; targetX?: number; targetY?: number } +function pointerPosition(value: JsonValue): PointerPosition | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + if (typeof value.x !== 'number' || typeof value.y !== 'number') return null + if (!Number.isFinite(value.x) || !Number.isFinite(value.y)) return null + return { + x: value.x, + y: value.y, + ...(typeof value.target === 'string' ? { target: value.target } : {}), + ...(typeof value.targetX === 'number' && Number.isFinite(value.targetX) + ? { targetX: value.targetX } + : {}), + ...(typeof value.targetY === 'number' && Number.isFinite(value.targetY) + ? { targetY: value.targetY } + : {}) + } +} + +export function Cursors({ id = 'default', children, className }: CursorsProps) { + const root = useRef(null) + const layer = useRef(null) + // Keep the overlay in the visible viewport when the cursor area itself scrolls. + useLayoutEffect(() => { + const element = root.current + const overlay = layer.current + if (!element || !overlay) return + const position = () => { + overlay.style.transform = `translate(${element.scrollLeft}px, ${element.scrollTop}px)` + } + position() + element.addEventListener('scroll', position) + return () => element.removeEventListener('scroll', position) + }, []) + const channel = presenceChannels.cursor(id) + const cursors = usePresenceChannel(channel) + const publish = usePresencePublisher(channel, null, hasPresence) + const byConnection = new Map() + for (const { connectionId, userId, value } of cursors) { + const point = pointerPosition(value) + if (point) byConnection.set(connectionId, { id: userId, point }) + } + const move = (event: PointerEvent) => { + if (event.pointerType === 'touch') return + const element = root.current + if ( + !element || + !(event.target instanceof Element) || + event.target.closest('[data-collab-cursors]') !== element + ) + return + const bounds = element.getBoundingClientRect() + const target = + event.target instanceof Element + ? event.target.closest('[data-collab-target]') + : null + const anchor = target && element.contains(target) ? target : null + const rect = anchor?.getBoundingClientRect() + publish({ + x: event.clientX - bounds.left - element.clientLeft + element.scrollLeft, + y: event.clientY - bounds.top - element.clientTop + element.scrollTop, + ...(anchor && rect + ? { + target: anchor.dataset.collabTarget ?? '', + targetX: rect.width ? (event.clientX - rect.left) / rect.width : 0, + targetY: rect.height ? (event.clientY - rect.top) / rect.height : 0 + } + : {}) + }) + } + return ( +
{ + if ( + event.target instanceof Element && + event.target.closest('[data-collab-cursors]') === event.currentTarget + ) + publish(null) + }} + > + {children} + +
+ ) +} + +type RemoteCursorProps = { + root: RefObject + point: PointerPosition + id: string +} +// Semantic anchors follow their own data through scrolling and reordering. +// A cursor on a different record's modal has no matching anchor and stays hidden. +function RemoteCursor({ root, point, id }: RemoteCursorProps) { + const marker = useRef(null) + useLayoutEffect(() => { + const element = root.current + const node = marker.current + if (!element || !node) return + const position = (animate = false) => { + let x = point.x - element.scrollLeft + let y = point.y - element.scrollTop + if (point.target) { + const target = [...element.querySelectorAll('[data-collab-target]')].find( + candidate => candidate.dataset.collabTarget === point.target + ) + if (!target) { + node.hidden = true + return + } + const bounds = element.getBoundingClientRect() + const rect = target.getBoundingClientRect() + x = rect.left - bounds.left - element.clientLeft + rect.width * (point.targetX ?? 0) + y = rect.top - bounds.top - element.clientTop + rect.height * (point.targetY ?? 0) + } + const transform = `translate(${x}px, ${y}px)` + if (node.hidden || node.style.transform !== transform) { + // Smooth pointer updates; place new cursors and layout corrections immediately. + node.dataset.moving = animate && !node.hidden ? 'true' : 'false' + node.style.transform = transform + } + node.hidden = false + } + position(true) + const reposition = () => position() + const observer = new ResizeObserver(reposition) + observer.observe(element) + const changes = new MutationObserver(reposition) + changes.observe(element, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['data-collab-target'] + }) + element.addEventListener('scroll', reposition, true) + window.addEventListener('resize', reposition) + return () => { + observer.disconnect() + changes.disconnect() + element.removeEventListener('scroll', reposition, true) + window.removeEventListener('resize', reposition) + } + }, [root, point]) + return +} + +type CursorProps = { + id: string + ref?: Ref +} +function Cursor({ id, ref }: CursorProps) { + const user = useUser(id) + return ( + + ) +} diff --git a/client/features/collab/components/presence-avatars.tsx b/client/features/collab/components/presence-avatars.tsx new file mode 100644 index 00000000..79ccef00 --- /dev/null +++ b/client/features/collab/components/presence-avatars.tsx @@ -0,0 +1,61 @@ +import type { PresenceAvatarsProps } from 'moi/collab' +import { useContext } from 'react' +import type { HTMLAttributes, ReactNode, Ref } from 'react' +import { motion } from 'motion/react' +import { cn } from '@/client/lib/cn' +import { PresenceGroupContext, usePresenceTarget, useTargetPresence } from './presence-helpers' +import { UserAvatar } from './user-avatar' + +export function PresenceAvatars({ id, present, align, children, className }: PresenceAvatarsProps) { + const target = usePresenceTarget(id) + const presence = useTargetPresence(target, present) + const grouped = useContext(PresenceGroupContext).length > 0 + return ( + + {children} + + ) +} + +type PresenceAvatarsPrimitiveProps = HTMLAttributes & { + ref?: Ref + users: readonly { id: string; connectionId: string }[] + children: ReactNode + animate?: boolean + align?: PresenceAvatarsProps['align'] +} + +// A target owns its own gutter. Optional group-scoped layout IDs let the same +// user's face glide between targets without searching or guessing DOM order. +function PresenceAvatarsPrimitive({ + users, + children, + animate = false, + align = 'center', + className, + ...rest +}: PresenceAvatarsPrimitiveProps) { + return ( +
+ {children} + +
+ ) +} diff --git a/client/features/collab/components/presence-frame.test.tsx b/client/features/collab/components/presence-frame.test.tsx new file mode 100644 index 00000000..19369040 --- /dev/null +++ b/client/features/collab/components/presence-frame.test.tsx @@ -0,0 +1,198 @@ +import { expect, test } from 'bun:test' +import { renderToStaticMarkup } from 'react-dom/server' +import { PresenceFrame, PresenceOutline } from './presence-frame' +import { PresenceAvatars } from './presence-avatars' +import { presenceTarget } from '../presence-target' +import { PresenceGroup } from './presence-group' +import { presenceChannels } from '../hooks' +import { + createFocusRoom, + me, + peer, + namelessUsers, + renderCollab +} from '../testing/component-fixtures' + +test('presence frames skip unresolved users and clear their identity after removal', () => { + const backend = createFocusRoom() + const frame = renderCollab( + backend, + + + + ) + expect(frame).toContain('Ada') + expect(frame).not.toContain('Unknown user') + backend.setUsers([me]) + const removed = renderCollab( + backend, + + + + ) + expect(removed).toContain(' { + for (const Wrapper of [PresenceFrame, PresenceAvatars]) { + const html = renderCollab( + createFocusRoom(), + + + , + false + ) + expect(html).toContain('Title') + expect(html).not.toContain('Ada') + } +}) + +test('presence-frame resolves a usable label for nameless user', () => { + const [profile, label] = namelessUsers[0]! + const engine = createFocusRoom(presenceTarget('task:42:title'), profile) + const html = renderCollab( + engine, + + + + ) + expect(html).toContain(`>${label}<`) +}) + +test('presence wrappers show peers only on the matching record', () => { + for (const Wrapper of [PresenceFrame, PresenceAvatars]) { + const backend = createFocusRoom() + expect( + renderCollab( + backend, + + + + ) + ).toContain('Ada') + expect( + renderCollab( + backend, + + + + ) + ).not.toContain('Ada') + } +}) + +test('presence-frame retains content outside a collaboration provider', () => { + const html = renderToStaticMarkup( + + + Local content + + + ) + expect(html).toContain('Local content') +}) + +test('automatic targets show remote presence across list and fragment children', () => { + for (const Wrapper of [PresenceFrame, PresenceAvatars]) { + const fields = [ + , + + ] + for (const children of [fields, <>{fields}]) { + const html = renderCollab(createFocusRoom(), {children}) + expect(html).toContain('Title') + expect(html).toContain('Notes') + expect(html).toContain('Ada') + } + } +}) + +test('presence wrappers reject blank IDs in automatic and controlled modes', () => { + for (const Wrapper of [PresenceFrame, PresenceAvatars]) { + for (const present of [undefined, false]) { + expect(() => + renderCollab( + createFocusRoom(), + + + + ) + ).toThrow('nonempty id') + } + } +}) + +test('controlled presence uses group scope while preserving flexible children', () => { + const engine = createFocusRoom() + engine.setOtherConnections([ + { + connectionId: 'remote', + userId: peer.id, + location: { page: 'board', status: 'active' }, + presence: [ + { + registrationId: 'selection', + appletId: 'views/board', + channel: presenceChannels.target(presenceTarget('tasks', '42')), + value: true + } + ] + } + ]) + const html = renderCollab( + engine, + <> + + + TitleDetails + + + + + Project + + + + ) + const targets = html.split('data-collab-target=') + expect(targets).toHaveLength(3) + expect(targets[1]).toStartWith('"tasks/42"') + expect(targets[1]).toContain('Ada') + expect(targets[1]).toContain('Title') + expect(targets[1]).toContain('Details') + expect(targets[2]).toStartWith('"projects/42"') + expect(targets[2]).not.toContain('Ada') +}) + +test('both appearances reflect remote controlled presence clearing', () => { + for (const Wrapper of [PresenceFrame, PresenceAvatars]) { + const engine = createFocusRoom() + for (const selected of [true, false]) { + engine.setOtherConnections([ + { + connectionId: 'remote', + userId: peer.id, + location: { page: 'board', status: 'active' }, + presence: [ + { + registrationId: 'selection', + appletId: 'views/board', + channel: presenceChannels.target(presenceTarget('42')), + value: selected + } + ] + } + ]) + const html = renderCollab( + engine, + + Task + + ) + expect(html.includes('Ada')).toBe(selected) + expect(html).toContain('Task') + } + } +}) diff --git a/client/features/collab/components/presence-frame.tsx b/client/features/collab/components/presence-frame.tsx new file mode 100644 index 00000000..e953cfd1 --- /dev/null +++ b/client/features/collab/components/presence-frame.tsx @@ -0,0 +1,126 @@ +import type { PresenceFrameProps } from 'moi/collab' +import { useLayoutEffect, useRef } from 'react' +import type { HTMLAttributes, ReactNode, Ref } from 'react' +import { cn } from '@/client/lib/cn' +import type { UserColor } from '@/lib/collab/colors' +import { Badge } from '@/ui-components/badge' +import { useUsers } from '../hooks' +import { userDisplayName } from '../users' +import { usePresenceTarget, useTargetPresence } from './presence-helpers' + +export function PresenceFrame({ id, present, align, children, className }: PresenceFrameProps) { + const target = usePresenceTarget(id) + const presence = useTargetPresence(target, present) + return ( + user.id)} + align={align} + className={className} + > + {children} + + ) +} + +type PresenceOutlineProps = HTMLAttributes & { + ref?: Ref + // Everyone at this element; the first user's color draws the frame. + ids: readonly string[] + children: ReactNode + align?: PresenceFrameProps['align'] +} + +// One visible element gets a frame matching its shape; other content gets one shared frame. +export function PresenceOutline({ + ids, + align, + children, + className, + ...rest +}: PresenceOutlineProps) { + const resolved = useUsers(ids).filter(user => user !== undefined) + return ( +
+ {/* Keep the overlay first so showing it never changes the content's spacing. */} + + {children} +
+ ) +} + +const FRAME_GAP = 4 +const FRAME_RADIUS = 6 + +const CORNERS = [ + 'borderTopLeftRadius', + 'borderTopRightRadius', + 'borderBottomRightRadius', + 'borderBottomLeftRadius' +] as const + +type FrameOutlineProps = { + names: string + color: UserColor | undefined + align?: PresenceFrameProps['align'] +} + +function FrameOutline({ names, color, align = 'end' }: FrameOutlineProps) { + const node = useRef(null) + // Refit after rendering and whenever the content resizes. + useLayoutEffect(() => { + const outline = node.current + const frame = outline?.parentElement + if (!names || !outline || !frame) return + const fit = () => { + // Ignore the overlay, hidden elements, and helpers outside normal flow. + const inside = [...frame.children].filter(child => { + if (child === outline) return false + const { display, position } = getComputedStyle(child) + return display !== 'none' && position !== 'absolute' && position !== 'fixed' + }) + const bareText = [...frame.childNodes].some( + child => child.nodeType === Node.TEXT_NODE && child.textContent?.trim() + ) + const only = inside.length === 1 && !bareText ? inside[0] : null + const subject = only instanceof HTMLElement ? only : null + const target = subject ?? frame + const bounds = target.getBoundingClientRect() + const origin = frame.getBoundingClientRect() + const box = outline.style + // Rectangles preserve fractions; absolute positions start inside the wrapper's border. + box.left = `${bounds.left - origin.left - frame.clientLeft + frame.scrollLeft - FRAME_GAP}px` + box.top = `${bounds.top - origin.top - frame.clientTop + frame.scrollTop - FRAME_GAP}px` + box.width = `${bounds.width + FRAME_GAP * 2}px` + box.height = `${bounds.height + FRAME_GAP * 2}px` + box.setProperty('--presence-target-width', `${bounds.width}px`) + // Expand one element's corners by the gap; otherwise use the default radius. + const shape = subject ? getComputedStyle(subject) : null + for (const corner of CORNERS) { + box[corner] = shape ? `calc(${shape[corner]} + ${FRAME_GAP}px)` : `${FRAME_RADIUS}px` + } + } + fit() + const observer = new ResizeObserver(fit) + observer.observe(frame) + for (const child of frame.children) if (child !== outline) observer.observe(child) + return () => observer.disconnect() + }) + return ( + + ) +} diff --git a/client/features/collab/components/presence-group.test.tsx b/client/features/collab/components/presence-group.test.tsx new file mode 100644 index 00000000..5d187a7a --- /dev/null +++ b/client/features/collab/components/presence-group.test.tsx @@ -0,0 +1,130 @@ +import { expect, test } from 'bun:test' +import { PresenceGroup } from './presence-group' +import { PresenceFrame } from './presence-frame' +import { PresenceAvatars } from './presence-avatars' +import { presenceTarget } from '../presence-target' +import { createFocusRoom, renderCollab } from '../testing/component-fixtures' + +test('grouped wrappers resolve remote focus by semantic ID regardless of child order', () => { + for (const Wrapper of [PresenceFrame, PresenceAvatars]) { + const backend = createFocusRoom(presenceTarget('task:42', 'title')) + const title = ( + + + + ) + const notes = ( + +