From a17b4e5f22c68b3271fb5d089917faeb3914ef75 Mon Sep 17 00:00:00 2001
From: Alexey Taktarov
Date: Tue, 15 Sep 2026 15:17:50 +0200
Subject: [PATCH 01/86] Add collab protocol and transactional shared storage
---
lib/collab/protocol.test.ts | 31 ++++
lib/collab/protocol.ts | 145 +++++++++++++++
lib/collab/types.ts | 113 ++++++++++++
server/collab/service.test.ts | 209 ++++++++++++++++++++++
server/collab/service.ts | 218 +++++++++++++++++++++++
server/collab/storage.test.ts | 300 +++++++++++++++++++++++++++++++
server/collab/storage.ts | 327 ++++++++++++++++++++++++++++++++++
7 files changed, 1343 insertions(+)
create mode 100644 lib/collab/protocol.test.ts
create mode 100644 lib/collab/protocol.ts
create mode 100644 lib/collab/types.ts
create mode 100644 server/collab/service.test.ts
create mode 100644 server/collab/service.ts
create mode 100644 server/collab/storage.test.ts
create mode 100644 server/collab/storage.ts
diff --git a/lib/collab/protocol.test.ts b/lib/collab/protocol.test.ts
new file mode 100644
index 00000000..8e65eac9
--- /dev/null
+++ b/lib/collab/protocol.test.ts
@@ -0,0 +1,31 @@
+import { expect, test } from 'bun:test'
+
+import { isCollabActor, isCollabClientMessage, isCollabIdentity } from './protocol'
+
+test('collab validates injected profiles as data and bounds identifiers and presence', () => {
+ const identity = { id: 'anna', name: 'Anna', color: '#7c3aed' }
+ expect(isCollabIdentity(identity)).toBe(true)
+ expect(isCollabClientMessage({ type: 'join', version: 1, identity })).toBe(true)
+ expect(isCollabClientMessage({ type: 'join', version: 2, identity })).toBe(false)
+ expect(isCollabIdentity({ ...identity, id: '🙂'.repeat(100) })).toBe(false)
+ expect(isCollabIdentity({ ...identity, id: 'a\0b' })).toBe(false)
+ expect(isCollabActor({ id: 'agent', kind: 'agent' })).toBe(true)
+ expect(isCollabActor({ id: 'agent', kind: 'admin' })).toBe(false)
+ expect(
+ isCollabClientMessage({
+ type: 'presence:set',
+ registrationId: 'field',
+ surface: 'board',
+ channel: 'focus',
+ value: 'x'.repeat(5000)
+ })
+ ).toBe(false)
+ expect(
+ isCollabClientMessage({
+ type: 'mutate',
+ scope: 'board',
+ operationId: 'op',
+ operations: [{ type: 'set', key: 'value', value: NaN }]
+ })
+ ).toBe(false)
+})
diff --git a/lib/collab/protocol.ts b/lib/collab/protocol.ts
new file mode 100644
index 00000000..687190fe
--- /dev/null
+++ b/lib/collab/protocol.ts
@@ -0,0 +1,145 @@
+import type {
+ CollabActor,
+ CollabClientMessage,
+ CollabCommand,
+ CollabIdentity,
+ CollabJsonValue,
+ CollabLocation,
+ CollabOperation
+} from './types'
+
+export const COLLAB_PROTOCOL_VERSION = 1
+export const COLLAB_MAX_MESSAGE_BYTES = 256 * 1024
+export const COLLAB_MAX_PRESENCE_BYTES = 4 * 1024
+export const COLLAB_MAX_AVATAR_BYTES = 8 * 1024
+
+export function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+export function isCollabString(value: unknown, max = 256): value is string {
+ return (
+ typeof value === 'string' &&
+ value.length > 0 &&
+ !value.includes('\0') &&
+ new TextEncoder().encode(value).length <= max
+ )
+}
+
+export function isCollabJson(value: unknown, depth = 0): value is CollabJsonValue {
+ if (depth > 24) return false
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return true
+ if (typeof value === 'number') return Number.isFinite(value)
+ if (Array.isArray(value)) return value.every(item => isCollabJson(item, depth + 1))
+ if (!isRecord(value)) return false
+ return Object.values(value).every(item => isCollabJson(item, depth + 1))
+}
+
+export function isCollabIdentity(value: unknown): value is CollabIdentity {
+ return (
+ isRecord(value) &&
+ isCollabString(value.id, 240) &&
+ isCollabString(value.name) &&
+ isCollabString(value.color, 64) &&
+ (value.avatar === undefined || isCollabString(value.avatar, COLLAB_MAX_AVATAR_BYTES))
+ )
+}
+
+export function isCollabActor(value: unknown): value is CollabActor {
+ return (
+ isRecord(value) &&
+ isCollabString(value.id, 240) &&
+ (value.kind === 'user' || value.kind === 'agent' || value.kind === 'system') &&
+ (value.onBehalfOf === undefined || isCollabString(value.onBehalfOf))
+ )
+}
+
+function isLocation(value: unknown): value is CollabLocation | null {
+ return (
+ value === null ||
+ (isRecord(value) &&
+ isCollabString(value.page, 1024) &&
+ (value.title === undefined || isCollabString(value.title)))
+ )
+}
+
+export function isCollabOperations(value: unknown): value is CollabOperation[] {
+ return (
+ Array.isArray(value) &&
+ value.length > 0 &&
+ value.length <= 100 &&
+ value.every(
+ operation =>
+ isRecord(operation) &&
+ isCollabString(operation.key, 1024) &&
+ (operation.type === 'delete' || (operation.type === 'set' && isCollabJson(operation.value)))
+ )
+ )
+}
+
+function isOperationIds(value: unknown): value is string[] {
+ return Array.isArray(value) && value.length <= 100 && value.every(id => isCollabString(id))
+}
+
+export function isCollabClientMessage(value: unknown): value is CollabClientMessage {
+ if (!isRecord(value)) return false
+ switch (value.type) {
+ case 'join':
+ return (
+ value.version === COLLAB_PROTOCOL_VERSION &&
+ (value.identity === null
+ ? isCollabString(value.anonymousId, 100)
+ : isCollabIdentity(value.identity) &&
+ (value.location === undefined || isLocation(value.location)))
+ )
+ case 'identity':
+ return isCollabIdentity(value.identity)
+ case 'location':
+ return isLocation(value.location)
+ case 'presence:set':
+ return (
+ isCollabString(value.registrationId) &&
+ isCollabString(value.surface) &&
+ isCollabString(value.channel) &&
+ isCollabJson(value.value) &&
+ new TextEncoder().encode(JSON.stringify(value.value)).length <= COLLAB_MAX_PRESENCE_BYTES
+ )
+ case 'presence:delete':
+ return isCollabString(value.registrationId)
+ case 'subscribe':
+ case 'unsubscribe':
+ return isCollabString(value.scope) && isCollabString(value.subscriptionId)
+ case 'mutate':
+ return (
+ isCollabString(value.scope) &&
+ isCollabString(value.operationId) &&
+ isCollabOperations(value.operations)
+ )
+ case 'receipts':
+ return isCollabString(value.requestId) && isOperationIds(value.operationIds)
+ case 'ping':
+ return true
+ default:
+ return false
+ }
+}
+
+export function isCollabCommand(value: unknown): value is CollabCommand {
+ if (!isRecord(value)) return false
+ switch (value.type) {
+ case 'snapshot':
+ return isCollabString(value.scope)
+ case 'mutate':
+ return (
+ isCollabString(value.scope) &&
+ isCollabString(value.operationId) &&
+ isCollabOperations(value.operations)
+ )
+ case 'receipts':
+ return isOperationIds(value.operationIds)
+ case 'export':
+ return isCollabString(value.path, 4096)
+ default:
+ return false
+ }
+}
diff --git a/lib/collab/types.ts b/lib/collab/types.ts
new file mode 100644
index 00000000..99e29a9b
--- /dev/null
+++ b/lib/collab/types.ts
@@ -0,0 +1,113 @@
+export type CollabJsonValue =
+ | null
+ | boolean
+ | number
+ | string
+ | CollabJsonValue[]
+ | { [key: string]: CollabJsonValue }
+
+export type CollabIdentity = {
+ id: string
+ name: string
+ avatar?: string
+ color: string
+}
+
+export type CollabCapability = {
+ enabled: boolean
+ referencePath?: string
+}
+
+export type CollabLocation = { page: string; title?: string }
+export type CollabActor = { id: string; kind: 'user' | 'agent' | 'system'; onBehalfOf?: string }
+
+export type CollabOperation =
+ | { type: 'set'; key: string; value: CollabJsonValue }
+ | { type: 'delete'; key: string }
+
+export type CollabScopeSnapshot = {
+ scope: string
+ revision: number
+ entries: Record
+}
+
+export type CollabMutationResult = {
+ scope: string
+ operationId: string
+ revision: number
+ operations: CollabOperation[]
+ duplicate: boolean
+}
+
+export type CollabReceipt =
+ | { operationId: string; status: 'unknown' }
+ | { operationId: string; status: 'committed'; scope: string; revision: number }
+
+export type CollabPresenceRegistration = {
+ registrationId: string
+ surface: string
+ channel: string
+ value: CollabJsonValue
+}
+
+export type CollabParticipant = {
+ connectionId: string
+ identity: CollabIdentity
+ location: CollabLocation | null
+ presence: CollabPresenceRegistration[]
+}
+
+export type CollabClientMessage =
+ | { type: 'join'; version: 1; identity: CollabIdentity; location?: CollabLocation | null }
+ | { type: 'join'; version: 1; identity: null; anonymousId: string }
+ | { type: 'identity'; identity: CollabIdentity }
+ | { type: 'location'; location: CollabLocation | null }
+ | ({ type: 'presence:set' } & CollabPresenceRegistration)
+ | { type: 'presence:delete'; registrationId: string }
+ | { type: 'subscribe'; scope: string; subscriptionId: string }
+ | { type: 'unsubscribe'; scope: string; subscriptionId: string }
+ | { type: 'mutate'; scope: string; operationId: string; operations: CollabOperation[] }
+ | { type: 'receipts'; requestId: string; operationIds: string[] }
+ | { type: 'ping' }
+
+export type CollabServerMessage =
+ | {
+ type: 'welcome'
+ version: 1
+ connectionId: string
+ identity: CollabIdentity | null
+ participants: CollabParticipant[]
+ }
+ | { type: 'participants'; participants: CollabParticipant[] }
+ | ({ type: 'snapshot'; subscriptionId: string } & CollabScopeSnapshot)
+ | {
+ type: 'update'
+ scope: string
+ revision: number
+ operations: CollabOperation[]
+ operationId: string
+ subscriptionId: string
+ }
+ | { type: 'ack'; scope: string; operationId: string; revision: number; duplicate: boolean }
+ | { type: 'receipts'; requestId: string; receipts: CollabReceipt[] }
+ | {
+ type: 'error'
+ code: string
+ message: string
+ operationId?: string
+ subscriptionId?: string
+ requestId?: string
+ }
+ | { type: 'pong' }
+
+export type CollabCommand =
+ | { type: 'snapshot'; scope: string }
+ | { type: 'mutate'; scope: string; operationId: string; operations: CollabOperation[] }
+ | { type: 'receipts'; operationIds: string[] }
+ | { type: 'export'; path: string }
+
+export type CollabCommandResult =
+ | CollabScopeSnapshot
+ | CollabMutationResult
+ | CollabReceipt[]
+ | null
diff --git a/server/collab/service.test.ts b/server/collab/service.test.ts
new file mode 100644
index 00000000..517fede8
--- /dev/null
+++ b/server/collab/service.test.ts
@@ -0,0 +1,209 @@
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
+
+import type { CollabServerMessage } from '@/lib/collab/types'
+
+import { CollabService } from './service'
+import { openCollabStorage } from './storage'
+
+describe('collab service', () => {
+ let service: CollabService
+ let messages: { connectionId: string; message: CollabServerMessage }[]
+
+ beforeEach(() => {
+ messages = []
+ service = new CollabService(openCollabStorage(':memory:'), (connectionId, message) => {
+ messages.push({ connectionId, message: structuredClone(message) })
+ })
+ })
+ afterEach(() => service.close())
+
+ function join(connectionId: string, id = connectionId) {
+ service.receive(connectionId, {
+ type: 'join',
+ version: 1,
+ identity: { id, name: id, color: '#336699' },
+ location: { page: 'view:board' }
+ })
+ }
+
+ test('anonymous applets share durable state and recover receipts without joining presence', () => {
+ service.receive('anon-a', { type: 'join', version: 1, identity: null, anonymousId: 'tab-a' })
+ service.receive('anon-b', { type: 'join', version: 1, identity: null, anonymousId: 'tab-b' })
+ join('person')
+ for (const { message } of messages) {
+ if (message.type === 'participants' || message.type === 'welcome') {
+ expect(
+ message.participants.every(participant => participant.identity.id === 'person')
+ ).toBe(true)
+ }
+ }
+ service.receive('anon-a', { type: 'subscribe', scope: 'board', subscriptionId: 'a-board' })
+ service.receive('anon-b', { type: 'subscribe', scope: 'board', subscriptionId: 'b-board' })
+ service.receive('anon-a', {
+ type: 'mutate',
+ scope: 'board',
+ operationId: 'anonymous-write',
+ operations: [{ type: 'set', key: 'title', value: 'Shared without a profile' }]
+ })
+ expect(
+ messages.some(
+ ({ connectionId, message }) =>
+ connectionId === 'anon-b' &&
+ message.type === 'update' &&
+ message.operations[0]?.type === 'set' &&
+ message.operations[0].value === 'Shared without a profile'
+ )
+ ).toBe(true)
+ service.leave('anon-a')
+ service.receive('anon-a-reconnected', {
+ type: 'join',
+ version: 1,
+ identity: null,
+ anonymousId: 'tab-a'
+ })
+ service.receive('anon-a-reconnected', {
+ type: 'receipts',
+ requestId: 'retry',
+ operationIds: ['anonymous-write']
+ })
+ expect(messages.at(-1)?.message).toMatchObject({
+ type: 'receipts',
+ receipts: [{ operationId: 'anonymous-write', status: 'committed', revision: 1 }]
+ })
+ service.receive('anon-b', {
+ type: 'receipts',
+ requestId: 'other-tab',
+ operationIds: ['anonymous-write']
+ })
+ expect(messages.at(-1)?.message).toMatchObject({
+ type: 'receipts',
+ receipts: [{ operationId: 'anonymous-write', status: 'unknown' }]
+ })
+ messages = []
+ service.receive('anon-b', { type: 'location', location: { page: 'view:board' } })
+ service.receive('anon-b', {
+ type: 'presence:set',
+ registrationId: 'cursor',
+ surface: 'board',
+ channel: 'cursor',
+ value: { x: 5 }
+ })
+ expect(messages).toEqual([])
+ })
+
+ test('snapshot precedes updates, and only matching subscribers receive mutations', () => {
+ join('a')
+ join('b')
+ service.receive('a', { type: 'subscribe', scope: 'board', subscriptionId: 'a-board' })
+ service.receive('b', { type: 'subscribe', scope: 'other', subscriptionId: 'b-other' })
+ service.receive('a', {
+ type: 'mutate',
+ scope: 'board',
+ operationId: 'first',
+ operations: [{ type: 'set', key: 'title', value: 'Shared' }]
+ })
+ const durable = messages.filter(item =>
+ ['snapshot', 'update', 'ack'].includes(item.message.type)
+ )
+ expect(durable.map(item => [item.connectionId, item.message.type])).toEqual([
+ ['a', 'snapshot'],
+ ['b', 'snapshot'],
+ ['a', 'update'],
+ ['a', 'ack']
+ ])
+ expect(durable[0]?.message).toMatchObject({ revision: 0, entries: {} })
+ expect(durable[2]?.message).toMatchObject({ revision: 1, subscriptionId: 'a-board' })
+ service.receive('a', { type: 'unsubscribe', scope: 'board', subscriptionId: 'a-board' })
+ messages = []
+ service.run(
+ { kind: 'agent', id: 'agent' },
+ {
+ type: 'mutate',
+ scope: 'board',
+ operationId: 'second',
+ operations: [{ type: 'set', key: 'title', value: 'From agent' }]
+ }
+ )
+ expect(messages).toEqual([])
+ })
+
+ test('duplicate operations return receipts without broadcasting an old edit', () => {
+ join('a')
+ service.receive('a', { type: 'subscribe', scope: 'board', subscriptionId: 'sub' })
+ const command = {
+ type: 'mutate' as const,
+ scope: 'board',
+ operationId: 'first',
+ operations: [{ type: 'set' as const, key: 'title', value: 'First' }]
+ }
+ service.receive('a', command)
+ service.run(
+ { kind: 'agent', id: 'a' },
+ {
+ ...command,
+ operations: [{ type: 'set', key: 'title', value: 'Newer' }]
+ }
+ )
+ messages = []
+ service.receive('a', command)
+ expect(messages).toEqual([
+ {
+ connectionId: 'a',
+ message: {
+ type: 'ack',
+ scope: 'board',
+ operationId: 'first',
+ revision: 1,
+ duplicate: true
+ }
+ }
+ ])
+ expect(
+ service.run({ kind: 'system', id: 'test' }, { type: 'snapshot', scope: 'board' })
+ ).toMatchObject({ revision: 2, entries: { title: 'Newer' } })
+ })
+
+ test('multiple connections share identity without sharing registration ownership', async () => {
+ join('tab-one', 'anna')
+ join('tab-two', 'anna')
+ for (const [registrationId, channel] of [
+ ['field-a', 'title'],
+ ['field-b', 'done']
+ ]) {
+ service.receive('tab-one', {
+ type: 'presence:set',
+ registrationId: registrationId!,
+ channel: channel!,
+ surface: 'view:board',
+ value: { focused: true }
+ })
+ }
+ service.receive('tab-one', { type: 'presence:delete', registrationId: 'field-a' })
+ await Bun.sleep(60)
+ const presence = messages.findLast(item => item.message.type === 'participants')?.message
+ expect(presence?.type).toBe('participants')
+ if (presence?.type !== 'participants') throw new Error('Missing participants')
+ expect(presence.participants).toHaveLength(2)
+ expect(presence.participants[0]?.presence.map(item => item.registrationId)).toEqual(['field-b'])
+ service.leave('tab-one')
+ const left = messages.at(-1)?.message
+ if (left?.type !== 'participants') throw new Error('Missing leave event')
+ expect(left.participants.map(item => item.connectionId)).toEqual(['tab-two'])
+ expect(left.participants[0]?.presence).toEqual([])
+ })
+
+ test('identity profile updates preserve actor while changing id requires reconnect', () => {
+ join('one', 'anna')
+ service.receive('one', {
+ type: 'identity',
+ identity: { id: 'anna', name: 'Anna updated', color: 'blue' }
+ })
+ expect(() =>
+ service.receive('one', {
+ type: 'identity',
+ identity: { id: 'boris', name: 'Boris', color: 'red' }
+ })
+ ).toThrow('Reconnect')
+ expect(() => service.receive('missing', { type: 'ping' })).toThrow('Join')
+ })
+})
diff --git a/server/collab/service.ts b/server/collab/service.ts
new file mode 100644
index 00000000..abb23670
--- /dev/null
+++ b/server/collab/service.ts
@@ -0,0 +1,218 @@
+import type {
+ CollabActor,
+ CollabClientMessage,
+ CollabCommand,
+ CollabCommandResult,
+ CollabIdentity,
+ CollabParticipant,
+ CollabServerMessage
+} from '@/lib/collab/types'
+
+import type { openCollabStorage } from './storage'
+
+type Storage = ReturnType
+type Emit = (connectionId: string, message: CollabServerMessage) => void
+type Client = {
+ actor: CollabActor
+ participant: CollabParticipant | null
+ subscriptions: Map
+}
+
+function cleanIdentity(identity: CollabIdentity): CollabIdentity {
+ return {
+ id: identity.id,
+ name: identity.name,
+ color: identity.color,
+ ...(identity.avatar ? { avatar: identity.avatar } : {})
+ }
+}
+
+// Include actor kind so an agent id cannot collide with a human id's receipts.
+function actorKey(actor: CollabActor): string {
+ return `${actor.kind}:${actor.id}`
+}
+
+export class CollabService {
+ private clients = new Map()
+ private presenceTimer: ReturnType | null = null
+
+ constructor(
+ private storage: Storage,
+ private emit: Emit
+ ) {}
+
+ private participants(): CollabParticipant[] {
+ return [...this.clients.values()].flatMap(client =>
+ client.participant ? [client.participant] : []
+ )
+ }
+
+ private publishParticipants() {
+ if (this.presenceTimer) clearTimeout(this.presenceTimer)
+ this.presenceTimer = null
+ const message: CollabServerMessage = { type: 'participants', participants: this.participants() }
+ for (const id of this.clients.keys()) this.emit(id, message)
+ }
+
+ private scheduleParticipants() {
+ this.presenceTimer ??= setTimeout(() => this.publishParticipants(), 50)
+ }
+
+ receive(connectionId: string, message: CollabClientMessage) {
+ if (message.type === 'join') {
+ if (this.clients.has(connectionId)) throw new Error('This connection already joined')
+ if (this.clients.size >= 32) throw new Error('This workspace has too many connections')
+ const identity = message.identity ? cleanIdentity(message.identity) : null
+ this.clients.set(connectionId, {
+ actor: message.identity
+ ? { id: message.identity.id, kind: 'user' }
+ : { id: `anonymous:${message.anonymousId}`, kind: 'system' },
+ participant: identity
+ ? {
+ connectionId,
+ identity,
+ location: 'location' in message ? (message.location ?? null) : null,
+ presence: []
+ }
+ : null,
+ subscriptions: new Map()
+ })
+ this.emit(connectionId, {
+ type: 'welcome',
+ version: 1,
+ connectionId,
+ identity,
+ participants: this.participants()
+ })
+ this.publishParticipants()
+ return
+ }
+
+ const client = this.clients.get(connectionId)
+ if (!client) throw new Error('Join the workspace before sending collab messages')
+ const { actor, participant } = client
+ switch (message.type) {
+ case 'identity': {
+ if (!participant || message.identity.id !== actor.id)
+ throw new Error('Reconnect to change identity')
+ participant.identity = cleanIdentity(message.identity)
+ this.publishParticipants()
+ return
+ }
+ case 'location':
+ if (!participant) return
+ participant.location = message.location
+ this.scheduleParticipants()
+ return
+ case 'presence:set': {
+ if (!participant) return
+ const presence = participant.presence
+ const index = presence.findIndex(item => item.registrationId === message.registrationId)
+ if (index < 0 && presence.length >= 64) throw new Error('Too many presence registrations')
+ const { registrationId, surface, channel, value } = message
+ const registration = { registrationId, surface, channel, value }
+ if (index >= 0) presence[index] = registration
+ else presence.push(registration)
+ this.scheduleParticipants()
+ return
+ }
+ case 'presence:delete':
+ if (!participant) return
+ participant.presence = participant.presence.filter(
+ item => item.registrationId !== message.registrationId
+ )
+ this.scheduleParticipants()
+ return
+ case 'subscribe': {
+ if (!client.subscriptions.has(message.subscriptionId) && client.subscriptions.size >= 64) {
+ throw new Error('Too many storage subscriptions')
+ }
+ const snapshot = this.storage.snapshot(message.scope)
+ // No await between snapshot and registration: every subsequent mutation sees this reader.
+ client.subscriptions.set(message.subscriptionId, message.scope)
+ this.emit(connectionId, {
+ type: 'snapshot',
+ subscriptionId: message.subscriptionId,
+ ...snapshot
+ })
+ return
+ }
+ case 'unsubscribe':
+ if (client.subscriptions.get(message.subscriptionId) === message.scope) {
+ client.subscriptions.delete(message.subscriptionId)
+ }
+ return
+ case 'mutate': {
+ const result = this.mutate(actor, message)
+ this.emit(connectionId, {
+ type: 'ack',
+ scope: result.scope,
+ operationId: result.operationId,
+ revision: result.revision,
+ duplicate: result.duplicate
+ })
+ return
+ }
+ case 'receipts':
+ this.emit(connectionId, {
+ type: 'receipts',
+ requestId: message.requestId,
+ receipts: this.storage.lookupReceipts(actorKey(actor), message.operationIds)
+ })
+ return
+ case 'ping':
+ this.emit(connectionId, { type: 'pong' })
+ return
+ }
+ }
+
+ private mutate(actor: CollabActor, command: Extract) {
+ const result = this.storage.mutate(
+ command.scope,
+ actorKey(actor),
+ command.operationId,
+ command.operations
+ )
+ if (!result.duplicate) {
+ for (const [connectionId, client] of this.clients) {
+ for (const [subscriptionId, scope] of client.subscriptions) {
+ if (scope !== command.scope) continue
+ this.emit(connectionId, {
+ type: 'update',
+ scope,
+ subscriptionId,
+ revision: result.revision,
+ operationId: result.operationId,
+ operations: result.operations
+ })
+ }
+ }
+ }
+ return result
+ }
+
+ run(actor: CollabActor, command: CollabCommand): CollabCommandResult {
+ switch (command.type) {
+ case 'snapshot':
+ return this.storage.snapshot(command.scope)
+ case 'mutate':
+ return this.mutate(actor, command)
+ case 'receipts':
+ return this.storage.lookupReceipts(actorKey(actor), command.operationIds)
+ case 'export':
+ this.storage.exportTo(command.path)
+ return null
+ }
+ }
+
+ leave(connectionId: string) {
+ if (this.clients.delete(connectionId)) this.publishParticipants()
+ }
+
+ close() {
+ if (this.presenceTimer) clearTimeout(this.presenceTimer)
+ this.presenceTimer = null
+ this.clients.clear()
+ this.storage.close()
+ }
+}
diff --git a/server/collab/storage.test.ts b/server/collab/storage.test.ts
new file mode 100644
index 00000000..13e636d9
--- /dev/null
+++ b/server/collab/storage.test.ts
@@ -0,0 +1,300 @@
+import { Database } from 'bun:sqlite'
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+import type { CollabJsonValue, CollabOperation } from '@/lib/collab/types'
+
+import { COLLAB_STORAGE_LIMITS, openCollabStorage, type CollabStorage } from './storage'
+
+describe('collab storage', () => {
+ let directory: string
+ let path: string
+ let storage: CollabStorage
+ let timestamp: number
+
+ beforeEach(() => {
+ directory = mkdtempSync(join(tmpdir(), 'moi-collab-storage-'))
+ path = join(directory, '.moi', 'data', 'collab.sqlite')
+ timestamp = Date.UTC(2026, 8, 15)
+ storage = openCollabStorage(path, { now: () => timestamp })
+ })
+
+ afterEach(() => {
+ storage.close()
+ rmSync(directory, { recursive: true, force: true })
+ })
+
+ const set = (key: string, value: CollabJsonValue): CollabOperation => ({
+ type: 'set',
+ key,
+ value
+ })
+
+ test('reading an absent scope never initializes values or advances revisions', () => {
+ expect(storage.snapshot('board')).toEqual({ scope: 'board', revision: 0, entries: {} })
+ expect(storage.snapshot('board')).toEqual({ scope: 'board', revision: 0, entries: {} })
+ storage.mutate('board', 'anna', 'create', [set('title', 'Created deliberately')])
+ expect(storage.snapshot('board').revision).toBe(1)
+ expect(storage.snapshot('other').revision).toBe(0)
+ })
+
+ test('interleaved participants preserve distinct fields and converge on the last same-key commit', async () => {
+ const commits = await Promise.all([
+ Promise.resolve().then(() =>
+ storage.mutate('board', 'anna', 'title', [set('task/1/title', 'A')])
+ ),
+ Promise.resolve().then(() =>
+ storage.mutate('board', 'boris', 'done', [set('task/1/done', true)])
+ ),
+ Promise.resolve().then(() =>
+ storage.mutate('board', 'boris', 'rename', [set('task/1/title', 'B')])
+ )
+ ])
+ expect(commits.map(commit => commit.revision)).toEqual([1, 2, 3])
+ expect(storage.snapshot('board')).toEqual({
+ scope: 'board',
+ revision: 3,
+ entries: { 'task/1/title': 'B', 'task/1/done': true }
+ })
+ storage.mutate('other', 'anna', 'other-write', [set('value', 1)])
+ expect(storage.snapshot('board').revision).toBe(3)
+ expect(storage.snapshot('other').revision).toBe(1)
+ })
+
+ test('a batch is ordered and receives one revision', () => {
+ const result = storage.mutate('board', 'anna', 'batch', [
+ set('value', 1),
+ { type: 'delete', key: 'value' },
+ set('value', 2),
+ set('other', null)
+ ])
+ expect(result.revision).toBe(1)
+ expect(storage.snapshot('board').entries).toEqual({ value: 2, other: null })
+ storage.mutate('board', 'anna', 'empty', [])
+ expect(storage.snapshot('board').revision).toBe(2)
+ })
+
+ test('SQLite failure rolls back all entries, revision and receipt', () => {
+ storage.mutate('board', 'anna', 'original', [set('value', 'before')])
+ // Inject a failure on the second write, after the first UPDATE has run.
+ const fault = new Database(path)
+ fault.exec(`CREATE TRIGGER fail_insert BEFORE INSERT ON entries
+ WHEN NEW.key = 'fail' BEGIN SELECT RAISE(ABORT, 'injected write failure'); END`)
+ fault.close()
+ expect(() =>
+ storage.mutate('board', 'anna', 'broken', [set('value', 'after'), set('fail', true)])
+ ).toThrow('injected write failure')
+ expect(storage.snapshot('board')).toEqual({
+ scope: 'board',
+ revision: 1,
+ entries: { value: 'before' }
+ })
+ expect(storage.lookupReceipts('anna', ['broken'])).toEqual([
+ { operationId: 'broken', status: 'unknown' }
+ ])
+ })
+
+ test('a delayed field edit cannot recreate deleted task membership', () => {
+ storage.mutate('board', 'anna', 'create', [
+ set('task/1/exists', true),
+ set('task/1/title', 'Demo'),
+ set('task/1/done', false)
+ ])
+ storage.mutate('board', 'anna', 'delete', [
+ { type: 'delete', key: 'task/1/exists' },
+ { type: 'delete', key: 'task/1/title' },
+ { type: 'delete', key: 'task/1/done' }
+ ])
+ storage.mutate('board', 'boris', 'late', [set('task/1/title', 'Delayed edit')])
+ expect(storage.snapshot('board').entries).toEqual({ 'task/1/title': 'Delayed edit' })
+ storage.close()
+ storage = openCollabStorage(path, { now: () => timestamp })
+ expect(storage.snapshot('board').entries['task/1/exists']).toBeUndefined()
+ })
+
+ test('retrying a committed request does not overwrite a newer change or advance revision', () => {
+ storage.mutate('board', 'anna', 'lost-ack', [set('title', 'A')])
+ storage.mutate('board', 'boris', 'newer', [set('title', 'B')])
+ expect(storage.mutate('board', 'anna', 'lost-ack', [set('title', 'A')])).toMatchObject({
+ duplicate: true,
+ revision: 1
+ })
+ expect(storage.snapshot('board')).toEqual({
+ scope: 'board',
+ revision: 2,
+ entries: { title: 'B' }
+ })
+ expect(storage.lookupReceipts('anna', ['lost-ack'])).toEqual([
+ { operationId: 'lost-ack', status: 'committed', scope: 'board', revision: 1 }
+ ])
+ })
+
+ test('receipt fingerprints canonicalize JSON and reject changed scope or contents', () => {
+ storage.mutate('board', 'anna', 'same-id', [set('data', { b: 2, a: 1 })])
+ expect(
+ storage.mutate('board', 'anna', 'same-id', [set('data', { a: 1, b: 2 })]).duplicate
+ ).toBe(true)
+ expect(() => storage.mutate('board', 'anna', 'same-id', [set('data', { a: 2 })])).toThrow(
+ 'Operation id was already used'
+ )
+ expect(() => storage.mutate('other', 'anna', 'same-id', [set('data', { a: 1, b: 2 })])).toThrow(
+ 'Operation id was already used'
+ )
+ expect(storage.snapshot('other').revision).toBe(0)
+ expect(storage.lookupReceipts('boris', ['same-id'])).toEqual([
+ { operationId: 'same-id', status: 'unknown' }
+ ])
+ expect(storage.mutate('board', 'boris', 'same-id', [set('other', true)]).duplicate).toBe(false)
+ })
+
+ test('data and retained receipts survive reopening the database', () => {
+ storage.mutate('board', 'anna', 'saved', [set('data', { title: 'Demo', done: false })])
+ storage.close()
+ storage = openCollabStorage(path, { now: () => timestamp })
+ expect(storage.lookupReceipts('anna', ['saved'])).toEqual([
+ { operationId: 'saved', status: 'committed', scope: 'board', revision: 1 }
+ ])
+ expect(
+ storage.mutate('board', 'anna', 'saved', [set('data', { done: false, title: 'Demo' })])
+ .duplicate
+ ).toBe(true)
+ expect(storage.snapshot('board').revision).toBe(1)
+ })
+
+ test('expired or absent receipts are unknown and expiry never removes data', () => {
+ storage.mutate('board', 'anna', 'old', [set('value', 'durable')])
+ timestamp += COLLAB_STORAGE_LIMITS.receiptTtlMs - 1
+ expect(storage.lookupReceipts('anna', ['old'])[0]?.status).toBe('committed')
+ timestamp += 1
+ expect(storage.lookupReceipts('anna', ['old', 'never-arrived'])).toEqual([
+ { operationId: 'old', status: 'unknown' },
+ { operationId: 'never-arrived', status: 'unknown' }
+ ])
+ storage.pruneReceipts()
+ storage.close()
+ storage = openCollabStorage(path, { now: () => timestamp })
+ expect(storage.lookupReceipts('anna', ['old'])[0]?.status).toBe('unknown')
+ expect(storage.snapshot('board').entries).toEqual({ value: 'durable' })
+ const inspection = new Database(path, { readonly: true })
+ expect(
+ inspection.query<{ count: number }, []>('SELECT COUNT(*) AS count FROM receipts').get()?.count
+ ).toBe(0)
+ inspection.close()
+ })
+
+ test('live export is consistent, independently restorable and never overwrites a destination', () => {
+ storage.mutate('board', 'anna', 'saved', [set('title', 'Before export')])
+ const destination = join(directory, 'export', 'collab.sqlite')
+ storage.exportTo(destination)
+ storage.mutate('board', 'boris', 'later', [set('title', 'After export')])
+ const restored = openCollabStorage(destination, { now: () => timestamp })
+ try {
+ expect(restored.snapshot('board')).toEqual({
+ scope: 'board',
+ revision: 1,
+ entries: { title: 'Before export' }
+ })
+ expect(restored.lookupReceipts('anna', ['saved'])[0]?.status).toBe('committed')
+ expect(restored.lookupReceipts('boris', ['later'])[0]?.status).toBe('unknown')
+ expect(() => storage.exportTo(destination)).toThrow('already exists')
+ } finally {
+ restored.close()
+ }
+ })
+
+ test('rejects non-JSON values, oversized payloads and excessive nesting before writing', () => {
+ const cyclic: Record = {}
+ cyclic.self = cyclic
+ const invalid: unknown[] = [
+ undefined,
+ NaN,
+ Infinity,
+ new Date(),
+ { missing: undefined },
+ cyclic,
+ [undefined]
+ ]
+ for (const value of invalid) {
+ expect(() =>
+ storage.mutate('board', 'anna', 'invalid', [set('bad', value as CollabJsonValue)])
+ ).toThrow()
+ }
+ let nested: CollabJsonValue = null
+ for (let i = 0; i < 40; i++) nested = [nested]
+ expect(() => storage.mutate('board', 'anna', 'nested', [set('bad', nested)])).toThrow(
+ 'too complex'
+ )
+ expect(() => storage.mutate('board', 'anna', 'large', [set('bad', 'x'.repeat(65536))])).toThrow(
+ '64 KiB'
+ )
+ expect(() =>
+ storage.mutate(
+ 'board',
+ 'anna',
+ 'batch-large',
+ Array.from({ length: 101 }, (_, i) => set(`${i}`, 1))
+ )
+ ).toThrow('100 operations')
+ expect(() =>
+ storage.mutate(
+ 'board',
+ 'anna',
+ 'request-large',
+ Array.from({ length: 5 }, (_, i) => set(`${i}`, 'x'.repeat(60_000)))
+ )
+ ).toThrow('256 KiB')
+ expect(() =>
+ storage.mutate('board', 'anna', 'key-large', [set('x'.repeat(1025), true)])
+ ).toThrow('Invalid shared key')
+ expect(storage.snapshot('board')).toEqual({ scope: 'board', revision: 0, entries: {} })
+ })
+
+ test('scope capacity rejection rolls back earlier operations in the same batch', () => {
+ const value = 'x'.repeat(65_000)
+ for (let batch = 0; batch < 40; batch++) {
+ storage.mutate(
+ 'board',
+ 'anna',
+ `fill-${batch}`,
+ Array.from({ length: 4 }, (_, i) => set(`fill/${batch * 4 + i}`, value))
+ )
+ }
+ expect(() =>
+ storage.mutate('board', 'anna', 'overflow', [
+ set('marker', true),
+ set('extra/1', value),
+ set('extra/2', value)
+ ])
+ ).toThrow('storage limit')
+ const snapshot = storage.snapshot('board')
+ expect(snapshot.revision).toBe(40)
+ expect(Object.keys(snapshot.entries)).toHaveLength(160)
+ expect(snapshot.entries.marker).toBeUndefined()
+ expect(storage.lookupReceipts('anna', ['overflow'])[0]?.status).toBe('unknown')
+ })
+
+ test('special object keys round-trip as data without prototype mutation', () => {
+ const value = JSON.parse('{"__proto__":{"admin":true},"constructor":"data"}') as CollabJsonValue
+ storage.mutate('board', 'anna', 'special', [set('__proto__', value)])
+ const entries = storage.snapshot('board').entries
+ expect(Object.hasOwn(entries, '__proto__')).toBe(true)
+ expect(entries['__proto__']).toEqual(value)
+ expect(Object.getPrototypeOf(entries)).toBe(Object.prototype)
+ })
+
+ test('uses versioned rollback-journal storage and refuses a newer schema', () => {
+ storage.close()
+ const inspection = new Database(path)
+ expect(
+ inspection.query<{ journal_mode: string }, []>('PRAGMA journal_mode').get()?.journal_mode
+ ).toBe('delete')
+ expect(
+ inspection.query<{ user_version: number }, []>('PRAGMA user_version').get()?.user_version
+ ).toBe(1)
+ inspection.exec('PRAGMA user_version = 2')
+ inspection.close()
+ expect(() => openCollabStorage(path)).toThrow('newer version')
+ })
+})
diff --git a/server/collab/storage.ts b/server/collab/storage.ts
new file mode 100644
index 00000000..06da9eea
--- /dev/null
+++ b/server/collab/storage.ts
@@ -0,0 +1,327 @@
+import { Database } from 'bun:sqlite'
+import { existsSync, mkdirSync } from 'node:fs'
+import { dirname } from 'node:path'
+
+import type {
+ CollabJsonValue,
+ CollabMutationResult,
+ CollabOperation,
+ CollabReceipt,
+ CollabScopeSnapshot
+} from '@/lib/collab/types'
+
+export const COLLAB_STORAGE_LIMITS = {
+ operations: 100,
+ valueBytes: 64 * 1024,
+ requestBytes: 256 * 1024,
+ scopeBytes: 10 * 1024 * 1024,
+ scopeEntries: 10_000,
+ identifierBytes: 256,
+ keyBytes: 1024,
+ jsonDepth: 32,
+ receiptTtlMs: 24 * 60 * 60 * 1000
+} as const
+
+const PRUNE_INTERVAL_MS = 60_000
+const SCHEMA_VERSION = 1
+
+export class CollabStorageError extends Error {
+ constructor(
+ public readonly code: string,
+ message: string
+ ) {
+ super(message)
+ this.name = 'CollabStorageError'
+ }
+}
+
+export type CollabStorage = {
+ snapshot: (scope: string) => CollabScopeSnapshot
+ mutate: (
+ scope: string,
+ actorId: string,
+ operationId: string,
+ operations: readonly CollabOperation[]
+ ) => CollabMutationResult
+ lookupReceipts: (actorId: string, operationIds: readonly string[]) => CollabReceipt[]
+ pruneReceipts: () => void
+ exportTo: (path: string) => void
+ close: () => void
+}
+
+export type CollabStorageOptions = {
+ now?: () => number
+}
+
+function fail(code: string, message: string): never {
+ throw new CollabStorageError(code, message)
+}
+
+function identifier(value: unknown, label: string, maxBytes: number) {
+ if (
+ typeof value !== 'string' ||
+ !value.length ||
+ value.includes('\0') ||
+ Buffer.byteLength(value) > maxBytes
+ ) {
+ fail('INVALID_REQUEST', `Invalid ${label}`)
+ }
+}
+
+// Canonical JSON gives equivalent objects the same fingerprint. Validate before
+// encoding: JSON.stringify otherwise silently drops undefined and changes NaN.
+function encodeJson(value: unknown): string {
+ const ancestors = new Set
+ }
+ >
+
+ {children}
+
+
+
+ )
+}
+
+const subscribeIdentity = (listener: () => void) =>
+ loaded?.subscribeIdentityStore(listener) ?? (() => {})
+const hasIdentity = () => loaded?.getIdentity() != null
+
+// Identity controls personal navigation and host UI independently of storage.
+export function useCollabIdentityEnabled(): boolean {
+ const enabled = useContext(EnabledContext)
+ const identity = useSyncExternalStore(subscribeIdentity, hasIdentity, hasIdentity)
+ return enabled && identity
+}
+
+export type AppletCollabMountProps = {
+ workspaceId: string
+ applet: { kind: AppletKind; name: string }
+ active?: boolean
+ children: ReactNode
+}
+export function AppletCollabMount(props: AppletCollabMountProps) {
+ const enabled = useContext(EnabledContext)
+ if (!loaded || !enabled) return props.children
+ return
+}
+
+export type CollabTabInfo = { label: string; Icon: TabIcon }
+export type CollabControlsProps = {
+ workspaceId: string
+ // Resolves a participant's tab to the label and icon the tab strip uses.
+ describeTab: (tab: WorkspaceTabId) => CollabTabInfo | null
+ onOpenTab: (tab: WorkspaceTabId) => void
+}
+export function CollabControls(props: CollabControlsProps) {
+ const enabled = useCollabIdentityEnabled()
+ return loaded && enabled ? : null
+}
diff --git a/client/features/collab/facehash-avatar.ts b/client/features/collab/facehash-avatar.ts
new file mode 100644
index 00000000..7dd10b78
--- /dev/null
+++ b/client/features/collab/facehash-avatar.ts
@@ -0,0 +1,132 @@
+import { stringHash } from 'facehash'
+
+import { COLLAB_MAX_AVATAR_BYTES } from '@/lib/collab/protocol'
+
+// Rasterizes a facehash face (https://facehash.dev) into a small PNG data URL,
+// so a test avatar travels inside the identity instead of pointing at a URL.
+// The library paints its faces with React and CSS, which a canvas cannot
+// consume, so the eye shapes and their selection are mirrored here: a name gets
+// the same face it gets from the library. This is the library's solid variant;
+// its gradient sheen alone costs more than the protocol's avatar budget.
+
+type Face = { width: number; height: number; draw: (ctx: CanvasRenderingContext2D) => void }
+
+function circle(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number): void {
+ ctx.beginPath()
+ ctx.arc(cx, cy, r, 0, Math.PI * 2)
+ ctx.fill()
+}
+
+function pill(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number): void {
+ ctx.beginPath()
+ ctx.roundRect(x, y, w, h, 3.5)
+ ctx.fill()
+}
+
+const CURVED_EYE =
+ 'M0 5.1c0-.1 0-.2 0-.3.1-.5.3-1 .7-1.3.1 0 .1-.1.2-.1C2.4 2.2 6 0 10.5 0S18.6 2.2 20.2 3.3c.1 0 .1.1.1.1.4.3.7.9.7 1.3v.3c0 1 0 1.4 0 1.7-.2 1.3-1.2 1.9-2.5 1.6-.2 0-.7-.3-1.8-.8C15 6.7 12.8 6 10.5 6s-4.5.7-6.3 1.5c-1 .5-1.5.7-1.8.8-1.3.3-2.3-.3-2.5-1.6v-1.7z'
+
+// Same order as the library: round, cross, line, curved.
+const FACES: Face[] = [
+ {
+ width: 63,
+ height: 15,
+ draw: ctx => {
+ circle(ctx, 7.2, 7.2, 7.2)
+ circle(ctx, 55.2, 7.2, 7.2)
+ }
+ },
+ {
+ width: 71,
+ height: 23,
+ draw: ctx => {
+ pill(ctx, 8, 0, 7, 23)
+ pill(ctx, 0, 8, 23, 7)
+ pill(ctx, 55.2, 0, 7, 23)
+ pill(ctx, 47.3, 8, 23, 7)
+ }
+ },
+ {
+ width: 82,
+ height: 8,
+ draw: ctx => {
+ pill(ctx, 0.07, 0.16, 6.9, 6.9)
+ pill(ctx, 7.9, 0.16, 20.7, 6.9)
+ pill(ctx, 74.7, 0.16, 6.9, 6.9)
+ pill(ctx, 53.1, 0.16, 20.7, 6.9)
+ }
+ },
+ {
+ width: 63,
+ height: 9,
+ draw: ctx => {
+ const eye = new Path2D(CURVED_EYE)
+ ctx.fill(eye)
+ ctx.translate(42, 0)
+ ctx.fill(eye)
+ }
+ }
+]
+
+// The library tilts the face in 3D; its image renderer flattens that into a
+// small nudge, which is what a raster copy can reproduce.
+const TILTS = [
+ [-1, 1],
+ [1, 1],
+ [1, 0],
+ [0, 1],
+ [-1, 0],
+ [0, 0],
+ [0, -1],
+ [-1, -1],
+ [1, -1]
+] as const
+
+// Crisp at the largest host avatar on a 2x display, and a few kilobytes as PNG.
+const SIZE = 96
+
+function drawFace(ctx: CanvasRenderingContext2D, name: string, color: string, size: number): void {
+ const hash = stringHash(name)
+ const face = FACES[hash % FACES.length] ?? FACES[0]
+ const [tiltX, tiltY] = TILTS[hash % TILTS.length] ?? [0, 0]
+ const dx = tiltY * size * 0.05
+ const dy = -tiltX * size * 0.05
+
+ ctx.fillStyle = color
+ ctx.fillRect(0, 0, size, size)
+
+ const eyesWidth = size * 0.6
+ const eyesHeight = (eyesWidth * face.height) / face.width
+ const gap = size * 0.08
+ const fontSize = size * 0.26
+ const top = (size - (eyesHeight + gap + fontSize)) / 2
+
+ ctx.save()
+ ctx.translate((size - eyesWidth) / 2 + dx, top + dy)
+ ctx.scale(eyesWidth / face.width, eyesWidth / face.width)
+ ctx.fillStyle = '#000'
+ face.draw(ctx)
+ ctx.restore()
+
+ ctx.fillStyle = '#000'
+ ctx.font = `700 ${fontSize}px ui-monospace, Menlo, monospace`
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(
+ name.charAt(0).toUpperCase(),
+ size / 2 + dx,
+ top + eyesHeight + gap + fontSize / 2 + dy
+ )
+}
+
+export function facehashDataUrl(name: string, color: string): string | undefined {
+ if (typeof document === 'undefined') return undefined
+ const canvas = document.createElement('canvas')
+ canvas.width = SIZE
+ canvas.height = SIZE
+ const ctx = canvas.getContext('2d')
+ if (!ctx) return undefined
+ drawFace(ctx, name, color, SIZE)
+ const url = canvas.toDataURL('image/png')
+ return url.length <= COLLAB_MAX_AVATAR_BYTES ? url : undefined
+}
diff --git a/client/features/collab/hooks.tsx b/client/features/collab/hooks.tsx
new file mode 100644
index 00000000..467b6d7f
--- /dev/null
+++ b/client/features/collab/hooks.tsx
@@ -0,0 +1,280 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+ useSyncExternalStore
+} from 'react'
+import type { ReactNode } from 'react'
+import { useLocation } from 'wouter'
+
+import type { AppletKind } from '@/lib/types'
+import type { CollabJsonValue, CollabOperation, CollabParticipant } from '@/lib/collab/types'
+
+import { CollabClient } from './client'
+import { getIdentity, installIdentityApi, subscribeIdentityStore } from './identity'
+import { participantForSelf } from './store'
+import type { CollabStore } from './store'
+import type { MutationOutcome } from './store'
+
+type AppletIdentity = { kind: AppletKind; name: string }
+type Mount = { applet: AppletIdentity; active: boolean; surface: string }
+const WorkspaceContext = createContext(null)
+const MountContext = createContext(null)
+
+installIdentityApi()
+
+// The route segment after `/workspace/:id/`, which is the workspace tab id.
+export function pageFromPath(path: string): string {
+ return path.split('/').slice(3).join('/') || 'overview'
+}
+
+export type CollabWorkspaceProviderProps = {
+ workspaceId: string
+ children: ReactNode
+}
+export function CollabWorkspaceProvider({ workspaceId, children }: CollabWorkspaceProviderProps) {
+ const [client] = useState(() => new CollabClient(workspaceId))
+ const [path] = useLocation()
+ useEffect(() => {
+ window.dispatchEvent(new CustomEvent('moi:collab-ready'))
+ return client.start()
+ }, [client])
+ useEffect(() => {
+ client.store.setLocation({ page: pageFromPath(path) })
+ }, [client, path])
+ useEffect(() => {
+ const update = () => {
+ if (document.visibilityState === 'hidden') client.store.setLocation(null)
+ else client.store.setLocation({ page: pageFromPath(location.pathname) })
+ }
+ document.addEventListener('visibilitychange', update)
+ return () => document.removeEventListener('visibilitychange', update)
+ }, [client])
+ return {children}
+}
+
+export type AppletCollabProviderProps = {
+ workspaceId: string
+ applet: AppletIdentity
+ active?: boolean
+ children: ReactNode
+}
+export function AppletCollabProvider({
+ applet,
+ active = true,
+ children
+}: AppletCollabProviderProps) {
+ const { kind, name } = applet
+ const mount = useMemo(
+ () => ({ applet: { kind, name }, active, surface: `${kind}:${name}` }),
+ [kind, name, active]
+ )
+ return {children}
+}
+
+function useCollabClient(): CollabClient {
+ const client = useContext(WorkspaceContext)
+ if (!client) throw new Error('Start moi with --experimental-collab to use collaboration.')
+ return client
+}
+
+export function useCollabStore(): CollabStore {
+ return useCollabClient().store
+}
+
+export function useConnection() {
+ const store = useCollabStore()
+ return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
+}
+
+export function useSelf(): CollabParticipant | null {
+ const state = useConnection()
+ const identity = useSyncExternalStore(subscribeIdentityStore, getIdentity, getIdentity)
+ return useMemo(() => (identity ? participantForSelf(state, identity) : null), [state, identity])
+}
+
+export type OthersOptions = { scope?: 'page' | 'workspace' }
+export function useOthers({ scope = 'page' }: OthersOptions = {}): CollabParticipant[] {
+ const state = useConnection()
+ const store = useCollabStore()
+ return useMemo(
+ () =>
+ state.participants.filter(
+ participant =>
+ participant.connectionId !== state.connectionId &&
+ (scope === 'workspace' ||
+ (store.getLocation() !== null &&
+ participant.location?.page === store.getLocation()?.page))
+ ),
+ [state, store, scope]
+ )
+}
+
+export function useMount(): Mount {
+ const mount = useContext(MountContext)
+ if (!mount) throw new Error('This collaboration hook must be used inside an applet.')
+ return mount
+}
+
+export type PresenceValue = { participant: CollabParticipant; value: T }
+export function usePresence(channel: string, initialValue: T) {
+ return usePresenceChannel(`custom:${channel}`, initialValue)
+}
+
+export function usePresenceChannel(channel: string, initialValue: T) {
+ const store = useCollabStore()
+ const mount = useMount()
+ const others = useOthers()
+ const [value, setValue] = useState(initialValue)
+ const [registrationId] = useState(() => crypto.randomUUID())
+ const live = useRef(false)
+ const valueRef = useRef(value)
+
+ useLayoutEffect(() => {
+ if (!mount.active) return
+ live.current = true
+ const publish = () => {
+ if (document.visibilityState === 'hidden') store.deletePresence(registrationId)
+ else
+ store.setPresence({
+ registrationId,
+ surface: mount.surface,
+ channel,
+ value: valueRef.current
+ })
+ }
+ publish()
+ document.addEventListener('visibilitychange', publish)
+ return () => {
+ live.current = false
+ document.removeEventListener('visibilitychange', publish)
+ store.deletePresence(registrationId)
+ }
+ }, [store, mount.active, mount.surface, channel, registrationId])
+
+ const update = useCallback(
+ (next: T) => {
+ if (!live.current) return
+ valueRef.current = next
+ setValue(next)
+ if (mount.active && document.visibilityState !== 'hidden') {
+ store.setPresence({ registrationId, surface: mount.surface, channel, value: next })
+ }
+ },
+ [store, mount.active, mount.surface, channel, registrationId]
+ )
+
+ const otherValues = useMemo(
+ () =>
+ others.flatMap(participant =>
+ participant.presence
+ .filter(
+ registration =>
+ registration.surface === mount.surface && registration.channel === channel
+ )
+ .map(registration => ({ participant, value: registration.value as T }))
+ ),
+ [others, mount.surface, channel]
+ )
+ return { value, setValue: update, others: otherValues }
+}
+
+export type SharedOptions = { scope?: string }
+export type SharedStateOptions = SharedOptions & { defaultValue?: T }
+
+function useScope(explicitScope?: string) {
+ const client = useCollabClient()
+ const store = client.store
+ const mount = useMount()
+ const scope = explicitScope ?? `applet:${mount.surface}`
+ const subscribe = useCallback(
+ (listener: () => void) => store.subscribeScope(scope, listener),
+ [store, scope]
+ )
+ const getSnapshot = useCallback(() => store.getScopeSnapshot(scope), [store, scope])
+ const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
+ const connection = useConnection()
+ const live = useRef(false)
+ useLayoutEffect(() => {
+ if (!mount.active) return
+ live.current = true
+ const release = store.acquireScope(scope)
+ const releaseConnection = client.acquireSharedState()
+ return () => {
+ live.current = false
+ release()
+ releaseConnection()
+ }
+ }, [client, store, scope, mount.active])
+ const mutate = useCallback(
+ (operations: CollabOperation[]): Promise => {
+ if (!live.current)
+ return Promise.resolve({ status: 'rejected', message: 'This applet is no longer active.' })
+ return store.mutate(scope, operations)
+ },
+ [store, scope]
+ )
+ return {
+ mutate,
+ state,
+ canWrite: mount.active && state.synced && connection.status === 'connected'
+ }
+}
+
+export function useSharedState(
+ key: string,
+ options: SharedStateOptions = {}
+) {
+ const { mutate, state, canWrite } = useScope(options.scope)
+ const setValue = useCallback((value: T) => mutate([{ type: 'set', key, value }]), [mutate, key])
+ const deleteValue = useCallback(() => mutate([{ type: 'delete', key }]), [mutate, key])
+ const exists = Object.hasOwn(state.entries, key)
+ return {
+ value: state.loaded ? (exists ? (state.entries[key] as T) : options.defaultValue) : undefined,
+ exists: state.loaded && exists,
+ loaded: state.loaded,
+ canWrite,
+ isSaving: state.isSaving,
+ error: state.error,
+ setValue,
+ deleteValue
+ }
+}
+
+export function useSharedStore(prefix = '', options: SharedOptions = {}) {
+ const { mutate, state, canWrite } = useScope(options.scope)
+ const entries = useMemo(
+ () =>
+ Object.fromEntries(
+ Object.entries(state.entries)
+ .filter(([key]) => key.startsWith(prefix))
+ .map(([key, value]) => [key.slice(prefix.length), value])
+ ),
+ [state.entries, prefix]
+ )
+ const batch = useCallback(
+ (operations: CollabOperation[]) =>
+ mutate(operations.map(operation => ({ ...operation, key: prefix + operation.key }))),
+ [mutate, prefix]
+ )
+ const set = useCallback(
+ (key: string, value: CollabJsonValue) => batch([{ type: 'set', key, value }]),
+ [batch]
+ )
+ const remove = useCallback((key: string) => batch([{ type: 'delete', key }]), [batch])
+ return {
+ entries,
+ loaded: state.loaded,
+ canWrite,
+ isSaving: state.isSaving,
+ error: state.error,
+ set,
+ delete: remove,
+ batch
+ }
+}
diff --git a/client/features/collab/index.ts b/client/features/collab/index.ts
new file mode 100644
index 00000000..1600544e
--- /dev/null
+++ b/client/features/collab/index.ts
@@ -0,0 +1,23 @@
+import { Activity, Cursors, PresenceField, Selection, SyncStatus } from './components'
+import { useOthers, usePresence, useSelf, useSharedState, useSharedStore } from './hooks'
+
+export { CollabWorkspaceProvider, AppletCollabProvider } from './hooks'
+export { WorkspaceCollabControls } from './WorkspaceCollabControls'
+export { getIdentity, subscribeIdentityStore } from './identity'
+
+const appletApi = {
+ Activity,
+ Cursors,
+ PresenceField,
+ Selection,
+ SyncStatus,
+ useOthers,
+ usePresence,
+ useSelf,
+ useSharedState,
+ useSharedStore
+}
+
+export function createAppletCollabApi() {
+ return appletApi
+}
diff --git a/client/features/collab/people.test.ts b/client/features/collab/people.test.ts
new file mode 100644
index 00000000..c8957147
--- /dev/null
+++ b/client/features/collab/people.test.ts
@@ -0,0 +1,43 @@
+import { expect, test } from 'bun:test'
+
+import type { CollabParticipant } from '@/lib/collab/types'
+
+import { groupPeople } from './people'
+
+const ada = { id: 'ada', name: 'Ada', color: '#f59e0b' }
+const ken = { id: 'ken', name: 'Ken', color: '#3b82f6' }
+
+function connection(
+ connectionId: string,
+ identity: typeof ada,
+ page: string | null
+): CollabParticipant {
+ return { connectionId, identity, location: page ? { page } : null, presence: [] }
+}
+
+test('groups connections by person, puts the current user first, and merges tabs', () => {
+ const people = groupPeople(
+ [
+ connection('c2', ken, 'view:board'),
+ connection('c1', ada, 'overview'),
+ connection('c3', ken, 'agent'),
+ connection('c4', ada, 'scratchpad')
+ ],
+ { identity: ada, connectionId: 'c1', page: 'overview' }
+ )
+ expect(people.map(person => person.identity.name)).toEqual(['Ada', 'Ken'])
+ expect(people[0]).toMatchObject({ self: true, pages: ['overview', 'scratchpad'] })
+ expect(people[1]).toMatchObject({ self: false, pages: ['view:board', 'agent'] })
+})
+
+test('a person whose tabs are all hidden has no pages, and the local user appears before connecting', () => {
+ const people = groupPeople([connection('c2', ken, null)], {
+ identity: ada,
+ connectionId: null,
+ page: null
+ })
+ expect(people).toEqual([
+ { identity: ada, self: true, pages: [] },
+ { identity: ken, self: false, pages: [] }
+ ])
+})
diff --git a/client/features/collab/people.ts b/client/features/collab/people.ts
new file mode 100644
index 00000000..2f119795
--- /dev/null
+++ b/client/features/collab/people.ts
@@ -0,0 +1,32 @@
+import type { CollabIdentity, CollabParticipant } from '@/lib/collab/types'
+
+// One row per person for the people list: connections collapse by identity
+// id, the current user comes first, and `pages` lists the tabs their visible
+// browser tabs are on. No pages means every tab of theirs is hidden: away.
+export type Person = { identity: CollabIdentity; self: boolean; pages: string[] }
+
+export type SelfConnection = {
+ identity: CollabIdentity | null
+ connectionId: string | null
+ page: string | null
+}
+
+export function groupPeople(participants: CollabParticipant[], self: SelfConnection): Person[] {
+ const people = new Map()
+ if (self.identity) {
+ people.set(self.identity.id, {
+ identity: self.identity,
+ self: true,
+ pages: self.page ? [self.page] : []
+ })
+ }
+ for (const participant of participants) {
+ if (participant.connectionId === self.connectionId) continue
+ const id = participant.identity.id
+ const person = people.get(id) ?? { identity: participant.identity, self: false, pages: [] }
+ const page = participant.location?.page
+ if (page && !person.pages.includes(page)) person.pages.push(page)
+ people.set(id, person)
+ }
+ return [...people.values()]
+}
diff --git a/client/features/dev/DevIndexPage.tsx b/client/features/dev/DevIndexPage.tsx
index 7ef565b4..d34a838c 100644
--- a/client/features/dev/DevIndexPage.tsx
+++ b/client/features/dev/DevIndexPage.tsx
@@ -7,6 +7,11 @@ type DevRouteEntry = {
}
const DEV_ROUTES: DevRouteEntry[] = [
+ {
+ path: '/dev/collab',
+ title: 'Collab',
+ description: 'Give this tab a collab identity: a name, a color, and a generated avatar.'
+ },
{
path: '/dev/blobatar-shapes',
title: 'Blobatar shape builder',
diff --git a/client/features/dev/DevRoutes.tsx b/client/features/dev/DevRoutes.tsx
index bdfbcde1..6c2a595a 100644
--- a/client/features/dev/DevRoutes.tsx
+++ b/client/features/dev/DevRoutes.tsx
@@ -1,3 +1,4 @@
+import { lazy, Suspense } from 'react'
import { Route, Switch } from 'wouter'
import { ChatStatesPage } from './ChatStatesPage'
@@ -12,9 +13,18 @@ import { UiComponentsPage } from './UiComponentsPage'
// (and its dependencies) lives in this folder and loads as one lazy chunk —
// see the dynamic import in AppRouter. /dev itself is the index; list new
// routes there too.
+const DevCollabPage = lazy(() =>
+ import('../collab/DevCollabPage').then(module => ({ default: module.DevCollabPage }))
+)
+
export default function DevRoutes() {
return (
+
+
+
+
+
diff --git a/package.json b/package.json
index 284672f6..4b65b4e2 100644
--- a/package.json
+++ b/package.json
@@ -82,6 +82,7 @@
"culori": "^4.0.2",
"devalue": "^5.9.2",
"env-paths": "^4.0.0",
+ "facehash": "^0.1.0",
"frimousse": "^0.3.0",
"highlight.js": "^10.7.3",
"hono": "^4.13.7",
diff --git a/server/applets/build-applet.ts b/server/applets/build-applet.ts
index f4cbd3e0..2d01c4cc 100644
--- a/server/applets/build-applet.ts
+++ b/server/applets/build-applet.ts
@@ -1,3 +1,4 @@
+import { COLLAB_MODULE_SOURCE } from '../collab/applet-module'
import type { BunPlugin } from 'bun'
import tailwind from 'bun-plugin-tailwind'
import { realpathSync } from 'node:fs'
@@ -232,6 +233,14 @@ function appletRuntimePlugin(
}))
// The `moi` runtime module (fileUrl). A bare specifier, so match it exactly.
+ build.onResolve({ filter: /^moi\/collab$/ }, () => ({
+ path: 'moi/collab',
+ namespace: 'moi-collab'
+ }))
+ build.onLoad({ filter: /.*/, namespace: 'moi-collab' }, () => ({
+ contents: COLLAB_MODULE_SOURCE,
+ loader: 'js'
+ }))
build.onResolve({ filter: /^moi$/ }, () => ({ path: 'moi', namespace: 'moi-runtime' }))
build.onLoad({ filter: /.*/, namespace: 'moi-runtime' }, () => ({
contents: MOI_MODULE_SOURCE,
diff --git a/server/collab/applet-module.ts b/server/collab/applet-module.ts
new file mode 100644
index 00000000..f86f660e
--- /dev/null
+++ b/server/collab/applet-module.ts
@@ -0,0 +1,21 @@
+// Evaluated in the browser bundle only. All state and React contexts remain
+// in the host feature, shared by every applet and revoked with its bridge.
+export const COLLAB_MODULE_SOURCE = `
+import { createElement } from 'react';
+import { __getBridge } from 'moi';
+function api() {
+ const collab = __getBridge()?.collab;
+ if (!collab) throw new Error('Start moi with --experimental-collab to use collab, or reload this applet if its build was disposed.');
+ return collab;
+}
+export function useSelf(...args) { return api().useSelf(...args); }
+export function useOthers(...args) { return api().useOthers(...args); }
+export function usePresence(...args) { return api().usePresence(...args); }
+export function useSharedState(...args) { return api().useSharedState(...args); }
+export function useSharedStore(...args) { return api().useSharedStore(...args); }
+export function Cursors(props) { return createElement(api().Cursors, props); }
+export function Activity(props) { return createElement(api().Activity, props); }
+export function PresenceField(props) { return createElement(api().PresenceField, props); }
+export function Selection(props) { return createElement(api().Selection, props); }
+export function SyncStatus(props) { return createElement(api().SyncStatus, props); }
+`
diff --git a/server/collab/skill/index.test.ts b/server/collab/skill/index.test.ts
new file mode 100644
index 00000000..2c202326
--- /dev/null
+++ b/server/collab/skill/index.test.ts
@@ -0,0 +1,96 @@
+import { afterEach, expect, test } from 'bun:test'
+import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import type * as CollabApi from 'moi/collab'
+
+import type * as Components from '@/client/features/collab/components'
+import type * as Hooks from '@/client/features/collab/hooks'
+import type { WorkspaceType } from '@/lib/types'
+
+import { provisionWorkspace, skillsDirFor } from '../../workspace-init'
+import { COLLAB_REFERENCE_SOURCE_PATH, installCollabSkill, removeCollabSkill } from './index'
+
+type ActualHooks = Pick<
+ typeof Hooks,
+ 'useSelf' | 'useOthers' | 'usePresence' | 'useSharedState' | 'useSharedStore'
+>
+type ActualComponents = Pick<
+ typeof Components,
+ 'Activity' | 'Cursors' | 'PresenceField' | 'Selection' | 'SyncStatus'
+>
+// This assignment is checked by tsc without importing React into the server.
+const declarationsMatch: ActualHooks & ActualComponents extends typeof CollabApi ? true : false =
+ true
+
+const directories: string[] = []
+
+afterEach(async () => {
+ for (const directory of directories.splice(0))
+ await rm(directory, { recursive: true, force: true })
+})
+
+test('optional collab reference installs beside the default skill without changing it', async () => {
+ const workspace = await mkdtemp(join(tmpdir(), 'moi-collab-skill-'))
+ directories.push(workspace)
+ const installed = await installCollabSkill(workspace, 'codex')
+ expect(installed.referencePath).toBe(
+ join(workspace, '.agents', 'skills', 'moi-workspace', 'references', 'COLLABORATIVE.md')
+ )
+ expect(installed.typesPath).toBe(join(workspace, '.moi', 'collab-env.d.ts'))
+ expect(await readFile(installed.referencePath, 'utf8')).toBe(
+ await readFile(COLLAB_REFERENCE_SOURCE_PATH, 'utf8')
+ )
+ const defaultSkill = join(dirname(dirname(installed.referencePath)), 'SKILL.md')
+ const marker = '# Existing workspace skill\nLeave its authored instructions alone.\n'
+ await writeFile(defaultSkill, marker)
+ const before = await stat(installed.referencePath)
+ await installCollabSkill(workspace, 'codex')
+ expect((await stat(installed.referencePath)).mtimeMs).toBe(before.mtimeMs)
+ expect(await readFile(defaultSkill, 'utf8')).toBe(marker)
+ await removeCollabSkill(workspace, 'codex')
+ expect(await Bun.file(installed.referencePath).exists()).toBe(false)
+ expect(await Bun.file(installed.typesPath).exists()).toBe(false)
+ expect(await readFile(defaultSkill, 'utf8')).toBe(marker)
+})
+
+test('workspace declarations match the public hooks and components', () => {
+ expect(declarationsMatch).toBe(true)
+})
+
+test('optional guide uses each harness skill directory', async () => {
+ const types: WorkspaceType[] = ['claude-code', 'codex', 'openclaw', 'hermes']
+ for (const type of types) {
+ const workspace = await mkdtemp(join(tmpdir(), 'moi-collab-harness-'))
+ directories.push(workspace)
+ const { referencePath } = await installCollabSkill(workspace, type)
+ expect(referencePath).toBe(
+ join(skillsDirFor(workspace, type), 'moi-workspace', 'references', 'COLLABORATIVE.md')
+ )
+ expect(await Bun.file(referencePath).exists()).toBe(true)
+ }
+})
+
+test('ordinary workspace provisioning omits collab and preserves a manually installed guide', async () => {
+ const workspace = await mkdtemp(join(tmpdir(), 'moi-collab-provision-'))
+ directories.push(workspace)
+ // An existing scaffold skips dependency installation; this exercises the
+ // same provisioning path used by ordinary init and the UI.
+ await Bun.write(join(workspace, '.moi', 'package.json'), '{}\n')
+ await provisionWorkspace(workspace, 'codex')
+ const referencePath = join(
+ skillsDirFor(workspace, 'codex'),
+ 'moi-workspace',
+ 'references',
+ 'COLLABORATIVE.md'
+ )
+ const typesPath = join(workspace, '.moi', 'collab-env.d.ts')
+ expect(await Bun.file(referencePath).exists()).toBe(false)
+ expect(await Bun.file(typesPath).exists()).toBe(false)
+ await installCollabSkill(workspace, 'codex')
+ const reference = await Bun.file(referencePath).text()
+ const declarations = await Bun.file(typesPath).text()
+ await provisionWorkspace(workspace, 'codex')
+ expect(await Bun.file(referencePath).text()).toBe(reference)
+ expect(await Bun.file(typesPath).text()).toBe(declarations)
+})
From 7c80a7200c0ec3a0ae13172cef6de5e3913ddf53 Mon Sep 17 00:00:00 2001
From: Alexey Taktarov
Date: Tue, 15 Sep 2026 15:20:28 +0200
Subject: [PATCH 05/86] Integrate collab with workspace views and personal
navigation
---
client/app/routes/WorkspaceRoute.tsx | 27 +-
client/features/applets/AppletMount.tsx | 9 +-
client/features/applets/applet-runtime.ts | 5 +
client/features/chat/useSelectedSession.ts | 82 ++++--
client/features/collab/personal-state.test.ts | 256 ++++++++++++++++++
client/features/collab/personal-state.ts | 61 +++++
client/features/views/ViewManager.tsx | 9 +-
.../workspace/WorkspaceLayoutContext.tsx | 21 +-
client/features/workspace/WorkspaceScreen.tsx | 33 ++-
client/features/workspace/api.ts | 14 +-
client/features/workspace/layout-save.test.ts | 67 +++++
client/features/workspace/layout-save.ts | 13 +
client/features/workspace/moi-context.ts | 9 +-
.../workspace/useWorkspaceNavigation.ts | 15 +-
lib/types.ts | 2 +
server/layout.ts | 17 +-
server/test/layout.test.ts | 50 ++++
17 files changed, 643 insertions(+), 47 deletions(-)
create mode 100644 client/features/collab/personal-state.test.ts
create mode 100644 client/features/collab/personal-state.ts
create mode 100644 client/features/workspace/layout-save.test.ts
create mode 100644 client/features/workspace/layout-save.ts
diff --git a/client/app/routes/WorkspaceRoute.tsx b/client/app/routes/WorkspaceRoute.tsx
index 74a40032..3cfdf332 100644
--- a/client/app/routes/WorkspaceRoute.tsx
+++ b/client/app/routes/WorkspaceRoute.tsx
@@ -1,3 +1,4 @@
+import { CollabGate, useCollabIdentityEnabled } from '@/client/features/collab/entry'
import { useQueryClient } from '@tanstack/react-query'
import { workspaceKeys } from '@/client/api/workspace-keys'
@@ -24,13 +25,35 @@ type WorkspaceRouteProps = {
export function WorkspaceRoute({ id }: WorkspaceRouteProps) {
return (
-
-
+
+
)
}
+function WorkspaceFeatures({ id }: WorkspaceRouteProps) {
+ const { collab, isLoading } = useWorkspaceLayoutCtx()
+ if (isLoading)
+ return (
+
+
+
+
+
+ )
+ return (
+
+
+
+ )
+}
+
+function WorkspaceIdentity({ id }: WorkspaceRouteProps) {
+ const personal = useCollabIdentityEnabled()
+ return
+}
+
function WorkspaceLoader({ id }: WorkspaceRouteProps) {
const queryClient = useQueryClient()
const [selectedSessionId] = useSelectedSession()
diff --git a/client/features/applets/AppletMount.tsx b/client/features/applets/AppletMount.tsx
index dcdd0c3c..127712e3 100644
--- a/client/features/applets/AppletMount.tsx
+++ b/client/features/applets/AppletMount.tsx
@@ -1,5 +1,6 @@
import { type ReactNode } from 'react'
+import { AppletCollabMount } from '@/client/features/collab/entry'
import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext'
import { type AppletSegment, appletScope, appletStyleKey } from './applet-cache'
@@ -25,7 +26,13 @@ export function AppletMount({ segment, name, version, children }: AppletMountPro
return (
- {children}
+
+ {children}
+
)
}
diff --git a/client/features/applets/applet-runtime.ts b/client/features/applets/applet-runtime.ts
index dfb0137f..0b89805a 100644
--- a/client/features/applets/applet-runtime.ts
+++ b/client/features/applets/applet-runtime.ts
@@ -14,6 +14,7 @@
// — 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 { getAppletCollabApi } from '@/client/features/collab/entry'
import { useEffect } from 'react'
import { createNanoEvents } from 'nanoevents'
@@ -54,6 +55,7 @@ export type AppletEvents = {
// cross the trust boundary from agent-authored code, and the runtime narrows
// them before emitting.
export type AppletBridge = {
+ collab?: ReturnType
focusTab: (tab: unknown, params?: unknown) => void
sendChatMessage: (message: unknown, context?: unknown) => void
}
@@ -124,6 +126,9 @@ function createRuntime(workspaceId: string) {
let alive = true
const source = appletSource(identity)
const bridge: AppletBridge = {
+ get collab() {
+ return getAppletCollabApi()
+ },
focusTab(tab, params) {
if (!alive) return
if (!isWorkspaceTabId(tab)) return
diff --git a/client/features/chat/useSelectedSession.ts b/client/features/chat/useSelectedSession.ts
index c1ed38d2..13c3d6b6 100644
--- a/client/features/chat/useSelectedSession.ts
+++ b/client/features/chat/useSelectedSession.ts
@@ -1,3 +1,5 @@
+import { useCollabIdentityEnabled } from '@/client/features/collab/entry'
+import { readPersonalSession, writePersonalSession } from '@/client/features/collab/personal-state'
import { useCallback, useMemo } from 'react'
import { useIsMutating, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
@@ -24,16 +26,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, personal = false) {
+ const key = appUiKeys.selectedSession(workspaceId)
+ return personal ? ([...key, 'collab-tab'] as const) : key
+}
+
+function selectedSessionMutationKey(workspaceId: string, personal: boolean) {
+ return [...selectedSessionKey(workspaceId, personal), 'save'] as const
}
export function optimisticallySetSelectedSession(
queryClient: QueryClient,
workspaceId: string,
- sessionId: string | null
+ sessionId: string | null,
+ personal = false
): SaveSelectedSessionInput | null {
- const queryKey = appUiKeys.selectedSession(workspaceId)
+ const queryKey = selectedSessionKey(workspaceId, personal)
const current = queryClient.getQueryData(queryKey)
const previousSessionId = current?.sessionId ?? null
if (current && previousSessionId === sessionId) return null
@@ -46,9 +54,10 @@ export function settleSelectedSessionSave(
queryClient: QueryClient,
workspaceId: string,
saved: SelectedSessionState,
- input: SaveSelectedSessionInput
+ input: SaveSelectedSessionInput,
+ personal = false
): SelectedSessionSaveResult {
- const queryKey = appUiKeys.selectedSession(workspaceId)
+ const queryKey = selectedSessionKey(workspaceId, personal)
const current = queryClient.getQueryData(queryKey)
if (saved.sessionId !== input.sessionId && current?.sessionId !== saved.sessionId) {
@@ -80,16 +89,27 @@ export function renameSelectedSessionInCache(
from: string,
to: string
): void {
- queryClient.setQueryData(appUiKeys.selectedSession(workspaceId), current =>
- current?.sessionId === from ? { sessionId: to } : current
- )
+ for (const personal of [false, true]) {
+ queryClient.setQueryData(
+ selectedSessionKey(workspaceId, personal),
+ current => (current?.sessionId === from ? { sessionId: to } : current)
+ )
+ }
+ if (readPersonalSession(workspaceId) === from) writePersonalSession(workspaceId, to)
}
export function useSelectedSession(): SelectedSessionResult {
const workspaceId = useWorkspaceId()
+ const collabEnabled = useCollabIdentityEnabled()
const queryClient = useQueryClient()
- const queryKey = useMemo(() => appUiKeys.selectedSession(workspaceId), [workspaceId])
- const mutationKey = useMemo(() => selectedSessionMutationKey(workspaceId), [workspaceId])
+ const queryKey = useMemo(
+ () => selectedSessionKey(workspaceId, collabEnabled),
+ [workspaceId, collabEnabled]
+ )
+ const mutationKey = useMemo(
+ () => selectedSessionMutationKey(workspaceId, collabEnabled),
+ [workspaceId, collabEnabled]
+ )
const pendingSaves = useIsMutating({ mutationKey, exact: true })
// WorkspaceLoader remains an observer for the active workspace. Nested hook
@@ -97,7 +117,10 @@ export function useSelectedSession(): SelectedSessionResult {
// the cache makes the next visit load the server-owned selection again.
const query = useQuery({
queryKey,
- queryFn: () => requestJson(`/api/workspaces/${workspaceId}/selected-session`),
+ queryFn: () =>
+ collabEnabled
+ ? Promise.resolve({ sessionId: readPersonalSession(workspaceId) })
+ : requestJson(`/api/workspaces/${workspaceId}/selected-session`),
staleTime: Infinity,
gcTime: 0,
refetchOnMount: false,
@@ -111,14 +134,25 @@ export function useSelectedSession(): SelectedSessionResult {
>({
mutationKey,
scope: { id: `selected-session:${workspaceId}` },
- mutationFn: input =>
- requestJson(
+ mutationFn: input => {
+ if (collabEnabled) {
+ writePersonalSession(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,
+ collabEnabled
+ )
if (result !== 'conflict') return
toast.add({ title: 'Couldn’t save selected chat', type: 'error' })
@@ -135,14 +169,24 @@ export function useSelectedSession(): SelectedSessionResult {
const setSelectedSessionId = useCallback(
sessionId => {
- const input = optimisticallySetSelectedSession(queryClient, workspaceId, sessionId)
+ const input = optimisticallySetSelectedSession(
+ queryClient,
+ workspaceId,
+ sessionId,
+ collabEnabled
+ )
if (input) saveSelectedSession(input)
},
- [queryClient, saveSelectedSession, workspaceId]
+ [queryClient, saveSelectedSession, workspaceId, collabEnabled]
)
useWorkspaceEvent(event => {
- if (event.type !== 'selected-session:updated' || event.workspaceId !== workspaceId) return
+ if (
+ collabEnabled ||
+ event.type !== 'selected-session:updated' ||
+ event.workspaceId !== workspaceId
+ )
+ return
applySelectedSessionEvent(queryClient, workspaceId, event.sessionId, pendingSaves > 0)
})
diff --git a/client/features/collab/personal-state.test.ts b/client/features/collab/personal-state.test.ts
new file mode 100644
index 00000000..06e0d964
--- /dev/null
+++ b/client/features/collab/personal-state.test.ts
@@ -0,0 +1,256 @@
+import { afterEach, describe, expect, test } from 'bun:test'
+import { QueryClient, QueryObserver } from '@tanstack/react-query'
+
+import {
+ applySelectedSessionEvent,
+ optimisticallySetSelectedSession,
+ renameSelectedSessionInCache,
+ selectedSessionKey,
+ settleSelectedSessionSave
+} from '@/client/features/chat/useSelectedSession'
+import type { SelectedSessionState, WorkspaceTabsState } from '@/lib/types'
+import { createDefaultWorkspaceLayout } from '@/lib/workspace-layout'
+import { mergeLayoutForSave } from '@/server/layout'
+
+import {
+ readPersonalSession,
+ readPersonalTabs,
+ writePersonalSession,
+ writePersonalTabs
+} from './personal-state'
+
+const originalStorage = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage')
+
+function browserTab(): Storage {
+ const data = new Map()
+ return {
+ get length() {
+ return data.size
+ },
+ clear: () => data.clear(),
+ key: index => [...data.keys()][index] ?? null,
+ getItem: key => data.get(key) ?? null,
+ setItem: (key, value) => {
+ data.set(key, value)
+ },
+ removeItem: key => {
+ data.delete(key)
+ }
+ }
+}
+
+function useBrowserTab(storage: Storage) {
+ Object.defineProperty(globalThis, 'sessionStorage', { configurable: true, value: storage })
+}
+
+afterEach(() => {
+ if (originalStorage) Object.defineProperty(globalThis, 'sessionStorage', originalStorage)
+ else Reflect.deleteProperty(globalThis, 'sessionStorage')
+})
+
+const defaults: WorkspaceTabsState = { open: ['overview', 'view:board'], active: 'overview' }
+
+describe('collab personal state', () => {
+ test('two browser tabs select chats independently, including New chat', () => {
+ const anna = browserTab()
+ const boris = browserTab()
+ useBrowserTab(anna)
+ writePersonalSession('workspace', 'annas-chat')
+ useBrowserTab(boris)
+ expect(readPersonalSession('workspace')).toBeNull()
+ writePersonalSession('workspace', 'boris-chat')
+ useBrowserTab(anna)
+ expect(readPersonalSession('workspace')).toBe('annas-chat')
+ writePersonalSession('workspace', null)
+ expect(readPersonalSession('workspace')).toBeNull()
+ useBrowserTab(boris)
+ expect(readPersonalSession('workspace')).toBe('boris-chat')
+ })
+
+ test('personal chat and view choices survive reads and stay partitioned by workspace', () => {
+ useBrowserTab(browserTab())
+ const selected: WorkspaceTabsState = { open: ['overview', 'view:board'], active: 'view:board' }
+ writePersonalSession('one', 'chat-one')
+ writePersonalSession('two', 'chat-two')
+ writePersonalTabs('one', selected)
+ expect(readPersonalSession('one')).toBe('chat-one')
+ expect(readPersonalSession('two')).toBe('chat-two')
+ expect(readPersonalTabs('one', defaults)).toEqual(selected)
+ expect(readPersonalTabs('two', defaults)).toEqual(defaults)
+ expect(defaults.active).toBe('overview')
+ })
+
+ test('local view selection does not change another tab or the authored defaults', () => {
+ const anna = browserTab()
+ const boris = browserTab()
+ useBrowserTab(anna)
+ writePersonalTabs('workspace', { open: ['overview', 'view:board'], active: 'view:board' })
+ useBrowserTab(boris)
+ expect(readPersonalTabs('workspace', defaults)).toEqual(defaults)
+ writePersonalTabs('workspace', { open: ['overview', 'scratchpad'], active: 'scratchpad' })
+ useBrowserTab(anna)
+ expect(readPersonalTabs('workspace', defaults).active).toBe('view:board')
+ expect(defaults).toEqual({ open: ['overview', 'view:board'], active: 'overview' })
+ })
+
+ test('malformed saved tabs fall back safely and stored tab lists are normalized', () => {
+ const storage = browserTab()
+ useBrowserTab(storage)
+ storage.setItem('moi:collab:workspace:tabs', '{broken')
+ expect(readPersonalTabs('workspace', defaults)).toEqual(defaults)
+ storage.setItem(
+ 'moi:collab:workspace:tabs',
+ JSON.stringify({
+ open: ['view:board', 'view:board', 'not-a-tab'],
+ active: 'not-a-tab'
+ })
+ )
+ expect(readPersonalTabs('workspace', defaults)).toEqual({
+ open: ['overview', 'view:board'],
+ active: 'overview'
+ })
+ })
+
+ test('denied browser storage does not crash personal selection', () => {
+ Object.defineProperty(globalThis, 'sessionStorage', {
+ configurable: true,
+ get: () => {
+ throw new Error('Storage disabled')
+ }
+ })
+ expect(readPersonalSession('workspace')).toBeNull()
+ expect(readPersonalTabs('workspace', defaults)).toEqual(defaults)
+ expect(() => writePersonalSession('workspace', 'chat')).not.toThrow()
+ expect(() => writePersonalTabs('workspace', defaults)).not.toThrow()
+ })
+})
+
+describe('collab authored layout preservation', () => {
+ test('saving shared layout after personal navigation preserves authored tab defaults', () => {
+ useBrowserTab(browserTab())
+ const existing = {
+ ...createDefaultWorkspaceLayout(),
+ tabs: defaults
+ }
+ const personal: WorkspaceTabsState = {
+ open: ['overview', 'scratchpad'],
+ active: 'scratchpad'
+ }
+ writePersonalTabs('workspace', personal)
+ const { tabs: _tabs, ...layout } = existing
+ const merged = mergeLayoutForSave(existing, { ...layout, layoutMode: 'fullscreen' })
+ expect(merged.tabs).toEqual(defaults)
+ expect(merged.layoutMode).toBe('fullscreen')
+ expect(readPersonalTabs('workspace', defaults)).toEqual(personal)
+ })
+
+ test('ordinary workspace saves retain their existing shared tab behavior', () => {
+ const existing = createDefaultWorkspaceLayout()
+ const tabs: WorkspaceTabsState = { open: ['overview', 'scratchpad'], active: 'scratchpad' }
+ expect(mergeLayoutForSave(existing, { ...existing, tabs }).tabs).toEqual(tabs)
+ })
+})
+
+describe('collab selected chat cache transitions', () => {
+ test('supplying an identity immediately remounts onto the saved tab chat before shared cache GC', async () => {
+ useBrowserTab(browserTab())
+ writePersonalSession('workspace', 'saved-tab-chat')
+ const client = new QueryClient()
+ const sharedKey = selectedSessionKey('workspace')
+ client.setQueryData(sharedKey, { sessionId: 'old-shared-chat' })
+ const options = { staleTime: Infinity, gcTime: 0, refetchOnMount: false as const }
+ const shared = new QueryObserver(client, {
+ ...options,
+ queryKey: sharedKey,
+ queryFn: async () => ({ sessionId: 'old-shared-chat' })
+ })
+ const stopShared = shared.subscribe(() => {})
+ stopShared()
+ let reads = 0
+ const personal = new QueryObserver(client, {
+ ...options,
+ queryKey: selectedSessionKey('workspace', true),
+ queryFn: async () => {
+ reads++
+ return { sessionId: readPersonalSession('workspace') }
+ }
+ })
+ let stopPersonal = () => {}
+ try {
+ // Do not wait for gcTime: a keyed React remount adds its new observer in
+ // the same commit, before the old cache's zero-delay GC timer can run.
+ const selected = await new Promise(resolve => {
+ const inspect = () => {
+ const result = personal.getCurrentResult()
+ if (result.isSuccess) resolve(result.data)
+ }
+ stopPersonal = personal.subscribe(inspect)
+ inspect()
+ })
+ expect(selected).toEqual({ sessionId: 'saved-tab-chat' })
+ expect(reads).toBe(1)
+ } finally {
+ stopPersonal()
+ client.clear()
+ }
+ })
+
+ test('a late shared save and remote selection event cannot replace the current personal chat', () => {
+ const client = new QueryClient()
+ try {
+ client.setQueryData(selectedSessionKey('workspace'), { sessionId: 'old-shared-chat' })
+ const pendingShared = optimisticallySetSelectedSession(
+ client,
+ 'workspace',
+ 'in-flight-shared-chat'
+ )
+ const personal = optimisticallySetSelectedSession(client, 'workspace', 'personal-chat', true)
+ if (!pendingShared || !personal) throw new Error('Expected pending selections')
+ settleSelectedSessionSave(
+ client,
+ 'workspace',
+ { sessionId: 'in-flight-shared-chat' },
+ pendingShared
+ )
+ applySelectedSessionEvent(client, 'workspace', 'remote-chat', false)
+ expect(
+ client.getQueryData(selectedSessionKey('workspace', true))
+ ).toEqual({
+ sessionId: 'personal-chat'
+ })
+ expect(client.getQueryData(selectedSessionKey('workspace'))).toEqual({
+ sessionId: 'remote-chat'
+ })
+ settleSelectedSessionSave(client, 'workspace', { sessionId: 'personal-chat' }, personal, true)
+ expect(
+ client.getQueryData(selectedSessionKey('workspace', true))
+ ).toEqual({
+ sessionId: 'personal-chat'
+ })
+ } finally {
+ client.clear()
+ }
+ })
+
+ test('a session id replacement follows the selected personal chat into saved tab state', () => {
+ useBrowserTab(browserTab())
+ const client = new QueryClient()
+ try {
+ writePersonalSession('workspace', 'temporary-id')
+ client.setQueryData(selectedSessionKey('workspace', true), { sessionId: 'temporary-id' })
+ client.setQueryData(selectedSessionKey('workspace'), { sessionId: 'different-shared-chat' })
+ renameSelectedSessionInCache(client, 'workspace', 'temporary-id', 'provider-id')
+ expect(readPersonalSession('workspace')).toBe('provider-id')
+ expect(
+ client.getQueryData(selectedSessionKey('workspace', true))
+ ).toEqual({
+ sessionId: 'provider-id'
+ })
+ expect(client.getQueryData(selectedSessionKey('workspace'))).toEqual({
+ sessionId: 'different-shared-chat'
+ })
+ } finally {
+ client.clear()
+ }
+ })
+})
diff --git a/client/features/collab/personal-state.ts b/client/features/collab/personal-state.ts
new file mode 100644
index 00000000..86c07297
--- /dev/null
+++ b/client/features/collab/personal-state.ts
@@ -0,0 +1,61 @@
+import { useCallback, useState } from 'react'
+
+import type { WorkspaceTabsState } from '@/lib/types'
+import { normalizeWorkspaceTabs } from '@/lib/workspace-layout'
+
+const keyFor = (workspaceId: string, field: string) => `moi:collab:${workspaceId}:${field}`
+
+export function readPersonalSession(workspaceId: string): string | null {
+ try {
+ return sessionStorage.getItem(keyFor(workspaceId, 'session'))
+ } catch {
+ return null
+ }
+}
+
+export function writePersonalSession(workspaceId: string, sessionId: string | null): void {
+ try {
+ if (sessionId === null) sessionStorage.removeItem(keyFor(workspaceId, 'session'))
+ else sessionStorage.setItem(keyFor(workspaceId, 'session'), sessionId)
+ } catch {
+ /* A private browser can still keep the active query in memory. */
+ }
+}
+
+export function readPersonalTabs(
+ workspaceId: string,
+ defaults: WorkspaceTabsState
+): WorkspaceTabsState {
+ try {
+ const saved = sessionStorage.getItem(keyFor(workspaceId, 'tabs'))
+ return saved ? normalizeWorkspaceTabs(JSON.parse(saved)) : defaults
+ } catch {
+ return defaults
+ }
+}
+
+export function writePersonalTabs(workspaceId: string, tabs: WorkspaceTabsState): void {
+ try {
+ sessionStorage.setItem(keyFor(workspaceId, 'tabs'), JSON.stringify(tabs))
+ } catch {
+ // The hook retains its in-memory selection when browser storage is denied.
+ }
+}
+
+export function usePersonalTabs(
+ workspaceId: string,
+ enabled: boolean,
+ defaults: WorkspaceTabsState
+) {
+ const [local, setLocal] = useState(() =>
+ enabled ? readPersonalTabs(workspaceId, defaults) : defaults
+ )
+ const setTabs = useCallback(
+ (tabs: WorkspaceTabsState) => {
+ setLocal(tabs)
+ writePersonalTabs(workspaceId, tabs)
+ },
+ [workspaceId]
+ )
+ return [enabled ? local : defaults, setTabs] as const
+}
diff --git a/client/features/views/ViewManager.tsx b/client/features/views/ViewManager.tsx
index cd56e81f..9e454b77 100644
--- a/client/features/views/ViewManager.tsx
+++ b/client/features/views/ViewManager.tsx
@@ -25,6 +25,7 @@ import {
useView
} from '@/client/features/applets/useApplet'
import { WidgetErrorBoundary } from '@/client/features/applets/WidgetErrorBoundary'
+import { AppletCollabMount } from '@/client/features/collab/entry'
import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext'
import { cn } from '@/client/lib/cn'
import { useLatestRef } from '@/client/lib/use-latest-ref'
@@ -227,7 +228,13 @@ function ViewFrame({ view, build, params, entering, thumbnailTarget }: ViewFrame
workspaceId={workspaceId}
resetKey={build.version}
>
-
+
+
+
diff --git a/client/features/workspace/WorkspaceLayoutContext.tsx b/client/features/workspace/WorkspaceLayoutContext.tsx
index 8c58a831..b806314c 100644
--- a/client/features/workspace/WorkspaceLayoutContext.tsx
+++ b/client/features/workspace/WorkspaceLayoutContext.tsx
@@ -9,9 +9,11 @@ import {
} from '@/client/features/workspace/api'
import { workspaceKeys } from '@/client/api/workspace-keys'
import { useLatestRef } from '@/client/lib/use-latest-ref'
-import type { WorkspaceLayout, WorkspaceType } from '@/lib/types'
+import type { WorkspaceLayout, WorkspaceLayoutSave, WorkspaceType } from '@/lib/types'
import { createDefaultWorkspaceLayout } from '@/lib/workspace-layout'
+import { accumulateLayoutSave } from './layout-save'
+
export type WorkspaceLayoutContextValue = {
// The persisted layout (widget grid, layout mode, theme). Falls back to an
// empty default while the query is still loading.
@@ -29,6 +31,7 @@ export type WorkspaceLayoutContextValue = {
// The workspace's registry id (the route param), so descendants can key
// their own queries (e.g. the model picker) without prop-drilling.
workspaceId: string
+ collab?: WorkspaceLayoutResponse['collab']
isLoading: boolean
}
@@ -53,7 +56,14 @@ export function useWorkspaceThemeSetting(): WorkspaceLayout['theme'] {
// Strip the server-only metadata so what we PUT back (and expose as `layout`)
// is just the persisted `WorkspaceLayout`.
function stripMeta(data: WorkspaceLayoutResponse): WorkspaceLayout {
- const { cwd: _cwd, name: _name, provider: _provider, agentId: _agentId, ...layout } = data
+ const {
+ cwd: _cwd,
+ name: _name,
+ provider: _provider,
+ agentId: _agentId,
+ collab: _collab,
+ ...layout
+ } = data
return layout
}
@@ -73,6 +83,7 @@ export function WorkspaceLayoutProvider({ id, children }: WorkspaceLayoutProvide
// reconcile), so reach the latest mutate via a ref instead of closing over it.
const saveRef = useLatestRef(save.mutate)
const timer = useRef | null>(null)
+ const pendingSave = useRef(null)
const setLayout = useCallback(
(update: Partial) => {
@@ -82,6 +93,7 @@ export function WorkspaceLayoutProvider({ id, children }: WorkspaceLayoutProvide
// Optimistic: the grid/theme reflects the change before the PUT lands.
const next = { ...prev, ...update }
qc.setQueryData(key, next)
+ pendingSave.current = accumulateLayoutSave(stripMeta(next), update, pendingSave.current)
if (timer.current) clearTimeout(timer.current)
// Persist the value captured at call time, NOT the cache at fire time: a
@@ -92,7 +104,9 @@ export function WorkspaceLayoutProvider({ id, children }: WorkspaceLayoutProvide
// optimistic write.
timer.current = setTimeout(() => {
timer.current = null
- saveRef.current(stripMeta(next))
+ const payload = pendingSave.current
+ pendingSave.current = null
+ if (payload) saveRef.current(payload)
}, 600)
},
[id, qc, saveRef]
@@ -110,6 +124,7 @@ export function WorkspaceLayoutProvider({ id, children }: WorkspaceLayoutProvide
cwd: query.data?.cwd ?? null,
provider: query.data?.provider ?? null,
workspaceId: id,
+ collab: query.data?.collab,
isLoading: query.isLoading
}),
[query.data, query.isLoading, setLayout, id]
diff --git a/client/features/workspace/WorkspaceScreen.tsx b/client/features/workspace/WorkspaceScreen.tsx
index 2f602d72..1485a8f6 100644
--- a/client/features/workspace/WorkspaceScreen.tsx
+++ b/client/features/workspace/WorkspaceScreen.tsx
@@ -22,6 +22,7 @@ import { ChatPopup } from '@/client/features/chat/ChatPopup'
import { ThemePanel } from '@/client/features/workspace/ThemePanel'
import { useAppletEvent } from '@/client/features/applets/applet-runtime'
import { Overview } from '@/client/features/overview/Overview'
+import { CollabControls, useCollabIdentityEnabled } from '@/client/features/collab/entry'
import { PanelHeader } from '@/client/components/shared/PanelHeader'
import { WorkspaceIcon } from '@/client/components/shared/WorkspaceIcon'
import { Button } from '@/client/components/ui/button'
@@ -217,6 +218,7 @@ function WorkspaceMenu({ onOpenTheme }: WorkspaceMenuProps) {
export function WorkspaceScreen({ widgets, views, builders }: WorkspaceScreenProps) {
const { layout, setLayout, workspaceId } = useWorkspaceLayoutCtx()
+ const collabIdentityEnabled = useCollabIdentityEnabled()
const theme = resolveWorkspaceTheme(layout.theme)
const builderActions = useViewBuilderActions()
const {
@@ -242,9 +244,11 @@ export function WorkspaceScreen({ widgets, views, builders }: WorkspaceScreenPro
// Split needs the open set to decide whether it's available at all, and the
// navigation hook needs split to resolve the active tab — so the open set is
// derived from the raw layout here, before either.
- const openTabIds = effectiveOpenTabs(normalizeTabsState(layout.tabs), views, builders)
- const nonAgentOpenTabs = openTabIds.filter(tab => tab !== 'agent')
- const hasWorkspaceContent = nonAgentOpenTabs.length > 0
+ const hasWorkspaceContent = effectiveOpenTabs(
+ normalizeTabsState(layout.tabs),
+ views,
+ builders
+ ).some(tab => tab !== 'agent')
const hasAppletWidgets = widgets.some(widget => !isDefaultWidget(widget.id))
const hasWorkspaceApplets = hasAppletWidgets || views.length > 0
@@ -289,6 +293,8 @@ export function WorkspaceScreen({ widgets, views, builders }: WorkspaceScreenPro
}
)
+ const openTabIds = effectiveOpenTabs(tabsState, views, builders)
+ const nonAgentOpenTabs = openTabIds.filter(tab => tab !== 'agent')
const openSet = new Set(tabsState.open)
// Entering split with the agent tab on screen needs no special-casing
@@ -347,13 +353,11 @@ export function WorkspaceScreen({ widgets, views, builders }: WorkspaceScreenPro
const open = tabsState.open.filter(tab => tabAvailable(tab, views, builders))
if (open.length === tabsState.open.length) return
const nextOpen = effectiveOpenTabs(tabsState, views, builders)
- setLayout({
- tabs: {
- open: nextOpen,
- active: nextOpen.includes(tabsState.active) ? tabsState.active : nextOpen[0]
- }
+ setTabs({
+ open: nextOpen,
+ active: nextOpen.includes(tabsState.active) ? tabsState.active : nextOpen[0]
})
- }, [builders, setLayout, tabsState, views])
+ }, [builders, setTabs, tabsState, views])
useEffect(() => {
const replacements = new Map()
@@ -387,8 +391,8 @@ export function WorkspaceScreen({ widgets, views, builders }: WorkspaceScreenPro
}
if (!changed) return
const active = replacements.get(tabsState.active) ?? tabsState.active
- setLayout({ tabs: { open: open.length > 0 ? open : ['overview'], active } })
- }, [activeTab, builders, navigateToTab, setLayout, tabsState, views])
+ setTabs({ open: open.length > 0 ? open : ['overview'], active })
+ }, [activeTab, builders, navigateToTab, setTabs, tabsState, views])
useEffect(() => {
if (mode !== 'fullscreen' || activeTab === 'agent') {
@@ -438,7 +442,7 @@ export function WorkspaceScreen({ widgets, views, builders }: WorkspaceScreenPro
// `moi tabs focus` — a workspace event, not an applet call: the control
// server validated the target and params before publishing.
useWorkspaceEvent(event => {
- if (event.type === 'tab:focus' && event.workspaceId === workspaceId) {
+ if (event.type === 'tab:focus' && event.workspaceId === workspaceId && !collabIdentityEnabled) {
openTab(event.tab, event.params)
} else if (
event.type === 'view:deleted' &&
@@ -740,6 +744,11 @@ export function WorkspaceScreen({ widgets, views, builders }: WorkspaceScreenPro
onReorder={reorderTabs}
/>
+ tabItemFor(tab, views, builders, false, false, () => false)}
+ onOpenTab={openTab}
+ />
{hasWorkspaceContent && canUseSplit && mode === 'fullscreen' && (
+ onWorkspaceEventsReconnect(() => {
+ void queryClient.invalidateQueries({ queryKey: workspaceKeys.layout(workspaceId) })
+ }),
+ [queryClient, workspaceId]
+ )
return useQuery({
queryKey: workspaceKeys.layout(workspaceId),
queryFn: () => requestJson(`/api/workspaces/${workspaceId}`),
@@ -122,7 +134,7 @@ export function useUpdateWorkspaceSkills(workspaceId: string) {
export function useSaveLayout(workspaceId: string) {
const queryClient = useQueryClient()
- return useMutation({
+ return useMutation({
mutationFn: layout =>
requestVoid(
`/api/workspaces/${workspaceId}`,
diff --git a/client/features/workspace/layout-save.test.ts b/client/features/workspace/layout-save.test.ts
new file mode 100644
index 00000000..30f4952d
--- /dev/null
+++ b/client/features/workspace/layout-save.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, test } from 'bun:test'
+
+import type { WorkspaceTabsState, WorkspaceTheme } from '@/lib/types'
+import { createDefaultWorkspaceLayout } from '@/lib/workspace-layout'
+import { mergeLayoutForSave } from '@/server/layout'
+
+import { accumulateLayoutSave } from './layout-save'
+
+const initial = createDefaultWorkspaceLayout()
+const selected: WorkspaceTabsState = { open: ['overview', 'view:board'], active: 'view:board' }
+
+describe('debounced layout saves', () => {
+ test('grid and theme edits omit cached tabs, preserving newer server navigation', () => {
+ const update = { widgetGrid: [{ i: 'board', x: 1, y: 2 }] }
+ const pending = accumulateLayoutSave({ ...initial, ...update }, update, null)
+ const theme: WorkspaceTheme = {
+ font: 'sans',
+ color: 'rose',
+ radius: 'square',
+ agent: 'dorito'
+ }
+ const payload = accumulateLayoutSave({ ...initial, ...update, theme }, { theme }, pending)
+
+ expect('tabs' in payload).toBe(false)
+ const saved = mergeLayoutForSave({ ...initial, tabs: selected }, payload)
+ expect(saved.tabs).toEqual(selected)
+ expect(saved.widgetGrid).toEqual(update.widgetGrid)
+ expect(saved.theme).toEqual(theme)
+ })
+
+ test('navigation followed by a grid edit keeps both changes in the same save', () => {
+ const navigated = { ...initial, tabs: selected }
+ const pending = accumulateLayoutSave(navigated, { tabs: selected }, null)
+ const update = { widgetGrid: [{ i: 'board', x: 2, y: 3 }] }
+ const payload = accumulateLayoutSave({ ...navigated, ...update }, update, pending)
+
+ expect(mergeLayoutForSave(initial, payload)).toEqual({ ...navigated, ...update })
+ })
+
+ test('a refetch between navigation and another edit cannot erase pending navigation', () => {
+ const pending = accumulateLayoutSave({ ...initial, tabs: selected }, { tabs: selected }, null)
+ // The layout query refetched the old server state before the debounce fired.
+ const update = { layoutMode: 'fullscreen' as const }
+ const payload = accumulateLayoutSave({ ...initial, ...update }, update, pending)
+
+ expect(payload.tabs).toEqual(selected)
+ expect(payload.layoutMode).toBe('fullscreen')
+ })
+
+ test('the latest explicit navigation replaces the previous pending tab selection', () => {
+ const pending = accumulateLayoutSave({ ...initial, tabs: selected }, { tabs: selected }, null)
+ const tabs: WorkspaceTabsState = { open: ['overview', 'agent'], active: 'agent' }
+ const payload = accumulateLayoutSave({ ...initial, tabs }, { tabs }, pending)
+
+ expect(payload.tabs).toEqual(tabs)
+ expect(pending.tabs).toEqual(selected)
+ })
+
+ test('a new debounce window does not resend previously saved tabs', () => {
+ const saved = { ...initial, tabs: selected }
+ const update = { layoutMode: 'fullscreen' as const }
+ const payload = accumulateLayoutSave({ ...saved, ...update }, update, null)
+
+ expect('tabs' in payload).toBe(false)
+ expect(payload.layoutMode).toBe('fullscreen')
+ })
+})
diff --git a/client/features/workspace/layout-save.ts b/client/features/workspace/layout-save.ts
new file mode 100644
index 00000000..658ce463
--- /dev/null
+++ b/client/features/workspace/layout-save.ts
@@ -0,0 +1,13 @@
+import type { WorkspaceLayout, WorkspaceLayoutSave } from '@/lib/types'
+
+// Unrelated edits must omit tabs from the PUT. Keep the last explicit tab edit
+// through the debounce window, even if a refetch replaces the optimistic cache.
+export function accumulateLayoutSave(
+ next: WorkspaceLayout,
+ update: Partial,
+ pending: WorkspaceLayoutSave | null
+): WorkspaceLayoutSave {
+ const { tabs: _tabs, ...layout } = next
+ const tabs = update.tabs ?? pending?.tabs
+ return tabs === undefined ? layout : { ...layout, tabs }
+}
diff --git a/client/features/workspace/moi-context.ts b/client/features/workspace/moi-context.ts
index b5100cd0..30d9dc73 100644
--- a/client/features/workspace/moi-context.ts
+++ b/client/features/workspace/moi-context.ts
@@ -1,3 +1,4 @@
+import { WorkspaceLayoutContext } from './WorkspaceLayoutContext'
// Central assembly of the moi context sent with every chat message — the one
// place that snapshots the workspace's primary UI state (active tab, view
// titles) and drains queued one-shot directives. The structured `MoiContext`
@@ -21,7 +22,7 @@
// Adding a new ambient field (e.g. scratchpad selection): extend the
// `MoiContext` type and its renderer in lib/moi-context.ts, then supply
// the field in `useMoiUserMessageContext`'s builder below.
-import { useCallback } from 'react'
+import { useCallback, useContext } from 'react'
import { useViewBuilders, useViews } from '@/client/features/views/api'
import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext'
@@ -111,6 +112,7 @@ export function useMoiUserMessageContext({
appletParams
}: WorkspaceTabAddress): (options?: MoiUserMessageOptions) => MoiContext {
const workspaceId = useWorkspaceId()
+ const collab = useContext(WorkspaceLayoutContext)?.collab
const views = useViews(workspaceId).data
const builders = useViewBuilders(workspaceId).data
return useCallback(
@@ -119,12 +121,15 @@ export function useMoiUserMessageContext({
const tabParams = envelopeTabParams(activeTab, appletParams)
return {
activeTab,
+ ...(collab?.enabled && collab.referencePath
+ ? { collabReference: collab.referencePath }
+ : {}),
tabTitle: activeTabTitle(activeTab, views, builders),
...(tabParams ? { tabParams } : {}),
...(options.applet ? { applet: options.applet } : {}),
...(directives.length > 0 ? { directives } : {})
}
},
- [workspaceId, activeTab, appletParams, views, builders]
+ [workspaceId, activeTab, appletParams, views, builders, collab]
)
}
diff --git a/client/features/workspace/useWorkspaceNavigation.ts b/client/features/workspace/useWorkspaceNavigation.ts
index da77f744..d0ab827a 100644
--- a/client/features/workspace/useWorkspaceNavigation.ts
+++ b/client/features/workspace/useWorkspaceNavigation.ts
@@ -1,3 +1,4 @@
+import { usePersonalTabs } from '@/client/features/collab/personal-state'
// The workspace's tab address: which tab the URL names, how to navigate
// elsewhere, and the persistence that keeps a bare `/workspace/:id` landing
// somewhere sensible. The URL is the live truth for the active tab; the
@@ -20,6 +21,7 @@ import {
resolveActiveTab
} from '@/client/features/workspace/tab-resolution'
import { useWorkspaceLayoutCtx } from '@/client/features/workspace/WorkspaceLayoutContext'
+import { useCollabIdentityEnabled } from '@/client/features/collab/entry'
import { useLatestRef } from '@/client/lib/use-latest-ref'
import type { ViewBuilder, ViewInfo, WorkspaceTabId, WorkspaceTabsState } from '@/lib/types'
import {
@@ -41,6 +43,7 @@ type UseWorkspaceNavigationOptions = {
export function useWorkspaceNavigation({ views, builders, split }: UseWorkspaceNavigationOptions) {
const { layout, setLayout, workspaceId } = useWorkspaceLayoutCtx()
+ const personal = useCollabIdentityEnabled()
const [, navigate] = useLocation()
// The tab id is the route's wildcard segment, read from the matched route
// instead of threaded down as a prop — so the pattern stays in AppRouter and
@@ -51,7 +54,12 @@ export function useWorkspaceNavigation({ views, builders, split }: UseWorkspaceN
const historyState = useHistoryState()
const appletParams = useMemo(() => readAppletParams(historyState), [historyState])
- const tabsState = normalizeTabsState(layout.tabs)
+ const [personalTabs, setPersonalTabs] = usePersonalTabs(
+ workspaceId,
+ personal,
+ normalizeTabsState(layout.tabs)
+ )
+ const tabsState = normalizeTabsState(personalTabs)
// Mirror for the effects below: a debounced layout PUT can still be in flight
// when a `workspace:updated` refetch lands, so reading the render-time value
// could persist a stale open set (and resurrect a just-closed tab).
@@ -78,9 +86,10 @@ export function useWorkspaceNavigation({ views, builders, split }: UseWorkspaceN
const setTabs = useCallback(
(tabs: WorkspaceTabsState) => {
tabsStateRef.current = tabs
- setLayout({ tabs })
+ if (personal) setPersonalTabs(tabs)
+ else setLayout({ tabs })
},
- [setLayout, tabsStateRef]
+ [setLayout, tabsStateRef, personal, setPersonalTabs]
)
// Keep the URL honest. One redirect covers every case: a bare
diff --git a/lib/types.ts b/lib/types.ts
index 9056132c..7914dabc 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -556,6 +556,8 @@ export type WorkspaceTabsState = {
export type { AgentTheme, ColorTheme, FontTheme, RadiusTheme, WorkspaceTheme } from './themes'
+export type WorkspaceLayoutSave = Omit & { tabs?: WorkspaceTabsState }
+
export type WorkspaceLayout = {
version: 1
widgetGrid: LayoutGridItem[]
diff --git a/server/layout.ts b/server/layout.ts
index 06cd5a6c..969e51a2 100644
--- a/server/layout.ts
+++ b/server/layout.ts
@@ -1,6 +1,11 @@
import { join } from 'path'
-import type { AppletKind, WorkspaceLayout, WorkspacePreview } from '@/lib/types'
+import type {
+ AppletKind,
+ WorkspaceLayout,
+ WorkspaceLayoutSave,
+ WorkspacePreview
+} from '@/lib/types'
import { createDefaultWorkspaceLayout, normalizeWorkspaceTabs } from '@/lib/workspace-layout'
import { isWorkspaceIcon } from '@/lib/workspace-icon'
@@ -13,6 +18,8 @@ function normalizeLayout(parsed: Record): WorkspaceLayout {
layout.layoutMode = defaults.layoutMode
}
layout.tabs = normalizeWorkspaceTabs(layout.tabs)
+ delete layout.experimental
+ delete layout.collab
if (typeof layout.icon === 'string') {
layout.icon = { type: 'upload', value: layout.icon }
} else if (layout.icon !== undefined && !isWorkspaceIcon(layout.icon)) {
@@ -59,15 +66,19 @@ export async function saveLayout(layout: WorkspaceLayout, workspacePath: string)
// as `name: undefined`.
export function mergeLayoutForSave(
existing: WorkspaceLayout,
- body: WorkspaceLayout
+ body: WorkspaceLayoutSave
): WorkspaceLayout {
const { name: _name, icon: _icon, ...editor } = body
+ delete (editor as Record).experimental
+ delete (editor as Record).collab
// Stale clients may still round-trip the pre-`.cache` thumbnail records;
// never let a layout PUT resurrect them in `.workspace.json`.
delete (editor as Record).appletThumbnails
return {
...editor,
- tabs: normalizeWorkspaceTabs(editor.tabs),
+ // Personal navigation never writes tabs. Other layout edits must not
+ // round-trip a stale copy of the workspace's authored defaults.
+ tabs: editor.tabs === undefined ? existing.tabs : normalizeWorkspaceTabs(editor.tabs),
...(existing.name !== undefined && { name: existing.name }),
...(existing.icon !== undefined && { icon: existing.icon })
}
diff --git a/server/test/layout.test.ts b/server/test/layout.test.ts
index 5f1ecf99..2d64cfc8 100644
--- a/server/test/layout.test.ts
+++ b/server/test/layout.test.ts
@@ -201,6 +201,18 @@ describe('loadLayout', () => {
})
})
})
+
+ test('drops legacy collab configuration and runtime metadata when loading a workspace', async () => {
+ await withWorkspaceFile(
+ { ...base, experimental: { collab: true }, collab: { enabled: true } },
+ async dir => {
+ const loaded = await loadLayout(dir)
+ expect(loaded).toEqual(base)
+ expect('experimental' in loaded).toBe(false)
+ expect('collab' in loaded).toBe(false)
+ }
+ )
+ })
})
describe('mergeLayoutForSave', () => {
@@ -276,6 +288,44 @@ describe('mergeLayoutForSave', () => {
expect(merged.name).toBe('Keep')
})
+ test('a layout save omitting tabs preserves newer authored tabs', () => {
+ const existing: WorkspaceLayout = {
+ ...base,
+ tabs: { open: ['overview', 'view:board'], active: 'view:board' }
+ }
+ // This client loaded base before another client changed the workspace tabs.
+ const { tabs: _staleTabs, ...body } = base
+ const merged = mergeLayoutForSave(existing, {
+ ...body,
+ widgetGrid: [{ i: 'w', x: 2, y: 3 }]
+ })
+ expect(merged.tabs).toEqual(existing.tabs)
+ expect(merged.widgetGrid).toEqual([{ i: 'w', x: 2, y: 3 }])
+ })
+
+ test('explicit undefined tabs have the same effect as an omitted JSON field', () => {
+ const { tabs: _tabs, ...body } = base
+ expect(mergeLayoutForSave(base, { ...body, tabs: undefined }).tabs).toEqual(base.tabs)
+ })
+
+ test('an explicit tab update still changes authored defaults after an unrelated save', () => {
+ const { tabs: _tabs, ...body } = base
+ const saved = mergeLayoutForSave(base, { ...body, layoutMode: 'split' })
+ const tabs: WorkspaceLayout['tabs'] = { open: ['overview', 'agent'], active: 'agent' }
+ expect(mergeLayoutForSave(saved, { ...saved, tabs })).toEqual({
+ ...saved,
+ tabs
+ })
+ })
+
+ test('stale saves cannot restore legacy collab configuration or runtime metadata', () => {
+ const stale = { ...base, experimental: { collab: true }, collab: { enabled: true } }
+ expect(mergeLayoutForSave(stale, stale)).toEqual(base)
+ // Filtering the save must not mutate either caller-owned object.
+ expect(stale.experimental).toEqual({ collab: true })
+ expect(stale.collab).toEqual({ enabled: true })
+ })
+
test('drops the old Widgets tab id from stale client saves', () => {
const body = {
...base,
From 7a96f9ba03d9b879147e2ab2619125df01257696 Mon Sep 17 00:00:00 2001
From: Alexey Taktarov
Date: Mon, 21 Sep 2026 17:36:28 +0200
Subject: [PATCH 06/86] Remember everyone who joins a workspace in a people
directory
A person's id now resolves to a name and face after they leave. The
collab worker keeps a `people` table (schema v2, migrated from v1),
upserts it on join and identity change, sends it in `welcome.people`,
and announces new or changed profiles with a `people` message. The
client store merges the directory with live participants.
Co-Authored-By: Claude Fable 5.1
---
client/features/collab/store.test.ts | 24 +++++++++-
client/features/collab/store.ts | 29 +++++++++++-
lib/collab/types.ts | 9 ++--
server/collab/service.test.ts | 54 ++++++++++++++++++++++
server/collab/service.ts | 13 +++++-
server/collab/storage.test.ts | 54 +++++++++++++++++++++-
server/collab/storage.ts | 67 +++++++++++++++++++++++++++-
7 files changed, 238 insertions(+), 12 deletions(-)
diff --git a/client/features/collab/store.test.ts b/client/features/collab/store.test.ts
index a93e5a2d..257c73c8 100644
--- a/client/features/collab/store.test.ts
+++ b/client/features/collab/store.test.ts
@@ -17,7 +17,8 @@ function fixture(entries: Record = {}) {
version: 1,
connectionId: crypto.randomUUID(),
identity: IDENTITY,
- participants: []
+ participants: [],
+ people: []
})
welcome()
const release = store.acquireScope(SCOPE)
@@ -285,4 +286,25 @@ describe('collab client state', () => {
})
await result
})
+
+ test('the people directory merges welcome, live profiles and later announcements, and survives a disconnect', () => {
+ const store = new CollabStore()
+ const ada = { id: 'ada', name: 'Ada', color: '#f59e0b' }
+ const ken = { id: 'ken', name: 'Ken', color: '#3b82f6' }
+ store.receive({
+ type: 'welcome',
+ version: 1,
+ connectionId: 'c1',
+ identity: null,
+ participants: [{ connectionId: 'c2', identity: ken, location: null, presence: [] }],
+ people: [ada]
+ })
+ expect(store.getSnapshot().people).toEqual({ ada, ken })
+ const renamed = { ...ada, name: 'Ada L' }
+ store.receive({ type: 'people', people: [renamed] })
+ expect(store.getSnapshot().people.ada).toEqual(renamed)
+ store.disconnect()
+ expect(store.getSnapshot().participants).toEqual([])
+ expect(store.getSnapshot().people).toEqual({ ada: renamed, ken })
+ })
})
diff --git a/client/features/collab/store.ts b/client/features/collab/store.ts
index 6a38851f..94a17170 100644
--- a/client/features/collab/store.ts
+++ b/client/features/collab/store.ts
@@ -17,6 +17,8 @@ export type CollabConnectionState = {
status: 'connecting' | 'connected' | 'disconnected'
connectionId: string | null
participants: CollabParticipant[]
+ // The people directory by id: everyone who has joined, online or not.
+ people: Readonly>
pendingCount: number
error: string | null
}
@@ -64,6 +66,16 @@ function applyOperations(
return next
}
+function mergePeople(
+ current: Readonly>,
+ identities: readonly CollabIdentity[]
+): Readonly> {
+ if (!identities.length) return current
+ const next = { ...current }
+ for (const identity of identities) next[identity.id] = identity
+ return next
+}
+
function isJson(value: unknown): value is CollabJsonValue {
if (value === null || typeof value === 'string' || typeof value === 'boolean') return true
if (typeof value === 'number') return Number.isFinite(value)
@@ -80,6 +92,7 @@ export class CollabStore {
status: 'connecting',
connectionId: null,
participants: [],
+ people: {},
pendingCount: 0,
error: null
}
@@ -222,6 +235,11 @@ export class CollabStore {
status: 'connected',
connectionId: message.connectionId,
participants: message.participants,
+ // An older server sends no directory; live profiles still resolve.
+ people: mergePeople(this.state.people, [
+ ...(message.people ?? []),
+ ...message.participants.map(participant => participant.identity)
+ ]),
error: null
})
for (const [name, scope] of this.scopes) {
@@ -238,7 +256,16 @@ export class CollabStore {
break
}
case 'participants':
- this.publish({ participants: message.participants })
+ this.publish({
+ participants: message.participants,
+ people: mergePeople(
+ this.state.people,
+ message.participants.map(participant => participant.identity)
+ )
+ })
+ break
+ case 'people':
+ this.publish({ people: mergePeople(this.state.people, message.people) })
break
case 'snapshot': {
const scope = this.scope(message.scope)
diff --git a/lib/collab/types.ts b/lib/collab/types.ts
index 99e29a9b..f286e088 100644
--- a/lib/collab/types.ts
+++ b/lib/collab/types.ts
@@ -13,11 +13,6 @@ export type CollabIdentity = {
color: string
}
-export type CollabCapability = {
- enabled: boolean
- referencePath?: string
-}
-
export type CollabLocation = { page: string; title?: string }
export type CollabActor = { id: string; kind: 'user' | 'agent' | 'system'; onBehalfOf?: string }
@@ -77,8 +72,12 @@ export type CollabServerMessage =
connectionId: string
identity: CollabIdentity | null
participants: CollabParticipant[]
+ // Everyone who has joined this workspace, so an id still resolves to a
+ // name and face after that person has left.
+ people: CollabIdentity[]
}
| { type: 'participants'; participants: CollabParticipant[] }
+ | { type: 'people'; people: CollabIdentity[] }
| ({ type: 'snapshot'; subscriptionId: string } & CollabScopeSnapshot)
| {
type: 'update'
diff --git a/server/collab/service.test.ts b/server/collab/service.test.ts
index 517fede8..f112008a 100644
--- a/server/collab/service.test.ts
+++ b/server/collab/service.test.ts
@@ -206,4 +206,58 @@ describe('collab service', () => {
).toThrow('Reconnect')
expect(() => service.receive('missing', { type: 'ping' })).toThrow('Join')
})
+
+ test('the people directory outlives presence and announces new or changed profiles', () => {
+ const ada = { id: 'ada', name: 'ada', color: '#336699' }
+ const ken = { id: 'ken', name: 'ken', color: '#336699' }
+ const announced = () =>
+ messages.flatMap(item =>
+ item.message.type === 'people'
+ ? [{ to: item.connectionId, people: item.message.people }]
+ : []
+ )
+ const welcome = (connectionId: string) => {
+ const found = messages.find(
+ item => item.connectionId === connectionId && item.message.type === 'welcome'
+ )?.message
+ if (found?.type !== 'welcome') throw new Error('No welcome')
+ return found
+ }
+ join('a', 'ada')
+ expect(welcome('a').people).toEqual([ada])
+ expect(announced()).toEqual([])
+
+ messages.length = 0
+ join('b', 'ken')
+ expect(announced()).toEqual([{ to: 'a', people: [ken] }])
+ expect(
+ welcome('b')
+ .people.map(person => person.id)
+ .sort()
+ ).toEqual(['ada', 'ken'])
+
+ // Leaving removes presence, not the directory entry; anonymous readers get it too.
+ service.leave('a')
+ messages.length = 0
+ service.receive('anon', { type: 'join', version: 1, identity: null, anonymousId: 'tab' })
+ expect(welcome('anon').participants.map(person => person.identity.id)).toEqual(['ken'])
+ expect(
+ welcome('anon')
+ .people.map(person => person.id)
+ .sort()
+ ).toEqual(['ada', 'ken'])
+
+ // The same profile rejoining announces nothing; a changed profile reaches everyone.
+ messages.length = 0
+ join('c', 'ada')
+ expect(announced()).toEqual([])
+ const renamed = { ...ada, name: 'Ada L' }
+ service.receive('c', { type: 'identity', identity: renamed })
+ expect(
+ announced()
+ .map(item => item.to)
+ .sort()
+ ).toEqual(['anon', 'b', 'c'])
+ expect(announced()[0]?.people).toEqual([renamed])
+ })
})
diff --git a/server/collab/service.ts b/server/collab/service.ts
index abb23670..722750f4 100644
--- a/server/collab/service.ts
+++ b/server/collab/service.ts
@@ -54,6 +54,14 @@ export class CollabService {
for (const id of this.clients.keys()) this.emit(id, message)
}
+ // Remembers a profile in the people directory and tells everyone else when
+ // it is new or changed, so ids keep resolving after the person leaves.
+ private rememberPerson(identity: CollabIdentity, except?: string) {
+ if (!this.storage.upsertPerson(identity)) return
+ const message: CollabServerMessage = { type: 'people', people: [identity] }
+ for (const id of this.clients.keys()) if (id !== except) this.emit(id, message)
+ }
+
private scheduleParticipants() {
this.presenceTimer ??= setTimeout(() => this.publishParticipants(), 50)
}
@@ -77,12 +85,14 @@ export class CollabService {
: null,
subscriptions: new Map()
})
+ if (identity) this.rememberPerson(identity, connectionId)
this.emit(connectionId, {
type: 'welcome',
version: 1,
connectionId,
identity,
- participants: this.participants()
+ participants: this.participants(),
+ people: this.storage.listPeople()
})
this.publishParticipants()
return
@@ -96,6 +106,7 @@ export class CollabService {
if (!participant || message.identity.id !== actor.id)
throw new Error('Reconnect to change identity')
participant.identity = cleanIdentity(message.identity)
+ this.rememberPerson(participant.identity)
this.publishParticipants()
return
}
diff --git a/server/collab/storage.test.ts b/server/collab/storage.test.ts
index 13e636d9..78a11581 100644
--- a/server/collab/storage.test.ts
+++ b/server/collab/storage.test.ts
@@ -292,9 +292,59 @@ describe('collab storage', () => {
).toBe('delete')
expect(
inspection.query<{ user_version: number }, []>('PRAGMA user_version').get()?.user_version
- ).toBe(1)
- inspection.exec('PRAGMA user_version = 2')
+ ).toBe(2)
+ inspection.exec('PRAGMA user_version = 3')
inspection.close()
expect(() => openCollabStorage(path)).toThrow('newer version')
})
+
+ test('the people directory keeps the latest profile per person and survives reopening', () => {
+ expect(storage.listPeople()).toEqual([])
+ const ada = { id: 'ada', name: 'Ada', color: '#f59e0b' }
+ const ken = { id: 'ken', name: 'Ken', color: '#3b82f6' }
+ expect(storage.upsertPerson(ada)).toBe(true)
+ expect(storage.upsertPerson(ada)).toBe(false)
+ timestamp += 1000
+ expect(storage.upsertPerson(ken)).toBe(true)
+ timestamp += 1000
+ const renamed = { ...ada, name: 'Ada L', avatar: 'data:image/png;base64,AAAA' }
+ expect(storage.upsertPerson(renamed)).toBe(true)
+ storage.close()
+ storage = openCollabStorage(path, { now: () => timestamp })
+ expect(storage.listPeople()).toEqual([renamed, ken])
+ expect(() => storage.upsertPerson({ ...ada, avatar: 'x'.repeat(9000) })).toThrow('8 KiB')
+ expect(storage.listPeople()).toEqual([renamed, ken])
+ })
+
+ test('a version 1 database gains the people directory and keeps its data', () => {
+ const legacyPath = join(directory, 'legacy.sqlite')
+ const legacy = new Database(legacyPath, { create: true })
+ legacy.exec(`
+ CREATE TABLE scopes (scope TEXT PRIMARY KEY, revision INTEGER NOT NULL DEFAULT 0);
+ CREATE TABLE entries (
+ scope TEXT NOT NULL, key TEXT NOT NULL, value_json TEXT NOT NULL, PRIMARY KEY (scope, key)
+ );
+ CREATE TABLE receipts (
+ actor_id TEXT NOT NULL, operation_id TEXT NOT NULL, request_hash TEXT NOT NULL,
+ scope TEXT NOT NULL, revision INTEGER NOT NULL, committed_at INTEGER NOT NULL,
+ PRIMARY KEY (actor_id, operation_id)
+ );
+ INSERT INTO scopes(scope, revision) VALUES ('shared:tasks', 3);
+ INSERT INTO entries(scope, key, value_json) VALUES ('shared:tasks', 'task/1/title', '"Ship"');
+ PRAGMA user_version = 1;
+ `)
+ legacy.close()
+ const upgraded = openCollabStorage(legacyPath)
+ try {
+ expect(upgraded.snapshot('shared:tasks')).toEqual({
+ scope: 'shared:tasks',
+ revision: 3,
+ entries: { 'task/1/title': 'Ship' }
+ })
+ expect(upgraded.listPeople()).toEqual([])
+ expect(upgraded.upsertPerson({ id: 'ada', name: 'Ada', color: '#f59e0b' })).toBe(true)
+ } finally {
+ upgraded.close()
+ }
+ })
})
diff --git a/server/collab/storage.ts b/server/collab/storage.ts
index 06da9eea..1cea5a15 100644
--- a/server/collab/storage.ts
+++ b/server/collab/storage.ts
@@ -2,7 +2,9 @@ import { Database } from 'bun:sqlite'
import { existsSync, mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
+import { COLLAB_MAX_AVATAR_BYTES } from '@/lib/collab/protocol'
import type {
+ CollabIdentity,
CollabJsonValue,
CollabMutationResult,
CollabOperation,
@@ -19,11 +21,12 @@ export const COLLAB_STORAGE_LIMITS = {
identifierBytes: 256,
keyBytes: 1024,
jsonDepth: 32,
- receiptTtlMs: 24 * 60 * 60 * 1000
+ receiptTtlMs: 24 * 60 * 60 * 1000,
+ people: 500
} as const
const PRUNE_INTERVAL_MS = 60_000
-const SCHEMA_VERSION = 1
+const SCHEMA_VERSION = 2
export class CollabStorageError extends Error {
constructor(
@@ -44,6 +47,10 @@ export type CollabStorage = {
operations: readonly CollabOperation[]
) => CollabMutationResult
lookupReceipts: (actorId: string, operationIds: readonly string[]) => CollabReceipt[]
+ // The people directory: the latest profile of everyone who has joined.
+ // Returns whether the stored profile changed.
+ upsertPerson: (identity: CollabIdentity) => boolean
+ listPeople: () => CollabIdentity[]
pruneReceipts: () => void
exportTo: (path: string) => void
close: () => void
@@ -182,6 +189,20 @@ export function openCollabStorage(path: string, options: CollabStorageOptions =
`)
})()
}
+ if (version < 2) {
+ db.transaction(() => {
+ db.exec(`
+ CREATE TABLE people (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ color TEXT NOT NULL,
+ avatar TEXT,
+ updated_at INTEGER NOT NULL
+ );
+ PRAGMA user_version = 2;
+ `)
+ })()
+ }
} catch (error) {
db.close()
throw error
@@ -212,6 +233,17 @@ export function openCollabStorage(path: string, options: CollabStorageOptions =
const insertReceipt = db.query(`INSERT INTO receipts
(actor_id, operation_id, request_hash, scope, revision, committed_at) VALUES (?, ?, ?, ?, ?, ?)`)
const deleteExpired = db.query('DELETE FROM receipts WHERE committed_at <= ?')
+ const selectPerson = db.query<{ name: string; color: string; avatar: string | null }, [string]>(
+ 'SELECT name, color, avatar FROM people WHERE id = ?'
+ )
+ const writePerson = db.query(`INSERT INTO people(id, name, color, avatar, updated_at)
+ VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET name = excluded.name, color = excluded.color,
+ avatar = excluded.avatar, updated_at = excluded.updated_at`)
+ const recentPeople = db.query<
+ { id: string; name: string; color: string; avatar: string | null },
+ [number]
+ >('SELECT id, name, color, avatar FROM people ORDER BY updated_at DESC, id LIMIT ?')
let closed = false
function pruneReceipts() {
@@ -310,6 +342,37 @@ export function openCollabStorage(path: string, options: CollabStorageOptions =
: { operationId, status: 'unknown' }
})
},
+ upsertPerson(identity) {
+ identifier(identity.id, 'person id', COLLAB_STORAGE_LIMITS.identifierBytes)
+ identifier(identity.name, 'person name', COLLAB_STORAGE_LIMITS.identifierBytes)
+ identifier(identity.color, 'person color', 64)
+ const avatar = identity.avatar ?? null
+ if (
+ avatar !== null &&
+ (typeof avatar !== 'string' || Buffer.byteLength(avatar) > COLLAB_MAX_AVATAR_BYTES)
+ ) {
+ fail('LIMIT_EXCEEDED', 'Avatar exceeds 8 KiB')
+ }
+ const known = selectPerson.get(identity.id)
+ if (
+ known &&
+ known.name === identity.name &&
+ known.color === identity.color &&
+ known.avatar === avatar
+ ) {
+ return false
+ }
+ writePerson.run(identity.id, identity.name, identity.color, avatar, now())
+ return true
+ },
+ listPeople() {
+ return recentPeople.all(COLLAB_STORAGE_LIMITS.people).map(row => ({
+ id: row.id,
+ name: row.name,
+ color: row.color,
+ ...(row.avatar ? { avatar: row.avatar } : {})
+ }))
+ },
pruneReceipts,
exportTo(destination) {
if (existsSync(destination)) fail('EXPORT_EXISTS', 'Shared storage export already exists')
From 5e1136e276a21dc473e838a37455d994fc2048db Mon Sep 17 00:00:00 2001
From: Alexey Taktarov
Date: Mon, 21 Sep 2026 17:36:46 +0200
Subject: [PATCH 07/86] Add id-based people components on a swappable collab
backend
Applets refer to a person by id (`id`, or `ids` for several) and the
components resolve name, face, and presence themselves: `Person` (with
`avatarOnly` and `showStatus`), `Facepile`, `Cursor`, `PresenceFrame`,
`PresenceGutter`, and the `usePerson` hook. `PresenceFrame`, and with it
`PresenceField` and `Selection`, hugs the single element it wraps and
copies its corner radius. `SyncStatus` is gone; applets render
`isSaving` and `error` from the shared-state hooks.
Hooks and components now talk to a `CollabBackend` contract from
context instead of the socket client. The workspace provides the live
backend; `createFakeBackend` is an in-memory room behind the same store.
`/dev/collab-kit` runs on it: every component with made-up people, then
the connected components and the exported hooks against scripted
participants.
The registry Avatar gains an `xs` size and expresses its default size as
a variant so applet-scoped CSS cannot override it.
Co-Authored-By: Claude Fable 5.1
---
client/features/collab/DevCollabKitPage.tsx | 951 ++++++++++++++++++
.../collab/WorkspaceCollabControls.tsx | 29 +-
client/features/collab/backend.ts | 70 ++
client/features/collab/components.tsx | 182 +---
client/features/collab/fake-backend.test.ts | 70 ++
client/features/collab/fake-backend.ts | 159 +++
client/features/collab/hooks.tsx | 97 +-
client/features/collab/index.ts | 23 +-
client/features/collab/people.test.ts | 29 +-
client/features/collab/people.ts | 34 +-
client/features/collab/primitives.tsx | 431 ++++++++
client/features/dev/DevIndexPage.tsx | 6 +
client/features/dev/DevRoutes.tsx | 8 +
server/collab/applet-module.ts | 7 +-
server/collab/skill/collab-env.d.ts | 56 +-
server/collab/skill/index.test.ts | 12 +-
.../collab/skill/references/COLLABORATIVE.md | 36 +-
ui-components/avatar.tsx | 9 +-
ui-components/docs/avatar.md | 10 +-
19 files changed, 2018 insertions(+), 201 deletions(-)
create mode 100644 client/features/collab/DevCollabKitPage.tsx
create mode 100644 client/features/collab/backend.ts
create mode 100644 client/features/collab/fake-backend.test.ts
create mode 100644 client/features/collab/fake-backend.ts
create mode 100644 client/features/collab/primitives.tsx
diff --git a/client/features/collab/DevCollabKitPage.tsx b/client/features/collab/DevCollabKitPage.tsx
new file mode 100644
index 00000000..60eacc9b
--- /dev/null
+++ b/client/features/collab/DevCollabKitPage.tsx
@@ -0,0 +1,951 @@
+import { useEffect, useRef, useState } from 'react'
+import type { ReactNode } from 'react'
+
+import {
+ IconChevronDown,
+ IconCursorText,
+ IconPhoto,
+ IconPlus,
+ IconPointer
+} from '@tabler/icons-react'
+import { Link } from 'wouter'
+
+import { Button } from '@/client/components/ui/button'
+import { Checkbox } from '@/client/components/ui/checkbox'
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger
+} from '@/client/components/ui/collapsible'
+import { Input } from '@/client/components/ui/input'
+import { Switch } from '@/client/components/ui/switch'
+import { Textarea } from '@/client/components/ui/textarea'
+import { cn } from '@/client/lib/cn'
+import type {
+ CollabIdentity,
+ CollabJsonValue,
+ CollabParticipant,
+ CollabPresenceRegistration
+} from '@/lib/collab/types'
+import { Card, CardDescription, CardHeader, CardTitle } from '@/ui-components/card'
+
+import { Activity, Cursors, PresenceField, Selection } from './components'
+import { createFakeBackend } from './fake-backend'
+import type { FakeCollabBackend } from './fake-backend'
+import {
+ AppletCollabProvider,
+ CollabBackendProvider,
+ presenceChannels,
+ useOthers,
+ usePerson,
+ usePresence,
+ useSelf,
+ useSharedState,
+ useSharedStore
+} from './hooks'
+import { Cursor, Facepile, Person, PresenceFrame, PresenceGutter } from './primitives'
+
+// Made-up people in an in-memory room (fake-backend.ts), so everything on this
+// page runs its real code with no server: ids resolve, presence travels, and
+// shared state saves. Some people are connected, the rest are only remembered.
+const fig: CollabIdentity = { id: 'fig', name: 'Fig', color: '#f59e0b' }
+const alex: CollabIdentity = { id: 'alex', name: 'Alex Hao', color: '#3b82f6' }
+const andrea: CollabIdentity = { id: 'andrea', name: 'Andrea Lim', color: '#ec4899' }
+const pierre: CollabIdentity = { id: 'pierre', name: 'Pierre', color: '#f97316' }
+const DIRECTORY: CollabIdentity[] = [
+ fig,
+ alex,
+ andrea,
+ { id: 'david', name: 'David Tibbitts', color: '#10b981' },
+ { id: 'lauren', name: 'Lauren Oliveri', color: '#06b6d4' },
+ { id: 'monica', name: 'Monica Perez', color: '#8b5cf6' },
+ pierre,
+ { id: 'ada', name: 'Ada', color: '#84cc16' }
+]
+// The person looking at this page.
+const you: CollabIdentity = { id: 'you', name: 'You', color: '#8b5cf6' }
+const PAGE = 'kit'
+const APPLET = { kind: 'view', name: 'kit' } as const
+const SURFACE = `${APPLET.kind}:${APPLET.name}`
+const SCOPE = 'demo:kit'
+
+type Point = { x: number; y: number }
+type BotState = { cursor: Point; focusing: boolean }
+function registration(
+ who: string,
+ channel: string,
+ value: CollabJsonValue
+): CollabPresenceRegistration {
+ return { registrationId: `${who}:${channel}`, surface: SURFACE, channel, value }
+}
+// Everyone else in the room. Fig and Alex are on this page, Pierre is on
+// another one, and Andrea has every tab hidden.
+function everyoneElse({ cursor, focusing }: BotState): CollabParticipant[] {
+ return [
+ {
+ connectionId: 'bot-fig',
+ identity: fig,
+ location: { page: PAGE },
+ presence: [
+ registration('fig', presenceChannels.cursor('lab'), cursor),
+ registration('fig', presenceChannels.custom('mood'), 'Reviewing'),
+ ...(focusing ? [registration('fig', presenceChannels.field('launch-note'), true)] : [])
+ ]
+ },
+ {
+ connectionId: 'bot-alex',
+ identity: alex,
+ location: { page: PAGE },
+ presence: [
+ registration('alex', presenceChannels.selection('task-1'), true),
+ registration('alex', presenceChannels.custom('mood'), 'Ready')
+ ]
+ },
+ {
+ connectionId: 'bot-pierre',
+ identity: pierre,
+ location: { page: 'view:board' },
+ presence: []
+ },
+ { connectionId: 'bot-andrea', identity: andrea, location: null, presence: [] }
+ ]
+}
+const START: BotState = { cursor: { x: 140, y: 60 }, focusing: true }
+
+// Fig's pointer wanders and Fig steps in and out of the note field.
+function useBots(room: FakeCollabBackend) {
+ useEffect(() => {
+ let at = START.cursor
+ let to = { x: 360, y: 150 }
+ let tick = 0
+ const timer = setInterval(() => {
+ tick++
+ const dx = to.x - at.x
+ const dy = to.y - at.y
+ if (Math.hypot(dx, dy) < 6) to = { x: 40 + Math.random() * 480, y: 30 + Math.random() * 190 }
+ else at = { x: at.x + dx * 0.12, y: at.y + dy * 0.12 }
+ room.setOthers(everyoneElse({ cursor: at, focusing: Math.floor(tick / 40) % 2 === 0 }))
+ }, 100)
+ return () => clearInterval(timer)
+ }, [room])
+}
+
+const IDS = DIRECTORY.map(person => person.id)
+const WALKERS = ['pierre', 'fig', 'alex', 'andrea']
+const TASKS = ['Confirm the venue', 'Draft the announcement', 'Book the studio']
+const BLOCKS = [
+ 'Q3 launch checklist',
+ 'Confirm the venue and the catering count by Friday.',
+ 'Draft the announcement post and the email to customers.',
+ 'Collect screenshots from the design team for the press kit.',
+ 'Book the recording studio for the walkthrough video.'
+]
+
+// Approximate applet usage, one snippet per section.
+const CODE = {
+ person: [
+ "import { Person, usePerson } from 'moi/collab'",
+ '',
+ '// A person is an id. Name, face, and the presence dot resolve on their own,',
+ '// also for people who have left. An unknown id renders as "Unknown person".',
+ '',
+ '',
+ '',
+ '',
+ '// Just the face, in four sizes.',
+ '',
+ '',
+ '// Compact keeps one line and puts the detail inline.',
+ '',
+ '',
+ '// The green dot means the person has the workspace open in a visible tab',
+ '// right now. It is on by default; turn it off where it adds nothing.',
+ '',
+ '',
+ '// The same lookup as a hook: { id, identity | null, status }.',
+ 'const { identity, status } = usePerson(task.assigneeId)'
+ ].join('\n'),
+ facepile: [
+ "import { Facepile, useOthers } from 'moi/collab'",
+ '',
+ '// Deduplicates, shows `max` faces (3 by default), then a count.',
+ '',
+ '',
+ '',
+ '// The green dots are off in a pile. Turn them on when they matter.',
+ '',
+ '',
+ "const others = useOthers({ scope: 'workspace' })",
+ ' other.identity.id)} size="xs" />'
+ ].join('\n'),
+ frames: [
+ "import { PresenceFrame, PresenceField, Selection } from 'moi/collab'",
+ '',
+ '// Wrap anything and say who is there. The frame hugs the element inside and',
+ '// takes its corner radius. The first person’s color draws it.',
+ '',
+ ' setNote(event.target.value)} />',
+ '',
+ '',
+ ' ',
+ '',
+ '',
+ ' ',
+ '',
+ '',
+ '// Connected: the workspace reports who is focused on or has selected a target.',
+ '',
+ ' setNote(event.target.value)} />',
+ '',
+ '',
+ ' ',
+ ''
+ ].join('\n'),
+ gutter: [
+ "import { PresenceGutter } from 'moi/collab'",
+ '',
+ '// A face beside the block each person is on. Blocks carry data-collab-target;',
+ '// a face glides to the next block instead of reappearing there.',
+ '',
+ ' {blocks.map(block => (',
+ ' ',
+ ' {block.text}',
+ '
',
+ ' ))}',
+ ''
+ ].join('\n'),
+ connected: [
+ "import { Activity, Cursors, PresenceField, Selection } from 'moi/collab'",
+ '',
+ '// No data props: each one reports you and shows everyone else by itself.',
+ '',
+ '',
+ ' ',
+ ' setNote(event.target.value)} />',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ''
+ ].join('\n'),
+ hooks: [
+ "import { useSelf, useOthers, usePerson, usePresence, useSharedState, useSharedStore } from 'moi/collab'",
+ '',
+ 'const self = useSelf() // participant | null',
+ "const others = useOthers({ scope: 'workspace' }) // connections, not people",
+ "const { identity, status } = usePerson('alex')",
+ '',
+ '// Temporary, gone when you leave.',
+ "const mood = usePresence('mood', 'Exploring')",
+ "mood.setValue('Ready')",
+ 'mood.others // [{ participant, value }]',
+ '',
+ '// Saved for everyone.',
+ "const note = useSharedState('note', { scope: 'demo:kit', defaultValue: '' })",
+ "await note.setValue('Ship it') // { status: 'committed', revision }",
+ "const tasks = useSharedStore('task/', { scope: 'demo:kit' })",
+ "await tasks.set('42/title', 'Ship demo')"
+ ].join('\n'),
+ cursors: [
+ "import { Cursor, Cursors } from 'moi/collab'",
+ '',
+ '// One pointer at x, y inside a relative parent. Updates glide over 100 ms,',
+ '// so 20 updates a second look continuous.',
+ '',
+ ' {pointers.map(pointer => (',
+ ' ',
+ ' ))}',
+ '
',
+ '',
+ '// Connected: everyone else’s pointer on this surface. Pointers over a',
+ '// data-collab-target element follow that element on every screen.',
+ '',
+ ' ',
+ ''
+ ].join('\n')
+}
+
+type CodeExampleProps = { code: string }
+function CodeExample({ code }: CodeExampleProps) {
+ return (
+
+
+ }
+ >
+
+ Code
+
+
+
+ {code}
+
+
+
+ )
+}
+
+type SectionProps = { title: string; hint: string; code: string; children: ReactNode }
+function Section({ title, hint, code, children }: SectionProps) {
+ return (
+
+ )
+}
+
+function PeopleDemo() {
+ const [selected, setSelected] = useState('alex')
+ return (
+
+
+
+
+
Select a person
+ {IDS.slice(0, 6).map(id => (
+
+ ))}
+
+
+
+
+
Compact
+
+
+ Last edited by a minute ago.
+
+
+
+ )
+}
+
+const TEXT_ICON =
+const POINTER_ICON =
+
+type FrameExampleProps = { label: string; children: ReactNode }
+function FrameExample({ label, children }: FrameExampleProps) {
+ return (
+
+ {/* Room above the element for the name tag. */}
+
{children}
+
{label}
+
+ )
+}
+
+// None of the frames below is told a size or a radius.
+function FramesDemo() {
+ const [shown, setShown] = useState(true)
+ const [note, setNote] = useState('Q3 launch checklist')
+ const [details, setDetails] = useState('Confirm the venue and the catering count by Friday.')
+ const [done, setDone] = useState([true, false, false])
+ const at = (...ids: string[]) => (shown ? ids : [])
+ return (
+
+
+ Frames
+
+
+
+
+
+ setNote(event.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ship the demo
+ Two people have this task selected.
+
+
+
+
+
+
+ {TASKS.map((task, index) => (
+ -
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Draft the announcement post and the email to customers.
+
+
+
+
+
+ )
+}
+
+function GutterDemo() {
+ const [spots, setSpots] = useState({ fig: 1, alex: 3, you: 0 })
+ // Fig and Alex wander between blocks the way people move through a document.
+ useEffect(() => {
+ const move = (who: 'fig' | 'alex') =>
+ setSpots(current => {
+ let next = current[who]
+ while (next === current[who]) next = Math.floor(Math.random() * BLOCKS.length)
+ return { ...current, [who]: next }
+ })
+ const figTimer = setInterval(() => move('fig'), 1600)
+ const alexTimer = setInterval(() => move('alex'), 2500)
+ return () => {
+ clearInterval(figTimer)
+ clearInterval(alexTimer)
+ }
+ }, [])
+ return (
+
+ {BLOCKS.map((text, index) => (
+ setSpots(current => ({ ...current, you: index }))}
+ >
+ {text}
+
+ ))}
+
+ )
+}
+
+type Walker = { at: Point; to: Point; pause: number }
+
+function randomPoint(width: number, height: number): Point {
+ const inset = 48
+ return {
+ x: inset + Math.random() * Math.max(0, width - inset * 2),
+ y: inset + Math.random() * Math.max(0, height - inset * 2)
+ }
+}
+
+function CursorLab() {
+ const canvas = useRef(null)
+ const [count, setCount] = useState(3)
+ const [labels, setLabels] = useState(true)
+ const [dark, setDark] = useState(true)
+ const [walkers, setWalkers] = useState([])
+ const [pointer, setPointer] = useState(null)
+ const [echo, setEcho] = useState(null)
+
+ // Made-up people wander at the cadence a real connection delivers updates.
+ useEffect(() => {
+ const element = canvas.current
+ if (!element) return
+ const { width, height } = element.getBoundingClientRect()
+ let state: Walker[] = WALKERS.slice(0, count).map(() => ({
+ at: randomPoint(width, height),
+ to: randomPoint(width, height),
+ pause: 0
+ }))
+ const timer = setInterval(() => {
+ state = state.map(walker => {
+ if (walker.pause > 0) return { ...walker, pause: walker.pause - 1 }
+ const dx = walker.to.x - walker.at.x
+ const dy = walker.to.y - walker.at.y
+ if (Math.hypot(dx, dy) < 6) {
+ return {
+ at: walker.to,
+ to: randomPoint(width, height),
+ pause: 5 + Math.floor(Math.random() * 25)
+ }
+ }
+ return { ...walker, at: { x: walker.at.x + dx * 0.08, y: walker.at.y + dy * 0.08 } }
+ })
+ setWalkers(state)
+ }, 60)
+ return () => clearInterval(timer)
+ }, [count])
+
+ // Your own pointer, echoed back with the delay a round trip would add.
+ useEffect(() => {
+ const timer = setTimeout(() => setEcho(pointer), 120)
+ return () => clearTimeout(timer)
+ }, [pointer])
+
+ return (
+
+
+ People
+ {[1, 2, 3, 4].map(n => (
+
+ ))}
+ Names
+
+ Surface
+
+
+
{
+ const rect = event.currentTarget.getBoundingClientRect()
+ setPointer({ x: event.clientX - rect.left, y: event.clientY - rect.top })
+ }}
+ onPointerLeave={() => setPointer(null)}
+ >
+ {walkers.slice(0, count).map((walker, index) => (
+
+ ))}
+ {echo &&
}
+
+ Move your cursor
+
+
+
+ Your own pointer comes back as a cursor about 120 ms late, the way it would for someone
+ else.
+
+
+ )
+}
+
+function ConnectedDemo() {
+ const [note, setNote] = useState('Q3 launch checklist')
+ const [selected, setSelected] = useState('task-2')
+ return (
+
+
+
+ On this page
+
+
+ In the workspace
+
+
+
+
+ setNote(event.target.value)}
+ />
+
+
+ {['task-1', 'task-2'].map((task, index) => (
+
+ setSelected(task)}
+ >
+
+ {index === 0 ? 'Ship the demo' : 'Write the announcement'}
+
+ {index === 0 ? 'Alex has this one selected.' : 'Click to select it yourself.'}
+
+
+
+
+ ))}
+
+
+
+ )
+}
+
+type OutputProps = { value: unknown }
+// Faces are long data URLs; they would drown the rest.
+function Output({ value }: OutputProps) {
+ return (
+
+ {JSON.stringify(value, (key, entry) => (key === 'avatar' ? undefined : entry), 2) ?? 'null'}
+
+ )
+}
+
+type HookCardProps = { signature: string; children: ReactNode }
+function HookCard({ signature, children }: HookCardProps) {
+ return (
+
+
{signature}
+ {children}
+
+ )
+}
+
+type HooksDemoProps = { room: FakeCollabBackend }
+function HooksDemo({ room }: HooksDemoProps) {
+ const self = useSelf()
+ const [scope, setScope] = useState<'page' | 'workspace'>('page')
+ const others = useOthers({ scope })
+ const [personId, setPersonId] = useState('alex')
+ const person = usePerson(personId)
+ const mood = usePresence('mood', 'Exploring')
+ const note = useSharedState('note', { scope: SCOPE, defaultValue: '' })
+ const tasks = useSharedStore('task/', { scope: SCOPE })
+ const status = ({ loaded, canWrite, isSaving, error }: typeof note | typeof tasks) => ({
+ loaded,
+ canWrite,
+ isSaving,
+ error
+ })
+ return (
+
+
+
+
+
+
+ {(['page', 'workspace'] as const).map(option => (
+
+ ))}
+
+
+
+
+ {['alex', 'andrea', 'david', 'nobody'].map(id => (
+
+ ))}
+
+
+
+
+
+ {['Exploring', 'Reviewing', 'Ready'].map(option => (
+
+ ))}
+
+
+
+
+ void note.setValue(event.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export function DevCollabKitPage() {
+ const [room] = useState(() =>
+ createFakeBackend({
+ self: you,
+ page: PAGE,
+ others: everyoneElse(START),
+ people: DIRECTORY,
+ entries: { [SCOPE]: { note: 'Saved for everyone' } },
+ latency: 400
+ })
+ )
+ useBots(room)
+ return (
+
+
+
+
+
+ ← Dev pages
+
+
+ Collab kit
+
+ Everything applets get from moi/collab, running on an in-memory room instead of a
+ server. The people are made up: Fig and Alex are on this page, Pierre is elsewhere
+ in the workspace, Andrea is away, and the rest exist only in the directory. The
+ first part is what it looks like, the second part is how it behaves.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Behavior
+
+ The same exports an applet imports, wired to the in-memory room. Fig and Alex are
+ scripted; you are a real participant.
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/client/features/collab/WorkspaceCollabControls.tsx b/client/features/collab/WorkspaceCollabControls.tsx
index cc1d7828..8bc60904 100644
--- a/client/features/collab/WorkspaceCollabControls.tsx
+++ b/client/features/collab/WorkspaceCollabControls.tsx
@@ -23,12 +23,12 @@ import type { WorkspaceTabId } from '@/lib/types'
import { parseWorkspaceTab } from '@/lib/workspace-tabs'
import { Avatar, AvatarFallback } from '@/ui-components/avatar'
-import { ParticipantAvatar } from './components'
+import { Person } from './primitives'
import type { CollabTabInfo } from './entry'
import { pageFromPath, useConnection } from './hooks'
import { getIdentity, getIdentitySource, shareWorkspace, subscribeIdentityStore } from './identity'
import { groupPeople } from './people'
-import type { Person } from './people'
+import type { PresentPerson } from './people'
type DescribeTab = (tab: WorkspaceTabId) => CollabTabInfo | null
@@ -93,8 +93,10 @@ export function WorkspaceCollabControls({
}
>
{self && (
-
@@ -106,7 +108,7 @@ export function WorkspaceCollabControls({
People in this workspace
{self && (
-
+
{self.identity.name}{' '}
(you)
@@ -148,7 +150,7 @@ export function WorkspaceCollabControls({
type Place = { where: string; Icon?: TabIcon; target: WorkspaceTabId | null; away: boolean }
-function placeOf(person: Person, page: string, describeTab: DescribeTab): Place {
+function placeOf(person: PresentPerson, page: string, describeTab: DescribeTab): Place {
const tabs = person.pages.map(candidate => {
const tab = parseWorkspaceTab(candidate)
return { tab, info: tab ? describeTab(tab) : null }
@@ -167,7 +169,7 @@ function placeOf(person: Person, page: string, describeTab: DescribeTab): Place
// A face in the header stack: hover names the person and where they are,
// and a click opens the tab they are on.
-type FaceProps = { person: Person; place: Place; onJump: (tab: WorkspaceTabId) => void }
+type FaceProps = { person: PresentPerson; place: Place; onJump: (tab: WorkspaceTabId) => void }
function Face({ person, place, onJump }: FaceProps) {
const target = place.target
const hint = place.away
@@ -190,7 +192,12 @@ function Face({ person, place, onJump }: FaceProps) {
)
}
>
-
+
@@ -202,12 +209,12 @@ function Face({ person, place, onJump }: FaceProps) {
)
}
-type PersonRowProps = { person: Person; place: Place; onJump: (tab: WorkspaceTabId) => void }
+type PersonRowProps = { person: PresentPerson; place: Place; onJump: (tab: WorkspaceTabId) => void }
function PersonRow({ person, place, onJump }: PersonRowProps) {
- const { Icon, where, away, target } = place
+ const { Icon, where, target } = place
const content = (
<>
-
+
{person.identity.name}
diff --git a/client/features/collab/backend.ts b/client/features/collab/backend.ts
new file mode 100644
index 00000000..94d0ba0f
--- /dev/null
+++ b/client/features/collab/backend.ts
@@ -0,0 +1,70 @@
+import type {
+ CollabIdentity,
+ CollabLocation,
+ CollabOperation,
+ CollabPresenceRegistration
+} from '@/lib/collab/types'
+
+import type { CollabClient } from './client'
+import { getIdentity, subscribeIdentityStore } from './identity'
+import type { CollabConnectionState, MutationOutcome, SharedScopeState } from './store'
+
+type Unsubscribe = () => void
+
+// Everything the collab hooks and components need from a sync engine, and
+// nothing about how it talks to one. The workspace provides the live backend
+// below; dev pages and tests provide the in-memory one from fake-backend.ts.
+// A different engine only has to fill in this shape.
+export type CollabBackend = {
+ // Connection status, live participants, and the people directory.
+ getSnapshot: () => CollabConnectionState
+ subscribe: (listener: () => void) => Unsubscribe
+ // Who is looking. Null while no identity is set: shared state still works,
+ // presence does not.
+ getIdentity: () => CollabIdentity | null
+ subscribeIdentity: (listener: () => void) => Unsubscribe
+ getLocation: () => CollabLocation | null
+ // Temporary presence, one registration per mounted hook.
+ setPresence: (registration: CollabPresenceRegistration) => void
+ deletePresence: (registrationId: string) => void
+ // Shared state. A scope stays subscribed while anything holds it.
+ acquireScope: (scope: string) => Unsubscribe
+ getScopeSnapshot: (scope: string) => SharedScopeState
+ subscribeScope: (scope: string, listener: () => void) => Unsubscribe
+ mutate: (scope: string, operations: CollabOperation[]) => Promise
+}
+
+export const NO_BACKEND_STATE: CollabConnectionState = {
+ status: 'disconnected',
+ connectionId: null,
+ participants: [],
+ people: {},
+ pendingCount: 0,
+ error: null
+}
+
+// The workspace connection: a WebSocket client around the connection store.
+export function createLiveBackend(client: CollabClient): CollabBackend {
+ const { store } = client
+ return {
+ getSnapshot: store.getSnapshot,
+ subscribe: store.subscribe,
+ getIdentity,
+ subscribeIdentity: subscribeIdentityStore,
+ getLocation: () => store.getLocation(),
+ setPresence: registration => store.setPresence(registration),
+ deletePresence: registrationId => store.deletePresence(registrationId),
+ acquireScope: scope => {
+ const release = store.acquireScope(scope)
+ // Anonymous tabs only hold a connection while they use shared state.
+ const releaseConnection = client.acquireSharedState()
+ return () => {
+ release()
+ releaseConnection()
+ }
+ },
+ getScopeSnapshot: scope => store.getScopeSnapshot(scope),
+ subscribeScope: (scope, listener) => store.subscribeScope(scope, listener),
+ mutate: (scope, operations) => store.mutate(scope, operations)
+ }
+}
diff --git a/client/features/collab/components.tsx b/client/features/collab/components.tsx
index 26693f95..80f4e709 100644
--- a/client/features/collab/components.tsx
+++ b/client/features/collab/components.tsx
@@ -1,26 +1,17 @@
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import type { FocusEvent, PointerEvent, ReactNode, RefObject } from 'react'
-import {
- IconCheck,
- IconCloudOff,
- IconCursorText,
- IconPointer,
- IconRefresh
-} from '@tabler/icons-react'
+import { IconCursorText, IconPointer } from '@tabler/icons-react'
import { cn } from '@/client/lib/cn'
-import {
- Avatar,
- AvatarBadge,
- AvatarFallback,
- AvatarGroup,
- AvatarImage
-} from '@/ui-components/avatar'
-import type { CollabIdentity, CollabJsonValue, CollabParticipant } from '@/lib/collab/types'
+import type { CollabJsonValue, CollabParticipant } from '@/lib/collab/types'
-import { facehashDataUrl } from './facehash-avatar'
-import { useConnection, useOthers, usePresenceChannel, useSelf } from './hooks'
+import { presenceChannels, useOthers, usePresenceChannel, useSelf } from './hooks'
+import { Cursor, Facepile, PresenceFrame } from './primitives'
+
+// Connected collab components: the primitives from primitives.tsx fed by the
+// workspace connection and the current applet's presence.
+export { Cursor, Facepile, Person, PresenceFrame, PresenceGutter } from './primitives'
export function uniqueParticipants(participants: CollabParticipant[]): CollabParticipant[] {
return [
@@ -28,80 +19,17 @@ export function uniqueParticipants(participants: CollabParticipant[]): CollabPar
]
}
-export type ParticipantAvatarProps = {
- identity: CollabIdentity
- size?: 'sm' | 'default' | 'lg'
- // Marks a person whose browser tab is visible right now.
- active?: boolean
- label?: string
- className?: string
-}
-export function ParticipantAvatar({
- identity,
- size = 'sm',
- active = false,
- label = identity.name,
- className
-}: ParticipantAvatarProps) {
- // An identity without a picture gets the same generated face on every
- // client, so nobody shows up as bare initials.
- const face = useMemo(
- () => identity.avatar ?? facehashDataUrl(identity.name, identity.color),
- [identity.avatar, identity.name, identity.color]
- )
- return (
-
- {face && }
- {identity.name.trim().slice(0, 2).toUpperCase()}
- {active && }
-
- )
-}
-
export type ActivityProps = { scope?: 'page' | 'workspace'; className?: string }
export function Activity({ scope = 'page', className }: ActivityProps) {
const others = useOthers({ scope })
const self = useSelf()
- const participants = uniqueParticipants(self ? [self, ...others] : others)
+ const participants = self ? [self, ...others] : others
return (
-
- {participants.map(participant => (
-
- ))}
-
- )
-}
-
-export type SyncStatusProps = { className?: string }
-export function SyncStatus({ className }: SyncStatusProps) {
- const state = useConnection()
- const message =
- state.error ??
- (state.status === 'connecting'
- ? 'Connecting…'
- : state.status === 'disconnected'
- ? 'Disconnected'
- : state.pendingCount
- ? 'Saving…'
- : 'Saved')
- const Icon =
- state.error || state.status === 'disconnected'
- ? IconCloudOff
- : state.status === 'connecting' || state.pendingCount
- ? IconRefresh
- : IconCheck
- return (
-
-
- {message}
-
+ participant.identity.id)}
+ max={participants.length}
+ className={className}
+ />
)
}
@@ -121,7 +49,7 @@ function pointerPosition(value: CollabJsonValue): PointerPosition | null {
export type CursorsProps = { surface?: string; children: ReactNode; className?: string }
export function Cursors({ surface = 'default', children, className }: CursorsProps) {
const root = useRef(null)
- const cursor = usePresenceChannel(`cursor:${surface}`, null)
+ const cursor = usePresenceChannel(presenceChannels.cursor(surface), null)
const move = (event: PointerEvent) => {
if (event.pointerType === 'touch') return
const element = root.current
@@ -161,7 +89,7 @@ export function Cursors({ surface = 'default', children, className }: CursorsPro
key={participant.connectionId}
root={root}
point={point}
- identity={participant.identity}
+ id={participant.identity.id}
/>
) : null
})}
@@ -173,17 +101,17 @@ export function Cursors({ surface = 'default', children, className }: CursorsPro
type RemoteCursorProps = {
root: RefObject
point: PointerPosition
- identity: CollabIdentity
+ id: string
}
-function RemoteCursor({ root, point, identity }: RemoteCursorProps) {
- const marker = useRef(null)
+// Anchored points follow their target element through scrolling and layout
+// changes, so the position is applied to the node directly rather than
+// re-rendered on every scroll event.
+function RemoteCursor({ root, point, id }: RemoteCursorProps) {
+ const marker = useRef(null)
useLayoutEffect(() => {
const element = root.current
const node = marker.current
if (!element || !node) return
- // Dynamic identity colors and pointer geometry are data, applied to this
- // overlay's DOM node instead of adding arbitrary applet stylesheet rules.
- node.style.setProperty('--collab-color', identity.color)
const position = () => {
let x = point.x - element.scrollLeft
let y = point.y - element.scrollTop
@@ -213,67 +141,32 @@ function RemoteCursor({ root, point, identity }: RemoteCursorProps) {
element.removeEventListener('scroll', position, true)
window.removeEventListener('resize', position)
}
- }, [root, point, identity.color])
- return (
-
-
-
- {identity.name}
-
-
- )
+ }, [root, point])
+ return
}
type PresenceOutlineProps = {
people: CollabParticipant[]
+ icon: ReactNode
children: ReactNode
target: string
className?: string
onFocusCapture?: (event: FocusEvent) => void
onBlurCapture?: (event: FocusEvent) => void
}
-function PresenceOutline({
- people,
- children,
- target,
- className,
- onFocusCapture,
- onBlurCapture
-}: PresenceOutlineProps) {
- const root = useRef(null)
- const color = people[0]?.identity.color
- useLayoutEffect(() => {
- if (root.current && color) root.current.style.setProperty('--collab-color', color)
- }, [color])
+function PresenceOutline({ people, target, ...rest }: PresenceOutlineProps) {
return (
-
- {children}
- {people.length > 0 && (
-
-
-
-
- {people.map(person => person.identity.name).join(', ')}
-
-
-
- )}
-
+ ids={people.map(person => person.identity.id)}
+ {...rest}
+ />
)
}
export type PresenceFieldProps = { target: string; children: ReactNode; className?: string }
export function PresenceField({ target, children, className }: PresenceFieldProps) {
- const presence = usePresenceChannel(`field:${target}`, false)
+ const presence = usePresenceChannel(presenceChannels.field(target), false)
const people = useMemo(
() =>
uniqueParticipants(
@@ -285,6 +178,7 @@ export function PresenceField({ target, children, className }: PresenceFieldProp
}
className={className}
onFocusCapture={() => presence.setValue(true)}
onBlurCapture={event => {
@@ -304,7 +198,10 @@ export type SelectionProps = {
className?: string
}
export function Selection({ target, selected, children, className }: SelectionProps) {
- const { setValue, others } = usePresenceChannel(`selection:${target}`, selected)
+ const { setValue, others } = usePresenceChannel(
+ presenceChannels.selection(target),
+ selected
+ )
useEffect(() => setValue(selected), [selected, setValue])
const people = useMemo(
() =>
@@ -314,7 +211,12 @@ export function Selection({ target, selected, children, className }: SelectionPr
[others]
)
return (
-
+ }
+ className={className}
+ >
{children}
)
diff --git a/client/features/collab/fake-backend.test.ts b/client/features/collab/fake-backend.test.ts
new file mode 100644
index 00000000..1d574627
--- /dev/null
+++ b/client/features/collab/fake-backend.test.ts
@@ -0,0 +1,70 @@
+import { expect, test } from 'bun:test'
+
+import type { CollabParticipant } from '@/lib/collab/types'
+
+import { createFakeBackend } from './fake-backend'
+
+const me = { id: 'me', name: 'Me', color: '#8b5cf6' }
+const fig = { id: 'fig', name: 'Fig', color: '#f59e0b' }
+const figHere: CollabParticipant = {
+ connectionId: 'bot-fig',
+ identity: fig,
+ location: { page: 'kit' },
+ presence: []
+}
+
+test('the fake room connects at once with the viewer, everyone else, and the directory', () => {
+ const room = createFakeBackend({
+ self: me,
+ page: 'kit',
+ others: [figHere],
+ people: [{ id: 'ada', name: 'Ada', color: '#84cc16' }]
+ })
+ const state = room.getSnapshot()
+ expect(state.status).toBe('connected')
+ expect(state.participants.map(participant => participant.identity.id)).toEqual(['me', 'fig'])
+ expect(Object.keys(state.people).sort()).toEqual(['ada', 'fig', 'me'])
+ expect(room.getIdentity()).toEqual(me)
+ expect(room.getLocation()).toEqual({ page: 'kit' })
+})
+
+test('presence lands on the viewer and other people can be replaced', () => {
+ const room = createFakeBackend({ self: me, others: [figHere] })
+ room.setPresence({ registrationId: 'r1', surface: 'view:kit', channel: 'custom:mood', value: 1 })
+ expect(room.getSnapshot().participants[0]?.presence).toHaveLength(1)
+ room.deletePresence('r1')
+ expect(room.getSnapshot().participants[0]?.presence).toHaveLength(0)
+ room.setOthers([])
+ expect(room.getSnapshot().participants.map(participant => participant.identity.id)).toEqual([
+ 'me'
+ ])
+})
+
+test('shared state loads, commits the viewer’s writes, and receives other people’s', async () => {
+ const room = createFakeBackend({ self: null, entries: { demo: { note: 'hello' } } })
+ const release = room.acquireScope('demo')
+ expect(room.getScopeSnapshot('demo')).toMatchObject({
+ loaded: true,
+ synced: true,
+ entries: { note: 'hello' }
+ })
+ const outcome = await room.mutate('demo', [{ type: 'set', key: 'done', value: true }])
+ expect(outcome).toEqual({ status: 'committed', revision: 1 })
+ room.write('demo', [{ type: 'set', key: 'note', value: 'Fig was here' }])
+ expect(room.getScopeSnapshot('demo').entries).toEqual({ note: 'Fig was here', done: true })
+ expect(await room.mutate('demo', [])).toMatchObject({ status: 'rejected' })
+ release()
+})
+
+test('a slow room shows the write at once and saving until it commits', async () => {
+ const room = createFakeBackend({ self: me, latency: 20 })
+ room.acquireScope('demo')
+ await Bun.sleep(30)
+ const saving = room.mutate('demo', [{ type: 'set', key: 'note', value: 'draft' }])
+ expect(room.getScopeSnapshot('demo')).toMatchObject({
+ entries: { note: 'draft' },
+ isSaving: true
+ })
+ expect(await saving).toMatchObject({ status: 'committed' })
+ expect(room.getScopeSnapshot('demo').isSaving).toBe(false)
+})
diff --git a/client/features/collab/fake-backend.ts b/client/features/collab/fake-backend.ts
new file mode 100644
index 00000000..40970f81
--- /dev/null
+++ b/client/features/collab/fake-backend.ts
@@ -0,0 +1,159 @@
+import type {
+ CollabClientMessage,
+ CollabIdentity,
+ CollabJsonValue,
+ CollabOperation,
+ CollabParticipant,
+ CollabPresenceRegistration
+} from '@/lib/collab/types'
+
+import type { CollabBackend } from './backend'
+import { CollabStore } from './store'
+
+export type FakeBackendOptions = {
+ // The person looking at the page. Null behaves like a tab without a profile.
+ self: CollabIdentity | null
+ page?: string
+ others?: CollabParticipant[]
+ // People who have been here before and are not connected now.
+ people?: CollabIdentity[]
+ // Starting shared data by scope.
+ entries?: Record>
+ // How long a save takes, so saving states are visible.
+ latency?: number
+}
+
+export type FakeCollabBackend = CollabBackend & {
+ // Replace everyone else in the room: who is here, where, with what presence.
+ setOthers: (others: CollabParticipant[]) => void
+ // Commit a change as if someone else had made it.
+ write: (scope: string, operations: CollabOperation[]) => void
+}
+
+type Room = { entries: Record; revision: number }
+
+// An in-memory room behind the real connection store, for dev pages and tests.
+// The store keeps its optimistic writes, validation, and error handling; only
+// the server on the other end of the wire is made up.
+export function createFakeBackend({
+ self,
+ page = 'preview',
+ others = [],
+ people = [],
+ entries = {},
+ latency = 0
+}: FakeBackendOptions): FakeCollabBackend {
+ const store = new CollabStore()
+ const rooms = new Map()
+ const subscriptions = new Map()
+ const presence = new Map()
+ let everyoneElse = others
+ let location: CollabParticipant['location'] = { page }
+
+ const room = (scope: string): Room => {
+ let found = rooms.get(scope)
+ if (!found) {
+ found = { entries: { ...entries[scope] }, revision: 0 }
+ rooms.set(scope, found)
+ }
+ return found
+ }
+ const participants = (): CollabParticipant[] => [
+ ...(self
+ ? [{ connectionId: 'local', identity: self, location, presence: [...presence.values()] }]
+ : []),
+ ...everyoneElse
+ ]
+ const announce = () => store.receive({ type: 'participants', participants: participants() })
+ const commit = (scope: string, operations: CollabOperation[], operationId: string) => {
+ const target = room(scope)
+ for (const operation of operations) {
+ if (operation.type === 'delete') delete target.entries[operation.key]
+ else target.entries[operation.key] = operation.value
+ }
+ target.revision++
+ const subscriptionId = subscriptions.get(scope)
+ if (subscriptionId)
+ store.receive({
+ type: 'update',
+ scope,
+ revision: target.revision,
+ operations,
+ operationId,
+ subscriptionId
+ })
+ }
+ const later = (run: () => void) => {
+ if (latency > 0) setTimeout(run, latency)
+ else run()
+ }
+
+ store.setSender((message: CollabClientMessage) => {
+ switch (message.type) {
+ case 'location':
+ location = message.location
+ announce()
+ break
+ case 'presence:set': {
+ const { type: _type, ...registration } = message
+ presence.set(registration.registrationId, registration)
+ announce()
+ break
+ }
+ case 'presence:delete':
+ presence.delete(message.registrationId)
+ announce()
+ break
+ case 'subscribe': {
+ subscriptions.set(message.scope, message.subscriptionId)
+ const { entries: current, revision } = room(message.scope)
+ later(() =>
+ store.receive({
+ type: 'snapshot',
+ scope: message.scope,
+ subscriptionId: message.subscriptionId,
+ entries: { ...current },
+ revision
+ })
+ )
+ break
+ }
+ case 'unsubscribe':
+ subscriptions.delete(message.scope)
+ break
+ case 'mutate':
+ later(() => commit(message.scope, message.operations, message.operationId))
+ break
+ default:
+ break
+ }
+ })
+ store.setLocation(location)
+ store.receive({
+ type: 'welcome',
+ version: 1,
+ connectionId: 'local',
+ identity: self,
+ participants: participants(),
+ people
+ })
+
+ return {
+ getSnapshot: store.getSnapshot,
+ subscribe: store.subscribe,
+ getIdentity: () => self,
+ subscribeIdentity: () => () => {},
+ getLocation: () => store.getLocation(),
+ setPresence: registration => store.setPresence(registration),
+ deletePresence: registrationId => store.deletePresence(registrationId),
+ acquireScope: scope => store.acquireScope(scope),
+ getScopeSnapshot: scope => store.getScopeSnapshot(scope),
+ subscribeScope: (scope, listener) => store.subscribeScope(scope, listener),
+ mutate: (scope, operations) => store.mutate(scope, operations),
+ setOthers: next => {
+ everyoneElse = next
+ announce()
+ },
+ write: (scope, operations) => commit(scope, operations, crypto.randomUUID())
+ }
+}
diff --git a/client/features/collab/hooks.tsx b/client/features/collab/hooks.tsx
index 467b6d7f..a5e04e0e 100644
--- a/client/features/collab/hooks.tsx
+++ b/client/features/collab/hooks.tsx
@@ -15,16 +15,23 @@ import { useLocation } from 'wouter'
import type { AppletKind } from '@/lib/types'
import type { CollabJsonValue, CollabOperation, CollabParticipant } from '@/lib/collab/types'
+import { createLiveBackend, NO_BACKEND_STATE } from './backend'
+import type { CollabBackend } from './backend'
import { CollabClient } from './client'
import { getIdentity, installIdentityApi, subscribeIdentityStore } from './identity'
+import { resolvePerson } from './people'
+import type { ResolvedPerson } from './people'
import { participantForSelf } from './store'
-import type { CollabStore } from './store'
import type { MutationOutcome } from './store'
type AppletIdentity = { kind: AppletKind; name: string }
type Mount = { applet: AppletIdentity; active: boolean; surface: string }
-const WorkspaceContext = createContext(null)
+// Every hook below talks to this contract and never to a socket or a store,
+// so a dev page can swap in the in-memory backend from fake-backend.ts.
+const BackendContext = createContext(null)
const MountContext = createContext(null)
+const noSubscription = () => () => {}
+const noBackendState = () => NO_BACKEND_STATE
installIdentityApi()
@@ -39,6 +46,7 @@ export type CollabWorkspaceProviderProps = {
}
export function CollabWorkspaceProvider({ workspaceId, children }: CollabWorkspaceProviderProps) {
const [client] = useState(() => new CollabClient(workspaceId))
+ const backend = useMemo(() => createLiveBackend(client), [client])
const [path] = useLocation()
useEffect(() => {
window.dispatchEvent(new CustomEvent('moi:collab-ready'))
@@ -55,7 +63,14 @@ export function CollabWorkspaceProvider({ workspaceId, children }: CollabWorkspa
document.addEventListener('visibilitychange', update)
return () => document.removeEventListener('visibilitychange', update)
}, [client])
- return {children}
+ return {children}
+}
+
+export type CollabBackendProviderProps = { backend: CollabBackend; children: ReactNode }
+// Host-only: runs the hooks and components against any backend, such as the
+// in-memory one on dev pages.
+export function CollabBackendProvider({ backend, children }: CollabBackendProviderProps) {
+ return {children}
}
export type AppletCollabProviderProps = {
@@ -77,41 +92,69 @@ export function AppletCollabProvider({
return {children}
}
-function useCollabClient(): CollabClient {
- const client = useContext(WorkspaceContext)
- if (!client) throw new Error('Start moi with --experimental-collab to use collaboration.')
- return client
+function useBackend(): CollabBackend {
+ const backend = useContext(BackendContext)
+ if (!backend) throw new Error('Start moi with --experimental-collab to use collaboration.')
+ return backend
}
-export function useCollabStore(): CollabStore {
- return useCollabClient().store
+export function useConnection() {
+ const backend = useBackend()
+ return useSyncExternalStore(backend.subscribe, backend.getSnapshot, backend.getSnapshot)
}
-export function useConnection() {
- const store = useCollabStore()
- return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
+// Person components render anywhere: ids resolve through the backend, and to
+// "Unknown person" on a page that has none.
+function usePeopleSource() {
+ const backend = useContext(BackendContext)
+ const state = useSyncExternalStore(
+ backend?.subscribe ?? noSubscription,
+ backend?.getSnapshot ?? noBackendState,
+ backend?.getSnapshot ?? noBackendState
+ )
+ const self = useSyncExternalStore(
+ backend?.subscribeIdentity ?? subscribeIdentityStore,
+ backend?.getIdentity ?? getIdentity,
+ backend?.getIdentity ?? getIdentity
+ )
+ return { state, self }
+}
+
+export function usePerson(id: string): ResolvedPerson {
+ const { state, self } = usePeopleSource()
+ return useMemo(() => resolvePerson(state, self, id), [state, self, id])
+}
+
+export function usePeople(ids: readonly string[]): ResolvedPerson[] {
+ const { state, self } = usePeopleSource()
+ return ids.map(id => resolvePerson(state, self, id))
}
export function useSelf(): CollabParticipant | null {
+ const backend = useBackend()
const state = useConnection()
- const identity = useSyncExternalStore(subscribeIdentityStore, getIdentity, getIdentity)
+ const identity = useSyncExternalStore(
+ backend.subscribeIdentity,
+ backend.getIdentity,
+ backend.getIdentity
+ )
return useMemo(() => (identity ? participantForSelf(state, identity) : null), [state, identity])
}
export type OthersOptions = { scope?: 'page' | 'workspace' }
export function useOthers({ scope = 'page' }: OthersOptions = {}): CollabParticipant[] {
const state = useConnection()
- const store = useCollabStore()
+ const backend = useBackend()
return useMemo(
() =>
state.participants.filter(
participant =>
participant.connectionId !== state.connectionId &&
(scope === 'workspace' ||
- (store.getLocation() !== null &&
- participant.location?.page === store.getLocation()?.page))
+ (backend.getLocation() !== null &&
+ participant.location?.page === backend.getLocation()?.page))
),
- [state, store, scope]
+ [state, backend, scope]
)
}
@@ -121,13 +164,22 @@ export function useMount(): Mount {
return mount
}
+// Presence channel names as they travel, by what registers them. A fake
+// backend uses the same names to put made-up people on a surface.
+export const presenceChannels = {
+ custom: (channel: string) => `custom:${channel}`,
+ cursor: (surface: string) => `cursor:${surface}`,
+ field: (target: string) => `field:${target}`,
+ selection: (target: string) => `selection:${target}`
+}
+
export type PresenceValue = { participant: CollabParticipant; value: T }
export function usePresence(channel: string, initialValue: T) {
- return usePresenceChannel(`custom:${channel}`, initialValue)
+ return usePresenceChannel(presenceChannels.custom(channel), initialValue)
}
export function usePresenceChannel(channel: string, initialValue: T) {
- const store = useCollabStore()
+ const store = useBackend()
const mount = useMount()
const others = useOthers()
const [value, setValue] = useState(initialValue)
@@ -188,8 +240,7 @@ export type SharedOptions = { scope?: string }
export type SharedStateOptions = SharedOptions & { defaultValue?: T }
function useScope(explicitScope?: string) {
- const client = useCollabClient()
- const store = client.store
+ const store = useBackend()
const mount = useMount()
const scope = explicitScope ?? `applet:${mount.surface}`
const subscribe = useCallback(
@@ -204,13 +255,11 @@ function useScope(explicitScope?: string) {
if (!mount.active) return
live.current = true
const release = store.acquireScope(scope)
- const releaseConnection = client.acquireSharedState()
return () => {
live.current = false
release()
- releaseConnection()
}
- }, [client, store, scope, mount.active])
+ }, [store, scope, mount.active])
const mutate = useCallback(
(operations: CollabOperation[]): Promise => {
if (!live.current)
diff --git a/client/features/collab/index.ts b/client/features/collab/index.ts
index 1600544e..8c6e59ea 100644
--- a/client/features/collab/index.ts
+++ b/client/features/collab/index.ts
@@ -1,17 +1,32 @@
-import { Activity, Cursors, PresenceField, Selection, SyncStatus } from './components'
-import { useOthers, usePresence, useSelf, useSharedState, useSharedStore } from './hooks'
+import {
+ Activity,
+ Cursor,
+ Cursors,
+ Facepile,
+ Person,
+ PresenceField,
+ PresenceFrame,
+ PresenceGutter,
+ Selection
+} from './components'
+import { useOthers, usePerson, usePresence, useSelf, useSharedState, useSharedStore } from './hooks'
-export { CollabWorkspaceProvider, AppletCollabProvider } from './hooks'
+export { CollabWorkspaceProvider, AppletCollabProvider, CollabBackendProvider } from './hooks'
export { WorkspaceCollabControls } from './WorkspaceCollabControls'
export { getIdentity, subscribeIdentityStore } from './identity'
const appletApi = {
Activity,
+ Cursor,
Cursors,
+ Facepile,
+ Person,
PresenceField,
+ PresenceFrame,
+ PresenceGutter,
Selection,
- SyncStatus,
useOthers,
+ usePerson,
usePresence,
useSelf,
useSharedState,
diff --git a/client/features/collab/people.test.ts b/client/features/collab/people.test.ts
index c8957147..42613674 100644
--- a/client/features/collab/people.test.ts
+++ b/client/features/collab/people.test.ts
@@ -2,7 +2,7 @@ import { expect, test } from 'bun:test'
import type { CollabParticipant } from '@/lib/collab/types'
-import { groupPeople } from './people'
+import { groupPeople, resolvePerson } from './people'
const ada = { id: 'ada', name: 'Ada', color: '#f59e0b' }
const ken = { id: 'ken', name: 'Ken', color: '#3b82f6' }
@@ -41,3 +41,30 @@ test('a person whose tabs are all hidden has no pages, and the local user appear
{ identity: ken, self: false, pages: [] }
])
})
+test('a person resolves from live connections, then the directory, then the local profile', () => {
+ const stale = { ...ken, name: 'Old Ken' }
+ const source = {
+ participants: [connection('c1', ken, 'view:board'), connection('c2', ada, null)],
+ people: { ken: stale, fig: { id: 'fig', name: 'Fig', color: '#10b981' } }
+ }
+ expect(resolvePerson(source, null, 'ken')).toEqual({ id: 'ken', identity: ken, status: 'active' })
+ expect(resolvePerson(source, null, 'ada').status).toBe('away')
+ expect(resolvePerson(source, null, 'fig')).toEqual({
+ id: 'fig',
+ identity: source.people.fig,
+ status: 'offline'
+ })
+ expect(resolvePerson(source, null, 'nobody')).toEqual({
+ id: 'nobody',
+ identity: null,
+ status: 'offline'
+ })
+ const me = { id: 'me', name: 'Me', color: '#8b5cf6' }
+ expect(resolvePerson(source, me, 'me').identity).toEqual(me)
+ // Once Ken disconnects, the directory's copy of him answers.
+ expect(resolvePerson({ ...source, participants: [] }, null, 'ken')).toEqual({
+ id: 'ken',
+ identity: stale,
+ status: 'offline'
+ })
+})
diff --git a/client/features/collab/people.ts b/client/features/collab/people.ts
index 2f119795..7fc6f44a 100644
--- a/client/features/collab/people.ts
+++ b/client/features/collab/people.ts
@@ -1,9 +1,34 @@
import type { CollabIdentity, CollabParticipant } from '@/lib/collab/types'
+// active: a visible tab. away: connected, every tab hidden. offline: not here.
+export type PersonStatus = 'active' | 'away' | 'offline'
+export type ResolvedPerson = { id: string; identity: CollabIdentity | null; status: PersonStatus }
+export type PeopleSource = {
+ participants: CollabParticipant[]
+ people: Readonly>
+}
+
+// A person is referred to by id everywhere. Live connections are freshest, then the directory, then the local profile
+// before it has reached the server.
+export function resolvePerson(
+ source: PeopleSource,
+ self: CollabIdentity | null,
+ id: string
+): ResolvedPerson {
+ const connections = source.participants.filter(participant => participant.identity.id === id)
+ const status: PersonStatus = connections.some(connection => connection.location !== null)
+ ? 'active'
+ : connections.length
+ ? 'away'
+ : 'offline'
+ const identity = connections[0]?.identity ?? source.people[id] ?? (self?.id === id ? self : null)
+ return { id, identity, status }
+}
+
// One row per person for the people list: connections collapse by identity
// id, the current user comes first, and `pages` lists the tabs their visible
// browser tabs are on. No pages means every tab of theirs is hidden: away.
-export type Person = { identity: CollabIdentity; self: boolean; pages: string[] }
+export type PresentPerson = { identity: CollabIdentity; self: boolean; pages: string[] }
export type SelfConnection = {
identity: CollabIdentity | null
@@ -11,8 +36,11 @@ export type SelfConnection = {
page: string | null
}
-export function groupPeople(participants: CollabParticipant[], self: SelfConnection): Person[] {
- const people = new Map()
+export function groupPeople(
+ participants: CollabParticipant[],
+ self: SelfConnection
+): PresentPerson[] {
+ const people = new Map()
if (self.identity) {
people.set(self.identity.id, {
identity: self.identity,
diff --git a/client/features/collab/primitives.tsx b/client/features/collab/primitives.tsx
new file mode 100644
index 00000000..a531b71f
--- /dev/null
+++ b/client/features/collab/primitives.tsx
@@ -0,0 +1,431 @@
+import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
+import type { HTMLAttributes, ReactNode, Ref } from 'react'
+
+import { IconUser } from '@tabler/icons-react'
+import { wcagLuminance } from 'culori'
+
+import { cn } from '@/client/lib/cn'
+import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from '@/ui-components/avatar'
+import { Badge } from '@/ui-components/badge'
+
+import { facehashDataUrl } from './facehash-avatar'
+import { usePeople, usePerson } from './hooks'
+
+// Person components. A person is always an id (`id`, or `ids` for several),
+// which resolves to the current name, face, and status through the workspace.
+
+export type PersonSize = 'xs' | 'sm' | 'md' | 'lg'
+const AVATAR_SIZE = { xs: 'xs', sm: 'sm', md: 'default', lg: 'lg' } as const
+// Shown for an id nobody in this workspace has ever used.
+const UNKNOWN_NAME = 'Unknown person'
+
+// Black or white, whichever reads on the person's color.
+export function readableOn(color: string): string {
+ return (wcagLuminance(color) ?? 0) > 0.3 ? 'oklch(0 0 0)' : 'oklch(1 0 0)'
+}
+
+// Identity colors are data. They land on the node as custom properties
+// instead of stylesheet rules; classes read `--collab-color` and
+// `--collab-contrast` from there. An unknown person gets quiet theme tones.
+export function usePersonColor(color: string | undefined) {
+ return useCallback(
+ (node: HTMLElement | null) => {
+ if (!node) return
+ node.style.setProperty('--collab-color', color ?? 'var(--muted-foreground)')
+ node.style.setProperty('--collab-contrast', color ? readableOn(color) : 'var(--background)')
+ },
+ [color]
+ )
+}
+
+export type PersonProps = {
+ id: string
+ size?: PersonSize
+ // Only the face, for stacks, gutters, and tight rows.
+ avatarOnly?: boolean
+ you?: boolean
+ // A quiet second line: where they are, what they are doing. The compact
+ // `xs` size keeps it on the same line.
+ detail?: ReactNode
+ // The green dot: this person has the workspace open in a visible tab right
+ // now. Away (every tab hidden) and offline people never get one.
+ showStatus?: boolean
+ label?: string
+ className?: string
+}
+export function Person({
+ id,
+ size = 'md',
+ avatarOnly = false,
+ you = false,
+ detail,
+ showStatus = true,
+ label,
+ className
+}: PersonProps) {
+ const resolved = usePerson(id)
+ const identity = resolved.identity
+ const name = identity?.name ?? UNKNOWN_NAME
+ // A profile without a picture gets the same generated face on every client,
+ // so nobody shows up as bare initials.
+ const picture = identity?.avatar
+ const faceName = identity?.name
+ const faceColor = identity?.color
+ const face = useMemo(
+ () => (faceName && faceColor ? (picture ?? facehashDataUrl(faceName, faceColor)) : undefined),
+ [picture, faceName, faceColor]
+ )
+ const compact = size === 'xs'
+ const avatar = (
+
+ {face && }
+
+ {identity ? (
+ identity.name.trim().slice(0, 2).toUpperCase()
+ ) : (
+
+ )}
+
+ {showStatus && resolved.status === 'active' && }
+
+ )
+ if (avatarOnly) return avatar
+ return (
+
+ {avatar}
+
+
+ {name}
+ {you && (you)}
+
+ {detail && (
+
+ {compact && · }
+ {detail}
+
+ )}
+
+
+ )
+}
+
+export type FacepileProps = {
+ ids: readonly string[]
+ max?: number
+ size?: 'xs' | 'sm' | 'md'
+ // Dots are off in a pile, where they crowd the overlapping faces.
+ showStatus?: boolean
+ className?: string
+}
+export function Facepile({
+ ids,
+ max = 3,
+ size = 'sm',
+ showStatus = false,
+ className
+}: FacepileProps) {
+ const unique = [...new Set(ids)]
+ const shown = unique.slice(0, max)
+ const hidden = unique.length - shown.length
+ return (
+
+ {shown.map(id => (
+
+ ))}
+ {hidden > 0 && (
+
+ +{hidden}
+
+ )}
+
+ )
+}
+
+type PersonTagProps = {
+ name: string
+ color: string | undefined
+ icon?: ReactNode
+ className?: string
+}
+// The kit's badge in the person's color, wherever a name marks a place.
+function PersonTag({ name, color, icon, className }: PersonTagProps) {
+ return (
+
+ {icon}
+ {name}
+
+ )
+}
+
+export type CursorProps = {
+ id: string
+ // Pointer tip in the parent's coordinate space. Leave both out to place the
+ // node yourself through `ref`.
+ x?: number
+ y?: number
+ label?: boolean
+ className?: string
+ ref?: Ref
+}
+export function Cursor({ id, x, y, label = true, className, ref }: CursorProps) {
+ const { identity } = usePerson(id)
+ const node = useRef(null)
+ const setColor = usePersonColor(identity?.color)
+ const attach = useCallback(
+ (element: HTMLSpanElement | null) => {
+ node.current = element
+ setColor(element)
+ if (typeof ref === 'function') ref(element)
+ else if (ref) ref.current = element
+ },
+ [ref, setColor]
+ )
+ useLayoutEffect(() => {
+ if (node.current && x !== undefined && y !== undefined)
+ node.current.style.transform = `translate(${x}px, ${y}px)`
+ }, [x, y])
+ return (
+
+
+
+ {label && (
+
+ )}
+
+
+ )
+}
+
+export type PresenceFrameProps = HTMLAttributes & {
+ // Everyone at this element; the first person's color draws the frame.
+ ids: readonly string[]
+ icon?: ReactNode
+ children: ReactNode
+}
+// Wraps anything. With one element inside, the frame hugs that element and
+// takes its corner radius, so a field, a card, a button, and a round avatar
+// each get a frame of their own shape with no styling from the caller.
+export function PresenceFrame({ ids, icon, children, className, ...rest }: PresenceFrameProps) {
+ const resolved = usePeople(ids)
+ const lead = resolved[0]
+ return (
+
+ {children}
+ {lead && (
+ person.identity?.name ?? 'Someone').join(', ')}
+ color={lead.identity?.color}
+ icon={icon}
+ />
+ )}
+
+ )
+}
+
+// A name does not fit within a frame narrower than this, so its tag starts at
+// the frame's left edge and runs past it instead of ending at the right edge.
+const NARROW_FRAME = 160
+const CORNERS = [
+ 'borderTopLeftRadius',
+ 'borderTopRightRadius',
+ 'borderBottomRightRadius',
+ 'borderBottomLeftRadius'
+] as const
+
+type FrameOutlineProps = { names: string; color: string | undefined; icon?: ReactNode }
+function FrameOutline({ names, color, icon }: FrameOutlineProps) {
+ const node = useRef(null)
+ const setColor = usePersonColor(color)
+ const attach = useCallback(
+ (element: HTMLDivElement | null) => {
+ node.current = element
+ setColor(element)
+ },
+ [setColor]
+ )
+ // Runs after every render, because whatever is wrapped may have changed shape
+ // in the same render, and again whenever the wrapped element resizes.
+ useLayoutEffect(() => {
+ const outline = node.current
+ const frame = outline?.parentElement
+ if (!outline || !frame) return
+ const fit = () => {
+ // The one in-flow element inside is what the frame is about; hidden
+ // inputs and other out-of-flow helpers beside it do not count. With
+ // several elements, or bare text, the frame goes around all of it.
+ 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 box = outline.style
+ box.left = subject ? `${subject.offsetLeft}px` : ''
+ box.top = subject ? `${subject.offsetTop}px` : ''
+ box.right = subject ? 'auto' : ''
+ box.bottom = subject ? 'auto' : ''
+ box.width = subject ? `${subject.offsetWidth}px` : ''
+ box.height = subject ? `${subject.offsetHeight}px` : ''
+ // Square elements, usually bare text, keep the outline's own slight
+ // rounding and get more air so the content does not touch the line.
+ const shape = getComputedStyle(subject ?? frame)
+ const rounded = CORNERS.some(corner => parseFloat(shape[corner]) > 0)
+ for (const corner of CORNERS) box[corner] = rounded ? shape[corner] : ''
+ // Both states are spelled out, and every class that depends on them is a
+ // variant: inside an applet, the applet's own copy of a bare utility
+ // outranks a host variant that tries to override it.
+ outline.dataset.shape = rounded ? 'rounded' : 'square'
+ outline.dataset.tag = (subject ?? frame).offsetWidth < NARROW_FRAME ? 'start' : 'end'
+ }
+ 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 (
+
+ )
+}
+
+export type GutterPerson = { id: string; target: string }
+export type PresenceGutterProps = HTMLAttributes & {
+ // Who is at which block. Blocks among the children carry `data-collab-target`.
+ people: readonly GutterPerson[]
+ children: ReactNode
+}
+// Reserves a gutter beside the children and floats a small face next to the
+// block each person is on. A face glides to the next block instead of
+// reappearing there, the way it does in a shared document.
+export function PresenceGutter({ people, children, className, ...rest }: PresenceGutterProps) {
+ const stacked = new Map()
+ return (
+
+ {children}
+
+ {people.map(entry => {
+ const stack = stacked.get(entry.target) ?? 0
+ stacked.set(entry.target, stack + 1)
+ return
+ })}
+
+
+ )
+}
+
+type GutterMarkProps = { entry: GutterPerson; stack: number }
+function GutterMark({ entry, stack }: GutterMarkProps) {
+ const node = useRef(null)
+ useLayoutEffect(() => {
+ const mark = node.current
+ // The gutter is already in the DOM here, while a ref on it would not be
+ // attached yet on the first render.
+ const element = mark?.closest('[data-presence-gutter]')
+ if (!element || !mark) return
+ const place = () => {
+ const target = [...element.querySelectorAll('[data-collab-target]')].find(
+ candidate => candidate.dataset.collabTarget === entry.target
+ )
+ if (!target) {
+ mark.hidden = true
+ return
+ }
+ const bounds = element.getBoundingClientRect()
+ const rect = target.getBoundingClientRect()
+ // Centered on the block's first line; later people on the same block
+ // overlap toward the content like a facepile.
+ const y = rect.top - bounds.top + Math.max(0, (Math.min(rect.height, 24) - 20) / 2)
+ mark.hidden = false
+ mark.style.transform = `translate(${stack * 8}px, ${y}px)`
+ }
+ place()
+ const observer = new ResizeObserver(place)
+ observer.observe(element)
+ element.addEventListener('scroll', place, true)
+ window.addEventListener('resize', place)
+ return () => {
+ observer.disconnect()
+ element.removeEventListener('scroll', place, true)
+ window.removeEventListener('resize', place)
+ }
+ }, [entry.target, stack])
+ return (
+
+
+
+ )
+}
diff --git a/client/features/dev/DevIndexPage.tsx b/client/features/dev/DevIndexPage.tsx
index d34a838c..5542ee4f 100644
--- a/client/features/dev/DevIndexPage.tsx
+++ b/client/features/dev/DevIndexPage.tsx
@@ -12,6 +12,12 @@ const DEV_ROUTES: DevRouteEntry[] = [
title: 'Collab',
description: 'Give this tab a collab identity: a name, a color, and a generated avatar.'
},
+ {
+ path: '/dev/collab-kit',
+ title: 'Collab kit',
+ description:
+ 'Everything applets get from moi/collab on an in-memory room: components, connected components, and live hooks.'
+ },
{
path: '/dev/blobatar-shapes',
title: 'Blobatar shape builder',
diff --git a/client/features/dev/DevRoutes.tsx b/client/features/dev/DevRoutes.tsx
index 6c2a595a..e9aafbb5 100644
--- a/client/features/dev/DevRoutes.tsx
+++ b/client/features/dev/DevRoutes.tsx
@@ -13,6 +13,9 @@ import { UiComponentsPage } from './UiComponentsPage'
// (and its dependencies) lives in this folder and loads as one lazy chunk —
// see the dynamic import in AppRouter. /dev itself is the index; list new
// routes there too.
+const DevCollabKitPage = lazy(() =>
+ import('../collab/DevCollabKitPage').then(module => ({ default: module.DevCollabKitPage }))
+)
const DevCollabPage = lazy(() =>
import('../collab/DevCollabPage').then(module => ({ default: module.DevCollabPage }))
)
@@ -20,6 +23,11 @@ const DevCollabPage = lazy(() =>
export default function DevRoutes() {
return (
+
+
+
+
+
diff --git a/server/collab/applet-module.ts b/server/collab/applet-module.ts
index f86f660e..782d2535 100644
--- a/server/collab/applet-module.ts
+++ b/server/collab/applet-module.ts
@@ -10,6 +10,7 @@ function api() {
}
export function useSelf(...args) { return api().useSelf(...args); }
export function useOthers(...args) { return api().useOthers(...args); }
+export function usePerson(...args) { return api().usePerson(...args); }
export function usePresence(...args) { return api().usePresence(...args); }
export function useSharedState(...args) { return api().useSharedState(...args); }
export function useSharedStore(...args) { return api().useSharedStore(...args); }
@@ -17,5 +18,9 @@ export function Cursors(props) { return createElement(api().Cursors, props); }
export function Activity(props) { return createElement(api().Activity, props); }
export function PresenceField(props) { return createElement(api().PresenceField, props); }
export function Selection(props) { return createElement(api().Selection, props); }
-export function SyncStatus(props) { return createElement(api().SyncStatus, props); }
+export function Person(props) { return createElement(api().Person, props); }
+export function Facepile(props) { return createElement(api().Facepile, props); }
+export function Cursor(props) { return createElement(api().Cursor, props); }
+export function PresenceFrame(props) { return createElement(api().PresenceFrame, props); }
+export function PresenceGutter(props) { return createElement(api().PresenceGutter, props); }
`
diff --git a/server/collab/skill/collab-env.d.ts b/server/collab/skill/collab-env.d.ts
index a023c86e..4a2a4552 100644
--- a/server/collab/skill/collab-env.d.ts
+++ b/server/collab/skill/collab-env.d.ts
@@ -80,6 +80,58 @@ declare module 'moi/collab' {
className?: string
}
export function Selection(props: SelectionProps): ReactElement
- export type SyncStatusProps = { className?: string }
- export function SyncStatus(props: SyncStatusProps): ReactElement
+
+ // People components. Store ids in shared data, not profiles: a person is
+ // always given by id (`id`, or `ids` for several) and resolves to the current
+ // name, face, and status through the workspace, including people who have left.
+ export type PersonStatus = 'active' | 'away' | 'offline'
+ export function usePerson(id: string): {
+ id: string
+ identity: CollabIdentity | null
+ status: PersonStatus
+ }
+ export type PersonSize = 'xs' | 'sm' | 'md' | 'lg'
+ export type PersonProps = {
+ id: string
+ size?: PersonSize
+ avatarOnly?: boolean
+ you?: boolean
+ detail?: ReactNode
+ // The green dot: this person has the workspace open in a visible tab now.
+ showStatus?: boolean
+ label?: string
+ className?: string
+ }
+ export function Person(props: PersonProps): ReactElement
+ export type FacepileProps = {
+ ids: string[]
+ max?: number
+ size?: 'xs' | 'sm' | 'md'
+ showStatus?: boolean
+ className?: string
+ }
+ export function Facepile(props: FacepileProps): ReactElement
+ export type CursorProps = {
+ id: string
+ x?: number
+ y?: number
+ label?: boolean
+ className?: string
+ }
+ export function Cursor(props: CursorProps): ReactElement
+ // Wraps anything: the frame hugs a single child element and takes its radius.
+ export type PresenceFrameProps = {
+ ids: string[]
+ icon?: ReactNode
+ children: ReactNode
+ className?: string
+ }
+ export function PresenceFrame(props: PresenceFrameProps): ReactElement
+ export type GutterPerson = { id: string; target: string }
+ export type PresenceGutterProps = {
+ people: GutterPerson[]
+ children: ReactNode
+ className?: string
+ }
+ export function PresenceGutter(props: PresenceGutterProps): ReactElement
}
diff --git a/server/collab/skill/index.test.ts b/server/collab/skill/index.test.ts
index 2c202326..ef01565a 100644
--- a/server/collab/skill/index.test.ts
+++ b/server/collab/skill/index.test.ts
@@ -13,11 +13,19 @@ import { COLLAB_REFERENCE_SOURCE_PATH, installCollabSkill, removeCollabSkill } f
type ActualHooks = Pick<
typeof Hooks,
- 'useSelf' | 'useOthers' | 'usePresence' | 'useSharedState' | 'useSharedStore'
+ 'useSelf' | 'useOthers' | 'usePerson' | 'usePresence' | 'useSharedState' | 'useSharedStore'
>
type ActualComponents = Pick<
typeof Components,
- 'Activity' | 'Cursors' | 'PresenceField' | 'Selection' | 'SyncStatus'
+ | 'Activity'
+ | 'Cursor'
+ | 'Cursors'
+ | 'Facepile'
+ | 'Person'
+ | 'PresenceField'
+ | 'PresenceFrame'
+ | 'PresenceGutter'
+ | 'Selection'
>
// This assignment is checked by tsc without importing React into the server.
const declarationsMatch: ActualHooks & ActualComponents extends typeof CollabApi ? true : false =
diff --git a/server/collab/skill/references/COLLABORATIVE.md b/server/collab/skill/references/COLLABORATIVE.md
index 6c3a9962..ff781d17 100644
--- a/server/collab/skill/references/COLLABORATIVE.md
+++ b/server/collab/skill/references/COLLABORATIVE.md
@@ -23,7 +23,6 @@ import {
Cursors,
PresenceField,
Selection,
- SyncStatus,
useSelf,
useOthers,
usePresence,
@@ -57,7 +56,6 @@ deduplicates its avatar stack by `identity.id`.
| `` | Wraps a surface and displays other pointers. Accepts `children`, optional stable `surface` name and `className`. |
| `` | Wraps a control, reports focus, and shows other editors. Required stable `target`, `children`; optional `className`. |
| `` | Shows other selections around an item. Required `target`, local `selected` boolean, and `children`; optional `className`. |
-| `` | Connection/save status. Optional `className`. Place beside controls that change shared data. |
```tsx
@@ -70,11 +68,41 @@ deduplicates its avatar stack by `identity.id`.
```
+Wrap one element and add no styling for the outline: it hugs that element and takes its corner
+radius, whether it is an input, a card, a button, or a round avatar.
+
Targets describe data and must be stable across people, for example `task/42/title`. Do not use
array indices, display names, or random ids generated while rendering. Field and selection
wrappers also anchor cursors to meaningful items. For custom controls, an element may use
`data-collab-target="task/42"` inside ``.
+## People components
+
+Store ids in shared data, not profiles. A person is always given by id: the prop is `id` for one
+person and `ids` for several. Each component resolves the current name, face, and status itself:
+live connections first, then the workspace's people directory, which remembers everyone who has
+joined. An id nobody has used renders as "Unknown person".
+
+| Component or hook | Props and behavior |
+| ------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
+| `usePerson(id)` | `{ id, identity, status }`. `identity` is `null` for an unknown id. `status` is `active` (a visible tab), `away`, or `offline`. |
+| `` | Avatar and name. `id`; optional `size` (`xs` keeps one line), `avatarOnly`, `you`, `detail`, `showStatus` (true), `label`. |
+| `` | Overlapping faces then a count. `ids`; optional `max` (3), `size` (`xs`, `sm`, `md`), `showStatus` (false). |
+| `` | One pointer with a name tag. `id`, `x`, `y` in the parent's coordinates; optional `label` (true). |
+| `` | Outline and names around `children`; hugs a single child element and takes its radius. `ids`; optional `icon`. |
+| `` | A face beside the block each person is on, gliding between blocks. `people` as `{ id, target }`. |
+
+The green dot (`showStatus`) means the person has this workspace open in a visible tab right now.
+Away people (every tab hidden) and offline people have none. Every people component also accepts
+`className`.
+
+```tsx
+// Write the id, render the person.
+await tasks.set(`${id}/assignee`, self.identity.id)
+
+ other.identity.id)} />
+```
+
Presence is advisory. A colored field does not lock it or save its value. `` reports
your local selection; keep `selectedId` in local React state. Multiple cursor surfaces in one
applet need different stable `surface` names. Free space in differently arranged canvases has
@@ -132,6 +160,7 @@ The result exposes `value`, `exists`, `loaded`, `canWrite`, `isSaving`, `error`,
- `unknown` means a save could not be confirmed. Keep the user's draft and let them deliberately
reapply it after reconnecting. Do not automatically replay old or offline edits.
- Local optimistic changes may appear while `isSaving` is true. Treat `committed` as confirmation.
+- There is no built-in save indicator. Render `isSaving` and `error` where the person is editing.
```tsx
const outcome = await title.setValue(draft)
@@ -195,7 +224,7 @@ errors. Put it in a view source file and rebuild with `moi bundle`.
```tsx
import { useState } from 'react'
-import { Activity, Cursors, PresenceField, SyncStatus, useSharedStore } from 'moi/collab'
+import { Activity, Cursors, PresenceField, useSharedStore } from 'moi/collab'
export const config = { title: 'Team tasks' }
@@ -225,7 +254,6 @@ export default function TeamTasks() {