Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .agents/rules/pull-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ alwaysApply: true
---

- Lead with 2–3 sentences of what changed and why, describing behavior the reviewer can observe — never a file-by-file or function-by-function inventory; the diff already shows that.
- Language: concise, simple, easy to read by ACTUAL HUMANS. Formatting: less.
- Show the artifact right after the intro: the rendered output, a screenshot for UI changes, a wire sample. One example explains more than any amount of prose.
- Keep the whole body around 25 lines. Put deep detail in repo docs (`NOTES.md`, rules) or commit messages and link to it instead of duplicating.
- Update the description whenever the design pivots mid-PR. The body must describe the final state — a description written at PR-open and never touched becomes misleading.
- Call out the 2–3 risky or surprising spots a reviewer should look at closely, with the reason each is risky.
- End with how the change was verified (test command and count, manual steps).
- Optionally, call out the risky or surprising spots a reviewer should look at closely, with the reason each is risky.
- Wrap literal tags like `<moi-context>` in backticks — bare angle-bracket tags are parsed as HTML by GitHub and silently vanish.
- Write the title as behavior, sentence case, no trailing period (e.g. `Add moi-context envelope for ambient workspace state`).
89 changes: 57 additions & 32 deletions client/features/collab/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,23 @@ import type { CollabClientMessage } from '@/lib/collab/types'
import { CollabService } from '@/server/collab/service'

import { CollabClient, NO_ENGINE } from './client'
import { getCurrentUser, setHostState } from './host-state'
import { getCurrentUser, publishUsers } from './host-state'
import type { UserProfileInput } from './host-state'

const originalLocation = Object.getOwnPropertyDescriptor(globalThis, 'location')
const originalWebSocket = Object.getOwnPropertyDescriptor(globalThis, 'WebSocket')
let service: CollabService
let sockets: TestSocket[]
let stop: (() => void) | undefined

// Publish as an outer host would: one viewer, listed in the directory.
function signIn(user: UserProfileInput, others: readonly UserProfileInput[] = []) {
publishUsers({ me: user.id, users: [user, ...others] })
}
function signOut() {
publishUsers({ users: [] })
}

class TestSocket {
static OPEN = 1
readyState = 0
Expand Down Expand Up @@ -56,7 +65,7 @@ class TestSocket {
}

beforeEach(() => {
setHostState({ currentUser: undefined, workspaces: {} })
signOut()
sockets = []
service = new CollabService((connectionId, message) => {
sockets
Expand All @@ -74,7 +83,7 @@ afterEach(() => {
stop?.()
stop = undefined
service.close()
setHostState({ currentUser: undefined, workspaces: {} })
signOut()
if (originalLocation) Object.defineProperty(globalThis, 'location', originalLocation)
else Reflect.deleteProperty(globalThis, 'location')
if (originalWebSocket) Object.defineProperty(globalThis, 'WebSocket', originalWebSocket)
Expand All @@ -100,22 +109,22 @@ test('an explicit user enables workspace presence without an applet and clearing
const engine = new CollabClient('workspace')
stop = engine.start()
const profile = { id: 'alice', name: 'Alice', color: 'emerald' } as const
setHostState({ currentUser: profile, workspaces: {} })
signIn(profile)
expect(sockets).toHaveLength(1)
const socket = sockets[0]!
socket.open()
expect(socket.sent[0]).toMatchObject({ type: 'join', profile })
expect(engine.getSnapshot().connections[0]?.userId).toEqual(profile.id)
setHostState({ currentUser: { ...profile, name: 'Alicia' }, workspaces: {} })
signIn({ ...profile, name: 'Alicia' })
expect(socket.sent.at(-1)).toMatchObject({ type: 'profile', profile: { name: 'Alicia' } })
expect(sockets).toHaveLength(1)
setHostState({ currentUser: undefined, workspaces: {} })
signOut()
expect(socket.readyState).toBe(3)
expect(engine.getSnapshot().status).toBe('disconnected')
})

test('two tabs send the same resolved color for a host user without one', () => {
setHostState({ currentUser: { id: 'alice' }, workspaces: {} })
signIn({ id: 'alice' })
const first = new CollabClient('workspace')
const second = new CollabClient('workspace')
stop = first.start()
Expand All @@ -135,7 +144,7 @@ test('two tabs send the same resolved color for a host user without one', () =>
})

test('reconnecting restores current presence and removed registrations stay gone', async () => {
setHostState({ currentUser: { id: 'alice', name: 'Alice', color: 'emerald' }, workspaces: {} })
signIn({ id: 'alice', name: 'Alice', color: 'emerald' })
const engine = new CollabClient('workspace')
stop = engine.start()
sockets[0]!.open()
Expand Down Expand Up @@ -176,7 +185,7 @@ test('a rejected join retries after capacity frees and cleanup cancels pending w
profile: { id: `occupied-${index}`, color: 'blue' }
})
}
setHostState({ currentUser: { id: 'alice', color: 'emerald' }, workspaces: {} })
signIn({ id: 'alice', color: 'emerald' })
const heartbeat = spyOn(globalThis, 'setInterval')
const clearHeartbeat = spyOn(globalThis, 'clearInterval')
const timer = spyOn(globalThis, 'setTimeout')
Expand Down Expand Up @@ -231,7 +240,7 @@ test('a rejected join retries after capacity frees and cleanup cancels pending w
})

test('errors after joining keep the connection available for further presence', () => {
setHostState({ currentUser: { id: 'alice', color: 'emerald' }, workspaces: {} })
signIn({ id: 'alice', color: 'emerald' })
const client = new CollabClient('workspace')
stop = client.start()
const socket = sockets[0]!
Expand All @@ -250,50 +259,59 @@ test('errors after joining keep the connection available for further presence',

test('profile edits publish immediately without changing the user id or reconnecting', () => {
const alice = { id: 'alice', name: 'Alice', color: 'emerald' } as const
setHostState({ currentUser: alice, workspaces: {} })
signIn(alice)
const engine = new CollabClient('workspace')
stop = engine.start()
sockets[0]!.open()
const edited = { ...alice, name: 'Ada Lovelace', color: 'cyan' } as const
setHostState({ currentUser: edited, workspaces: {} })
signIn(edited)
expect(sockets).toHaveLength(1)
expect(sockets[0]!.readyState).toBe(TestSocket.OPEN)
expect(sockets[0]!.sent.at(-1)).toEqual({ type: 'profile', profile: edited })
expect(engine.getSnapshot().users).toEqual([edited])
})

test('a directory change that leaves the viewer alone sends nothing to the room', () => {
const alice = { id: 'alice', name: 'Alice', color: 'emerald' } as const
const bob = { id: 'bob', name: 'Bob', color: 'blue' } as const
signIn(alice, [bob])
const engine = new CollabClient('workspace')
stop = engine.start()
sockets[0]!.open()
const sent = sockets[0]!.sent.length
signIn(alice, [{ ...bob, name: 'Robert' }])
expect(engine.getDirectory()).toEqual([alice, { ...bob, name: 'Robert' }])
expect(sockets[0]!.sent).toHaveLength(sent)
expect(sockets).toHaveLength(1)
})

test('changing user reconnects and never sends the host directory over the socket', async () => {
setHostState({ currentUser: { id: 'alice', name: 'Alice', color: 'emerald' }, workspaces: {} })
signIn({ id: 'alice', name: 'Alice', color: 'emerald' })
const engine = new CollabClient('workspace')
stop = engine.start()
sockets[0]!.open()
setHostState({ currentUser: { id: 'bob', name: 'Bob', color: 'blue' }, workspaces: {} })
signIn({ id: 'bob', name: 'Bob', color: 'blue' })
expect(sockets[0]!.readyState).toBe(3)
await Bun.sleep(550)
sockets[1]!.open()
expect(sockets[1]!.sent[0]).toMatchObject({ type: 'join', profile: { id: 'bob' } })
expect(sockets[1]!.sent.every(message => !('users' in message))).toBe(true)
})

test('disabled engine exposes host profiles and readiness without starting a transport', () => {
test('disabled engine exposes host profiles without starting a transport', () => {
const alice = { id: 'alice', name: 'Alice', color: 'emerald' } as const
setHostState({
currentUser: alice,
workspaces: { workspace: { status: 'loading' } }
})
const bob = { id: 'bob', name: 'Bob', color: 'blue' } as const
signIn(alice)
const engine = new CollabClient('workspace', false)
stop = engine.start()
expect(engine.enabled).toBe(false)
expect(engine.workspaceId).toBe('workspace')
expect(engine.getCurrentUser()).toEqual(alice)
expect(engine.getWorkspaceDirectory()).toEqual({ status: 'loading' })
expect(engine.getDirectory()).toEqual([alice])
let updates = 0
const unsubscribe = engine.subscribeWorkspaceUsers(() => updates++)
setHostState({
currentUser: alice,
workspaces: { workspace: { status: 'ready', users: [alice] } }
})
expect(engine.getWorkspaceDirectory()).toEqual({ status: 'ready', users: [alice] })
const unsubscribe = engine.subscribeDirectory(() => updates++)
signIn(alice, [bob])
expect(engine.getDirectory()).toEqual([alice, bob])
expect(updates).toBe(1)
engine.setLocation({ page: 'overview', status: 'active' })
engine.setPresence({
Expand All @@ -312,15 +330,15 @@ test('disabled engine exposes host profiles and readiness without starting a tra

test('engine cleanup survives remounting and does not leave user listeners or retry sockets', async () => {
const alice = { id: 'alice', name: 'Alice', color: 'emerald' } as const
setHostState({ currentUser: alice, workspaces: {} })
signIn(alice)
const engine = new CollabClient('workspace')
stop = engine.start()
engine.start()()
sockets[0]!.open()
setHostState({ currentUser: { ...alice, name: 'Alicia' }, workspaces: {} })
signIn({ ...alice, name: 'Alicia' })
expect(sockets[0]!.sent.filter(message => message.type === 'profile')).toHaveLength(1)
stop()
setHostState({ currentUser: { ...alice, name: 'Alice again' }, workspaces: {} })
signIn({ ...alice, name: 'Alice again' })
await Bun.sleep(550)
expect(sockets).toHaveLength(1)
stop = engine.start()
Expand All @@ -329,11 +347,18 @@ test('engine cleanup survives remounting and does not leave user listeners or re
expect(engine.getSnapshot().status).toBe('connected')
})

test('the default engine keeps the current user readable while workspace operations stay inert', () => {
test('the default engine keeps the viewer and directory readable while workspace operations stay inert', () => {
const alice = { id: 'alice', name: 'Alice', color: 'emerald' } as const
setHostState({ currentUser: alice, workspaces: {} })
const bob = { id: 'bob', name: 'Bob', color: 'blue' } as const
signIn(alice, [bob])
expect(NO_ENGINE.getCurrentUser()).toEqual(alice)
expect(NO_ENGINE.getWorkspaceDirectory()).toBeUndefined()
// The directory belongs to the app, not to a workspace, so it needs no engine.
expect(NO_ENGINE.getDirectory()).toEqual([alice, bob])
let updates = 0
const unsubscribe = NO_ENGINE.subscribeDirectory(() => updates++)
signIn(alice)
unsubscribe()
expect([NO_ENGINE.getDirectory(), updates]).toEqual([[alice], 1])
expect(NO_ENGINE.getPresenceSnapshot('one', 'field')).toBe(
NO_ENGINE.getPresenceSnapshot('two', 'cursor')
)
Expand Down
21 changes: 10 additions & 11 deletions client/features/collab/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,10 @@ import type {

import {
getCurrentUser,
getWorkspaceDirectory,
getDirectory,
subscribeCurrentUserStore,
subscribeWorkspaceUsersStore
subscribeDirectoryStore
} from './host-state'
import type { WorkspaceDirectory } from './host-state'
import { CollabStore, DISCONNECTED_STATE } from './store'
import type { ClientState, PresenceEntry } from './store'

Expand All @@ -32,8 +31,10 @@ export type CollabEngineApi = {
subscribe: (listener: () => void) => Unsubscribe
getCurrentUser: () => UserProfile | undefined
subscribeCurrentUser: (listener: () => void) => Unsubscribe
getWorkspaceDirectory: () => WorkspaceDirectory | undefined
subscribeWorkspaceUsers: (listener: () => void) => Unsubscribe
// Everyone an outer host published. Undefined without a host, in which
// case live users stand in.
getDirectory: () => readonly UserProfile[] | undefined
subscribeDirectory: (listener: () => void) => Unsubscribe
getLocation: () => ConnectionLocation | null
setLocation: (location: ConnectionLocation | null) => void
setPresence: (registration: PresenceRegistration) => void
Expand All @@ -53,8 +54,8 @@ export const NO_ENGINE: CollabEngineApi = {
subscribe: () => noop,
getCurrentUser,
subscribeCurrentUser: subscribeCurrentUserStore,
getWorkspaceDirectory: () => undefined,
subscribeWorkspaceUsers: () => noop,
getDirectory,
subscribeDirectory: subscribeDirectoryStore,
getLocation: () => null,
setLocation: noop,
setPresence: noop,
Expand Down Expand Up @@ -91,10 +92,8 @@ export class CollabClient implements CollabEngineApi {
this.enabled ? this.store.subscribe(listener) : noop
getCurrentUser = getCurrentUser
subscribeCurrentUser = subscribeCurrentUserStore
getWorkspaceDirectory = (): WorkspaceDirectory | undefined =>
getWorkspaceDirectory(this.workspaceId)
subscribeWorkspaceUsers = (listener: () => void): Unsubscribe =>
subscribeWorkspaceUsersStore(this.workspaceId, listener)
getDirectory = getDirectory
subscribeDirectory = subscribeDirectoryStore
getLocation = (): ConnectionLocation | null => (this.enabled ? this.store.getLocation() : null)
setLocation = (location: ConnectionLocation | null): void => {
this.store.setLocation(location)
Expand Down
46 changes: 31 additions & 15 deletions client/features/collab/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,15 @@ import { expect, test } from 'bun:test'
import { renderToStaticMarkup } from 'react-dom/server'
import type { CollabEngineApi } from './client'
import { createFakeEngine } from './testing/fake-engine'
import { setHostState } from './host-state'
import { publishUsers } from './host-state'
import {
useMe,
usePeers,
usePresence,
usePublishPresence,
useUser,
useUsers,
useWorkspaceUsers,
useWorkspaceDirectory
useWorkspaceUsers
} from './hooks'
import { AppletPresenceProvider, CollabContext, pageFromPath } from './provider'
import type { WorkspaceUser } from './hooks'
Expand Down Expand Up @@ -95,11 +94,33 @@ function Disabled() {
return <Observer />
}
test('hooks are safe without a backend or applet and resolve missing users to undefined', () => {
setHostState({ currentUser: undefined, workspaces: {} })
publishUsers({ users: [] })
const html = renderToStaticMarkup(<Disabled />)
expect(html).toContain('[]')
})

test('a published directory resolves users without a workspace engine', () => {
publishUsers({ me: 'alice', users: [alice, bob] })
try {
function Global() {
return encodeURIComponent(
JSON.stringify({ me: useMe(), user: useUser('bob'), users: useWorkspaceUsers() })
)
}
// No room is joined here, so everyone resolves and reads as offline.
expect(JSON.parse(decodeURIComponent(renderToStaticMarkup(<Global />)))).toEqual({
me: { ...alice, status: 'offline' },
user: { ...bob, status: 'offline' },
users: [
{ ...alice, status: 'offline' },
{ ...bob, status: 'offline' }
]
})
} finally {
publishUsers({ users: [] })
}
})

test('user hooks preserve missing names for self, peers, and offline members', () => {
const self = { id: 'self', color: 'emerald', email: 'self@example.test' } as const
const peer = { id: 'bob', color: 'blue', email: 'bob@example.test' } as const
Expand Down Expand Up @@ -215,7 +236,7 @@ test('workspace users include self and offline members with workspace-wide statu
expect(render().all).toEqual([])
})

test('absent, loading, and empty directories preserve distinct user resolution', () => {
test('absent and empty directories preserve distinct user resolution', () => {
const engine = createFakeEngine({
self: alice,
users: [alice, bob],
Expand All @@ -225,12 +246,7 @@ test('absent, loading, and empty directories preserve distinct user resolution',
function Directory() {
const me = useMe()
return encodeURIComponent(
JSON.stringify({
status: useWorkspaceDirectory()?.status,
users: useWorkspaceUsers(),
me,
hasMe: me !== undefined
})
JSON.stringify({ users: useWorkspaceUsers(), me, hasMe: me !== undefined })
)
}
const render = () =>
Expand All @@ -251,13 +267,13 @@ test('absent, loading, and empty directories preserve distinct user resolution',
me: { ...alice, status: 'active' },
hasMe: true
}
// Without a host directory, the viewer and live users stand in.
expect(render()).toEqual(fallback)
engine.setDirectoryStatus('loading')
expect(render()).toEqual({ status: 'loading', users: [], hasMe: false })
// An empty host directory is authoritative, even over the viewer.
engine.setUsers([])
expect(render()).toEqual({ status: 'ready', users: [], hasMe: false })
expect(render()).toEqual({ users: [], hasMe: false })
engine.setUsers([alice, bob])
expect(render()).toEqual({ status: 'ready', ...fallback })
expect(render()).toEqual(fallback)
engine.setUsers(undefined)
expect(render()).toEqual(fallback)
})
Expand Down
Loading
Loading