Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
87 commits
Select commit Hold shift + click to select a range
a17b4e5
Add collab protocol and transactional shared storage
molefrog Sep 15, 2026
72c8af0
Add workspace collab workers and explicit CLI setup
molefrog Sep 15, 2026
2afd3cd
Add collab client state and external identity bridge
molefrog Sep 15, 2026
8189305
Add collaborative applet primitives and dev identity setup
molefrog Sep 15, 2026
7c80a72
Integrate collab with workspace views and personal navigation
molefrog Sep 15, 2026
7a96f9b
Remember everyone who joins a workspace in a people directory
molefrog Sep 21, 2026
5e1136e
Add id-based people components on a swappable collab backend
molefrog Sep 21, 2026
1f8f115
Keep the collab capability type until its callers move off it
molefrog Sep 21, 2026
81eeb93
Move collab availability into startup config
molefrog Sep 21, 2026
6dbe314
Combine collab playground and optional dev identity setup
molefrog Sep 21, 2026
2d862a5
Document the experimental collab architecture and dev workflow
molefrog Sep 21, 2026
1893769
Remove the collab controls import cycle
molefrog Sep 21, 2026
ca22895
Limit collaboration to presence and add host user directories
molefrog Sep 26, 2026
e918b16
Add keyed list presence and expose workspace members
molefrog Sep 26, 2026
2fa339a
Enable collaboration only through CLI flags
molefrog Sep 26, 2026
7598e5c
Handle missing collab bridges and isolate presence lifecycle
molefrog Sep 26, 2026
6af84f2
Merge latest main into codex/collab
molefrog Sep 27, 2026
d7d4f1a
Consolidate collaboration engine and atomic host state
molefrog Sep 27, 2026
375a12a
Allow collaboration users without names
molefrog Sep 27, 2026
f039de4
Scope presence targets with explicit component and group IDs
molefrog Sep 27, 2026
efc3f2b
Include emails in collaboration hook examples
molefrog Sep 27, 2026
e1127d3
Keep collaboration guide with workspace references
molefrog Sep 27, 2026
750855e
Simplify collaborative applet authoring guide
molefrog Sep 27, 2026
6c65918
Keep applet messages in personal chats and verify review fixes
molefrog Sep 27, 2026
ef4c399
Document collaboration component props and defaults
molefrog Sep 27, 2026
2924a48
Show Activity scopes in a dedicated collaboration demo
molefrog Sep 27, 2026
8d8d2bb
Simplify experimental startup flags
tonyfresher Sep 28, 2026
e360193
Remove redundant workspace layout provider key
tonyfresher Sep 28, 2026
ca822d6
Simplify workspace collaboration boundary
tonyfresher Sep 28, 2026
1c52199
Inherit collab identity from Cloudflare Access (#148)
molefrog Sep 28, 2026
d1abf70
Clarify collaboration names and tab state
tonyfresher Sep 28, 2026
4269700
Clarify applet collaboration bridge types
tonyfresher Sep 29, 2026
86cd5d7
Remove obsolete collaboration error boundary
tonyfresher Sep 29, 2026
4af0003
Simplify avatar size guidance
tonyfresher Sep 29, 2026
f954591
Allow host user profiles without colors
tonyfresher Sep 29, 2026
57879bd
Name proxy verified user consistently
tonyfresher Sep 29, 2026
34df397
Remove obsolete collab layout cleanup
tonyfresher Sep 29, 2026
f5c45c9
Trim Batiok collab integration guide
tonyfresher Sep 29, 2026
3ff5ab5
Use named user colors for collaboration
tonyfresher Sep 29, 2026
4194c5b
Remove unused collaboration location title
tonyfresher Sep 29, 2026
d8eb435
Resolve collab reference in server chat context
tonyfresher Sep 29, 2026
1d5c80b
Shorten browser tab session helper names
tonyfresher Sep 30, 2026
975c72f
Start unshipped collab protocol at version 1
tonyfresher Sep 30, 2026
824381c
Unify applet URL resolution for pages and files
tonyfresher Sep 30, 2026
689e1a1
Omit blank user profile names
tonyfresher Sep 30, 2026
a2742c4
Trim applet and collaboration documentation
tonyfresher Sep 30, 2026
8c5ffbf
Return undefined for unresolved collaboration users
tonyfresher Sep 30, 2026
5e5b810
Load collaboration runtime from typed source
tonyfresher Sep 30, 2026
4f570f6
Clarify applet runtime structure and worker messages
tonyfresher Sep 30, 2026
2caba5a
Reuse public applet declarations as type contracts
tonyfresher Sep 30, 2026
534a227
Use undefined for absent collaboration users
tonyfresher Sep 30, 2026
4f22e9b
Name cursor areas with id
tonyfresher Sep 30, 2026
e86bb2e
Use explicit status for collaboration connections
tonyfresher Sep 30, 2026
bcf56dc
Name collaboration applet scopes with appletId
tonyfresher Sep 30, 2026
dddd0b2
Simplify collaboration type names and share JsonValue
tonyfresher Sep 30, 2026
93706e2
Clarify applet presence naming and reuse applet IDs
tonyfresher Sep 30, 2026
4a1f56c
Use path-shaped applet IDs and rename AppletContainer
tonyfresher Sep 30, 2026
debe0b9
Document the collaboration host bridge generically
tonyfresher Sep 30, 2026
eb6de8b
Trim collaboration docs and clarify applet guidance
tonyfresher Sep 30, 2026
949826a
Round generated collaboration avatars
tonyfresher Sep 30, 2026
a2c5449
Simplify collaboration structure and recover rejected joins
tonyfresher Sep 30, 2026
579bbf5
Rename collaboration toolbar and fix scoped color token checks
tonyfresher Sep 30, 2026
b19ebc8
Simplify collaboration directory state and public hooks
tonyfresher Sep 30, 2026
0047671
Initialize local collaboration users during startup
tonyfresher Oct 1, 2026
718e595
Simplify collaboration user rendering and component types
tonyfresher Oct 1, 2026
39cc5e4
Check collaboration implementations against the public contract
tonyfresher Oct 1, 2026
5747a89
Compose user avatars and clarify collaboration docs and tests
tonyfresher Oct 1, 2026
0af75d9
Clarify collaboration component names and playground examples
tonyfresher Oct 1, 2026
d63f3e6
Group dev UI component pages and simplify collab page naming
tonyfresher Oct 1, 2026
a63ea69
Refine user component sizes and rename detail to description
tonyfresher Oct 1, 2026
9a22582
Simplify avatar badges and mask their background gap
tonyfresher Oct 1, 2026
647157d
Reuse shared avatar groups and simplify count sizing
tonyfresher Oct 1, 2026
eed596a
Use transparent masks for avatar group overlaps
tonyfresher Oct 1, 2026
e7a17d5
Simplify collaboration user components and remove Activity
tonyfresher Oct 1, 2026
9dfa15f
Simplify collaboration badges and refine presence visuals
tonyfresher Oct 1, 2026
71dbfe5
Make collaboration playground interactive with two participants
tonyfresher Oct 1, 2026
0f24781
Unify collaboration presence components and simplify focus tracking
tonyfresher Oct 1, 2026
a3a1cc5
Simplify presence frame geometry and add badge alignment
tonyfresher Oct 1, 2026
ca04297
Fix collaboration cursor entry and scroll positioning
tonyfresher Oct 1, 2026
1ce99fc
Add vertical alignment to presence avatars
tonyfresher Oct 1, 2026
cf6e223
Clarify when to use collaboration presence components
tonyfresher Oct 1, 2026
22ecb95
Simplify workspace collaboration users menu
tonyfresher Oct 2, 2026
0b3f101
Polish workspace users menu
tonyfresher Oct 2, 2026
27e1665
Use fruit names for anonymous collaboration users
tonyfresher Oct 2, 2026
33560cb
Refine collaboration avatars and users menu ordering
tonyfresher Oct 2, 2026
5abed84
Restore avatar group overlap direction
tonyfresher Oct 2, 2026
f54d683
Add optional muted You label to collaboration users
tonyfresher Oct 2, 2026
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
3 changes: 2 additions & 1 deletion .agents/rules/typescript.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
---
description: TypeScript conventions for props, types, and type safety.
description: TypeScript conventions for component filenames, props, types, and type safety.
globs: '*.ts, *.tsx'
alwaysApply: false
---

- Always define a named `type` for component props above the component. Never use inline object types for props. Name it after the component: `type ChatPanelProps = { ... }`.
- Use kebab-case for new React component filenames and their tests, such as `chat-panel.tsx` and `chat-panel.test.tsx`. Keep component and props identifiers in PascalCase: `ChatPanel`, `ChatPanelProps`.
- Never use `any`. Use `unknown` when the type is truly unknown, then narrow it.
- Never cast with `as any`. Prefer proper typing or `as unknown as T` with a comment explaining why.
- Reuse existing types from `lib/types.ts` instead of redefining equivalent shapes.
Expand Down
5 changes: 4 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions client/api/app-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { afterEach, expect, test } from 'bun:test'
import type { ClientAppConfig } from '@/lib/types'

import { loadAppConfig } from './app-config'

const originalFetch = Object.getOwnPropertyDescriptor(globalThis, 'fetch')

afterEach(() => {
if (originalFetch) Object.defineProperty(globalThis, 'fetch', originalFetch)
else Reflect.deleteProperty(globalThis, 'fetch')
})

test('startup receives confirmed config while a failed refresh cannot enable a local profile from cached config', async () => {
const config: ClientAppConfig = {
cloudDemo: false,
experimental: { collab: true },
demoInstallUrl: 'https://moi.computer'
}
Object.defineProperty(globalThis, 'fetch', {
configurable: true,
value: async () => Response.json(config)
})
expect(await loadAppConfig()).toEqual(config)
Object.defineProperty(globalThis, 'fetch', {
configurable: true,
value: async () => Response.json({}, { status: 503 })
})
expect(await loadAppConfig()).toBeUndefined()
})
11 changes: 7 additions & 4 deletions client/api/app-config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useSyncExternalStore } from 'react'

import type { ClientAppConfig } from '@/lib/types'
import { EXPERIMENTAL_DEFAULTS } from '@/lib/experimental'

import { onWorkspaceEventsReconnect } from '@/client/runtime/useWorkspaceEvents'

Expand All @@ -14,7 +15,7 @@ import { requestJson } from './http'

const DEFAULTS: ClientAppConfig = {
cloudDemo: false,
experiments: [],
experimental: { ...EXPERIMENTAL_DEFAULTS },
demoInstallUrl: 'https://moi.computer'
}

Expand All @@ -30,20 +31,22 @@ function subscribe(listener: () => void): () => void {

// Failures keep the last known value (defaults on the very first fetch):
// non-demo is the safe presentation, and the server still enforces its gates.
async function fetchAppConfig(): Promise<void> {
async function fetchAppConfig(): Promise<ClientAppConfig | undefined> {
try {
current = await requestJson<ClientAppConfig>('/api/config')
for (const listener of listeners) listener()
return current
} catch {}
}

// Called once from init() before the app mounts; never rejects, so a config
// hiccup cannot block the app from starting.
export async function loadAppConfig(): Promise<void> {
await fetchAppConfig()
export async function loadAppConfig(): Promise<ClientAppConfig | undefined> {
const config = await fetchAppConfig()
onWorkspaceEventsReconnect(() => {
void fetchAppConfig()
})
return config
}

export function useAppConfig(): ClientAppConfig {
Expand Down
46 changes: 27 additions & 19 deletions client/app/routes/WorkspaceRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { CollabProvider, useCollabEnabled } from '@/client/features/collab'
import { useQueryClient } from '@tanstack/react-query'

import { workspaceKeys } from '@/client/api/workspace-keys'
import { useAppConfig } from '@/client/api/app-config'
import { LedLogo } from '@/client/components/shared/LedLogo'
import { SidebarLayout } from '@/client/app/shell/SidebarLayout'
import { useSelectedSession } from '@/client/features/chat/sessions/useSelectedSession'
Expand All @@ -19,19 +21,24 @@ type WorkspaceRouteProps = {
id: string
}

// The URL's tab segment is not threaded down — useWorkspaceNavigation reads it
// off the matched route with wouter's `useParams`.
// Navigation reads the raw browser address so encoded paths and query params
// are decoded exactly once; the route only owns the workspace ID.
export function WorkspaceRoute({ id }: WorkspaceRouteProps) {
const { experimental } = useAppConfig()

return (
<Workspace id={id}>
<WorkspaceLayoutProvider id={id}>
<WorkspaceLoader id={id} />
<CollabProvider workspaceId={id} enabled={experimental.collab}>
<WorkspaceContent id={id} />
</CollabProvider>
</WorkspaceLayoutProvider>
</Workspace>
)
}

function WorkspaceLoader({ id }: WorkspaceRouteProps) {
function WorkspaceContent({ id }: WorkspaceRouteProps) {
const collabEnabled = useCollabEnabled()
const queryClient = useQueryClient()
const [selectedSessionId] = useSelectedSession()
const { isLoading: layoutLoading } = useWorkspaceLayoutCtx()
Expand Down Expand Up @@ -62,20 +69,21 @@ function WorkspaceLoader({ id }: WorkspaceRouteProps) {
builders.isLoading

return (
<>
<SidebarLayout>
{fresh ? (
<div className="flex h-full items-center justify-center">
<LedLogo sprite="moi" effect="chaos" />
</div>
) : (
<WorkspaceScreen
widgets={widgets.data}
views={views.data ?? []}
builders={builders.data ?? []}
/>
)}
</SidebarLayout>
</>
<SidebarLayout>
{fresh ? (
<div className="flex h-full items-center justify-center">
<LedLogo sprite="moi" effect="chaos" />
</div>
) : (
// Browser tab state is read when the screen mounts. Reset the screen
// when it switches between shared workspace tabs and browser tab state.
<WorkspaceScreen
key={collabEnabled ? 'browser-tab' : 'shared'}
widgets={widgets.data}
views={views.data ?? []}
builders={builders.data ?? []}
/>
)}
</SidebarLayout>
)
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { type ReactNode } from 'react'

import { AppletPresenceProvider } from '@/client/features/collab'
import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext'

import { type AppletSegment, appletScope, appletStyleKey } from './applet-cache'
import { type AppletSegment, appletId, appletStyleKey } from './applet-cache'
import { useAppletStyle } from './applet-styles'

type AppletMountProps = {
type AppletContainerProps = {
segment: AppletSegment
name: string
version: number
Expand All @@ -19,13 +20,14 @@ type AppletMountProps = {
// page. This is the widget path. Views don't use it: ViewManager parks a view's
// DOM offscreen instead of unmounting it, so it holds the styles itself, above
// the boundary that hides the view.
export function AppletMount({ segment, name, version, children }: AppletMountProps) {
export function AppletContainer({ segment, name, version, children }: AppletContainerProps) {
const workspaceId = useWorkspaceId()
const id = appletId(segment, name)
useAppletStyle(appletStyleKey(segment, workspaceId, name), version)

return (
<div data-applet={appletScope(segment, name)} className="size-full">
{children}
<div data-applet={id} className="size-full">
<AppletPresenceProvider appletId={id}>{children}</AppletPresenceProvider>
</div>
)
}
6 changes: 3 additions & 3 deletions client/features/applets/WidgetShell.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AnimatePresence, motion } from 'motion/react'

import { AppletMount } from '@/client/features/applets/AppletMount'
import { AppletContainer } from '@/client/features/applets/AppletContainer'
import { useWidget } from '@/client/features/applets/useApplet'
import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext'

Expand Down Expand Up @@ -31,9 +31,9 @@ export function WidgetShell({ name }: WidgetShellProps) {
workspaceId={workspaceId}
resetKey={widget.version}
>
<AppletMount segment="widgets" name={name} version={widget.version}>
<AppletContainer segment="widgets" name={name} version={widget.version}>
<widget.Component />
</AppletMount>
</AppletContainer>
</WidgetErrorBoundary>
</motion.div>
)}
Expand Down
8 changes: 3 additions & 5 deletions client/features/applets/applet-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,9 @@ export function appletStyleKey(segment: AppletSegment, workspaceId: string, name
return `/api/workspaces/${workspaceId}/${segment}/${name}`
}

// The `data-applet` value the bundle's scoped CSS selectors key off (see
// server/applets/applet-css.ts). It goes on the container wrapping a mounted
// applet — AppletMount for widgets, the view slot for views.
export function appletScope(segment: AppletSegment, name: string): string {
return `${segment === 'widgets' ? 'widget' : 'view'}:${name}`
// Stable applet identity used by scoped CSS, presence, and thumbnail capture.
export function appletId(segment: AppletSegment, name: string): string {
return `${segment}/${name}`
}

export function getCachedApplet(key: string): Promise<unknown> | undefined {
Expand Down
44 changes: 36 additions & 8 deletions client/features/applets/applet-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as appletLog from './applet-log'
import { describe, expect, test, spyOn } from 'bun:test'
import { toast } from '@/client/components/ui/toast'
import type { AppletBridge } from '@/lib/types'

import {
appletKey,
Expand All @@ -9,7 +10,6 @@ import {
setCachedApplet
} from './applet-cache'
import {
type AppletBridge,
type AppletChatMessage,
type AppletIdentity,
appletRuntime,
Expand Down Expand Up @@ -45,27 +45,39 @@ describe('bridge validation', () => {
const log = spyOn(appletLog, 'reportAppletError').mockImplementation(() => {})
bridge.navigate('moi:/views/orders?order=o-1')
bridge.navigate('moi:/overview')
bridge.navigate('moi:/files/clips/video.mp4#t=5')
bridge.navigate(['invalid'])
bridge.navigate('javascript:alert(1)')
expect(calls).toEqual(['moi:/views/orders?order=o-1', 'moi:/overview'])
expect(log).toHaveBeenCalledTimes(2)
bridge.navigate('moi:/files/clips/%2Fvideo.mp4')
expect(calls).toEqual([
'moi:/views/orders?order=o-1',
'moi:/overview',
'moi:/files/clips/video.mp4#t=5'
])
expect(log).toHaveBeenCalledTimes(3)
log.mockRestore()
})

test('resolves native anchor hrefs in the source workspace and disposes safely', () => {
const { bridge, dispose } = appletRuntime('ws-1').connect(VIEW)
expect(bridge.resolveHref('moi:/views/orders?order=o-1')).toBe(
expect(bridge.resolveUrl('moi:/views/orders?order=o-1')).toBe(
'/workspace/ws-1/views/orders?order=o-1'
)
expect(bridge.resolveHref('https://example.com/')).toBe('https://example.com/')
expect(() => bridge.resolveHref('javascript:alert(1)')).toThrow()
expect(bridge.resolveUrl('https://example.com/')).toBe('https://example.com/')
expect(bridge.resolveUrl('moi:/files/clips/a%20b.mp4')).toBe(
'/api/workspaces/ws-1/files/clips/a%20b.mp4'
)
expect(() => bridge.resolveUrl('javascript:alert(1)')).toThrow()
dispose()
expect(bridge.resolveHref('moi:/overview')).toBe('')
expect(bridge.resolveUrl('moi:/overview')).toBe('')
})

test('resolves applet links with the host router base', () => {
const { bridge } = appletRuntime('prefixed').connect(VIEW, '/prefix')
expect(bridge.resolveHref('moi:/views/orders')).toBe('/prefix/workspace/prefixed/views/orders')
expect(bridge.resolveUrl('moi:/views/orders')).toBe('/prefix/workspace/prefixed/views/orders')
expect(bridge.resolveUrl('moi:/files/photo.png')).toBe(
'/api/workspaces/prefixed/files/photo.png'
)
})

test('drops calls with malformed addresses instead of emitting', () => {
Expand Down Expand Up @@ -334,6 +346,22 @@ describe('sendChatMessage rate limiting', () => {
})

describe('disposal', () => {
test('collaboration shares the host API and is revoked with its connection', () => {
const runtime = appletRuntime(`ws-${crypto.randomUUID()}`)
const view = runtime.connect(VIEW)
const widget = runtime.connect(WIDGET)
const api = view.bridge.collab

expect(typeof api?.usePeers).toBe('function')
expect(typeof api?.useWorkspaceUsers).toBe('function')
expect(widget.bridge.collab).toBe(api)

view.dispose()
expect(view.bridge.collab).toBeUndefined()
expect(widget.bridge.collab).toBe(api)
widget.dispose()
})

test('a disposed connection is inert even while subscribers are live', () => {
const ws = `ws-${crypto.randomUUID()}`
const { calls } = subscribeNavigation(ws)
Expand Down
Loading
Loading