Skip to content

feat: team-edition collaboration surfaces + workspace-context seam - #5302

Closed
lefarcen wants to merge 63 commits into
feat/workspace-teamfrom
feat/collab-c-lane
Closed

feat: team-edition collaboration surfaces + workspace-context seam#5302
lefarcen wants to merge 63 commits into
feat/workspace-teamfrom
feat/collab-c-lane

Conversation

@lefarcen

@lefarcen lefarcen commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Why

Team-edition collaboration: members of a shared workspace need to see each other's presence, comment on shared projects, share design systems / plugins / skills across the team, and move between a personal and a team workspace. This PR lands the client-side collaboration surfaces and the workspace-context integration they all sit on, and leaves labeled integration slots for the team-management views (members, billing, dashboard, project lists) that other feature areas own — so those can be plugged into a concrete interface instead of being reinvented per surface.

Opening it now to (a) land the collaboration + resource-sharing engine the team edition builds on, and (b) give the workspace / billing / project-visibility feature areas a concrete seam to mount their views into.

What users will see

  • A team navigation rail when signed into a team workspace — workspace switcher, Community and recent projects in the sidebar — and a lean local rail otherwise. A bring-your-own-key / non-cloud-agent workspace keeps every team feature (the rail is gated on the workspace being a team, not on the agent).
  • Presence avatars on a shared project, and a read-only view for a member who is not the project's owner. A locked / past-due workspace freezes edits for everyone (owner included) and shows a recovery banner.
  • Comments on shared projects.
  • Team collections in Design systems, Plugins, and Skills with a "share to team" action; members pull shared resources into their own client.
  • A Workspace section in Settings, shown by role.
  • An invite-acceptance page (preview → accept → hand off to the desktop client), with download-fallback and retry-open paths.
  • od collab … CLI subcommands for sharing resources to the team (dual-track with the UI).

Team management lives in the cloud web console. Members, the team dashboard, subscription/billing, and workspace settings are managed there — the local client keeps a single Workspace settings entry that opens that console in the browser (its URL is carried on the workspace context as workspaceSettingsUrl). The client does not embed those views.

The remaining in-client seam is the project surfaces: the drafts / all-projects entries render a shared placeholder for the project-listing views the visibility feature area owns (routes /drafts, /all-projects), reading the shared WorkspaceCollabContext (role + permission bits) this PR ships. The invite-acceptance component is likewise standalone and prop-driven, awaiting the shell to mount it at the invite route.

Surface area

  • UI — team navigation rail, in-project presence + read-only, team collections, Workspace settings section, invite-acceptance page
  • CLI / env varod collab … resource sharing; OD_RESOURCE_TRANSPORT=vela-cli (opt-in CLI transport), OD_WORKSPACE_CONTEXT_SOURCE=vela (real context provider), OD_VELA_WEB_URL (cloud console base for the Workspace settings link)
  • API / contractpackages/contracts: workspace context, collab presence/sync, project-sync state, team resources, workspace invites
  • i18n keys — nav shell, Settings workspace section, invite flow
  • New top-level dependency
  • Default behavior change — the team surfaces are dormant unless there is a team workspace context, so existing single-player users see no change

Screenshots

Entry points reviewers can drive via pnpm tools-dev: the team nav rail (sidebar), presence avatars + read-only banner on a shared project, the team collections in Design systems / Plugins / Skills, the Workspace section in Settings, and the invite-acceptance page. (Screenshots to follow.)

Bug fix verification

  • n/a — feature work.

Validation

  • pnpm guard, pnpm typecheck
  • pnpm --filter @open-design/web typecheck + web collab / settings / invite suites green
  • pnpm --filter @open-design/daemon typecheck + collab suites green
  • Resource sharing verified end to end against a live cloud stack: an owner publishes a directory and a member pulls it byte-identical (executable bits and empty directories preserved), authenticated purely by the signed-in session — forged principal headers were proven unusable, and a non-owner's write was correctly refused.

lefarcen and others added 30 commits July 7, 2026 00:36
Storage layer for the read-only comment drift ladder (C lane, spec D2),
building on the contract from #5222.

- preview_comments gains anchor_state / anchored_version / author_member_id /
  last_good_position_json. The migration runs after the slide-key table rebuild
  so a legacy rebuild cannot drop the new columns.
- upsert persists creation metadata (anchoredVersion, authorMemberId); resolved
  state stays null for the drift ladder to fill in.
- updatePreviewCommentAnchor is the engine write-back: COALESCE keeps the
  last-good position/version on a lost resolve, and it never bumps updated_at.
- contract: anchoredVersion on PreviewCommentTarget, authorMemberId on the upsert
  request (server-set), and PreviewCommentAnchorUpdateRequest.

Tests cover fresh-db columns, creation round-trip, engine write-back with
lost-keeps-last-good, and legacy-migration backfill.
The read-only team-collab comment layer (C lane, spec D2) must survive the
author restructuring the HTML. resolveCommentAnchor replaces the silent
exact-match with an explicit ladder that never mis-points:
  - exact anchor hit -> anchored (older content version -> reanchored)
  - content-based fuzzy match (selector / htmlHint / text; position only breaks
    ties) -> stale
  - nothing found -> lost (ghost pin at lastGoodPosition)

liveSnapshotForComment keeps its exact single-user behavior; the ladder is a new
opt-in function for the collab UI. Re-exports PreviewCommentAnchorState to web.
Tests: 5 ladder cases + web typecheck green.
PATCH /api/projects/:id/conversations/:cid/comments/:commentId/anchor calls
updatePreviewCommentAnchor so the client-side drift ladder can persist how each
comment resolved (anchor_state + last-good position). Mirrors the existing
status PATCH; deliberately skips updateProject() because anchor resolution is a
derived read-back, not a content edit.
CommentPreviewOverlays gains an opt-in driftLadder prop: when set it resolves
each comment through resolveCommentAnchor (keeping stale/lost with an explicit
badge + ghost styling) instead of the exact-match silent drop. Single-user
callers leave it off, so their behavior is unchanged. The marker exposes
data-anchor-state and a comment-saved-marker--{state} class; core.css styles
reanchored (amber), stale (dashed), and lost (grey ghost).
C's trigger + orchestration for team sync: coalesce rapid author edits into a
single publish (half-written intermediate states never reach members), flush at
run boundaries, and re-publish if a change lands mid-publish (last-write-wins, no
lost change). Publishing content + advancing the published ref is E's resource
hub behind a ResourcePublishAdapter interface — this owns only the *when*.
onPublished fires only after the adapter resolves (durable) so members are never
told to pull a not-yet-durable version (spec I.0). 6 unit tests.
Poll-friendly heartbeat set of who is viewing a shared project, decoupled from
the resource/sync layer (spec D2/D6). Timer-free: present() sweeps expired
members on read (matching the polling model), and onChange fires on explicit
join/leave. Not a realtime-cursor engine (live cursors were cut). 6 unit tests.
createCollabRuntime bundles the presence tracker + publish scheduler (with a
stub resource-hub adapter until E's client ships) behind one factory the server
wires once. registerCollabPresenceRoutes exposes GET presence + POST
heartbeat/leave, so presence is now a live, poll-friendly capability. 4 route
tests over a real express server.
Exposes C's coalescing publish scheduler as a live, client-driven capability:
POST /collab/changed (debounced), POST /collab/publish (flush at run boundary),
GET /collab/status (published head version). The client is authoritative about
being in a shared context, so it drives the trigger — no dependency on D's
visibility fact. Publishing still routes through the stub resource adapter until
E's client lands. The runtime tracks the published head per project. 3 route tests.
Client glue that consumes the daemon collab APIs (C lane): heartbeats
presence, polls the published head version so a member knows when to
pull, and reports author-side edits / requests a run-boundary publish.
Polling-based per spec §D6 (live cursors cut). Injectable fetch + timers
for tests.
Closes the UI/CLI dual-track for the C-lane collab capability (AGENTS.md
capability-exposure rule). `od collab` mirrors the presence + sync routes
the web CollabClient drives: status / presence / heartbeat / leave /
changed / publish, all with --json. Exercised end-to-end via tsx against a
stub daemon socket (real SUBCOMMAND_MAP dispatch + request shaping).
Consumes the full presence vertical (tracker -> route -> CollabClient) as
React surface (C lane, spec §D6 在场). useCollab starts/stops the client on
mount and re-renders on the present set / published version; PresenceBar is
the poll-driven avatar stack (no live cursors). CSS Module per web ownership
rules. renderHook + RTL tests with mock fetch / fake timers.
Closes the comment drift-ladder loop end-to-end (C lane, spec §D2). When a
comment first drifts to `lost`, planLostAnchorWriteBacks captures its
last-good position and persistCommentAnchor PATCHes the daemon anchor route,
so the ghost pin survives reload. anchored/reanchored/stale stay derived
per-viewer (never written back — they'd conflict across members). FileViewer
fires this once per comment (ref-guarded against pod-draw re-renders; server
COALESCEs too). Inert until collab mode sets driftLadder + onLostAnchors.
The presence identity + sync-status shapes were declared twice (daemon
PresenceMember, web CollabPresenceMember). Per the repo boundary rule (shared
web/daemon DTOs live in packages/contracts), add api/collab.ts as the single
source of truth and have both sides consume it, so the daemon route, web
CollabClient, and od collab CLI can never drift apart.
A drivable demo of the C-lane presence + sync loop against the real daemon
routes: open /collab-demo/<projectId> in two tabs and each appears in the
other's presence overlay; an author Publish advances the head both tabs poll,
surfacing a Pull prompt on the member tab. The member identity and shared
entry are clearly-labeled demo stubs (real B identity / D visibility pending)
— everything the daemon does (presence set, coalesced publish, version head)
is real. Router round-trip + component tests included.
Three fixes found by launching it: (1) CSS Module pure-mode rejects bare
element selectors — scope button/select/input under .view; (2) apps/web's
webpack does not resolve NodeNext-style .js import specifiers — drop the
extensions on the collab sibling imports (matches the rest of apps/web);
(3) exempt /collab-demo from the first-run onboarding redirect so the
deep-link stays reachable without completing onboarding. Verified live: two
tabs see each other's presence and Publish advances the polled head (Pull vN).
Adds the comment drift ladder to the collab demo surface so all three C-lane
verticals are drivable. Four scenario buttons re-resolve a fixed comment
through the real resolveCommentAnchor + planLostAnchorWriteBacks: unchanged →
anchored, re-published → reanchored, id churned but content intact → stale
(content recovery), section removed → lost (ghost pin) with the durable
PATCH write-back shown. Engine is production code; only the DOM snapshots are
synthetic (labelled). Verified live in-browser across all four states.
Daemon-side Tier-1 skeleton for team resource sharing over the Vela
resource hub:
- integrations/resource-hub.ts: hub HTTP client (index ops real; auth and
  workspace principal are seams; blob byte transfer pending transport).
- routes/resources/index.ts: registerResourceSharingRoutes (list/_status
  real; share/pull 501 pending wrap logic).
- resource-cli.ts: `od resource` (list real; share/pull stub).
Hot-file footprint kept to 4 lines (server.ts + cli.ts); app-config.ts
untouched. New files typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A hard tab close skips React unmount, so the fetch-based leave never sent and
the member lingered until the daemon's 30s presence TTL. Add CollabClient.
leaveBeacon() (navigator.sendBeacon, keepalive-fetch fallback) fired from a
useCollab pagehide listener, so a closing tab hands off a leave that survives
the unload. Also persist the demo member id in sessionStorage so a reload
reuses one identity instead of piling up a fresh 'Demo member' each time.
Verified live: navigating away drops the present set to [] at once.
resource-hub.ts client: implement pushBlob/pullBlob + prepareUpload/
commitUpload. Bytes flow daemon<->object-store direct via hub-issued
short-TTL presigned URLs (transport decision 2026-07-07); the hub is
never in the byte path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The C-lane plug point that replaces the demo's stubbed identity once B ships.
resolveCollabSession takes a faithful subset of B's CurrentWorkspaceContext
(workspaceType / workspaceMemberId / role / memberStatus / lifecycleState —
mirrored field-for-field from vela packages/shared/src/workspace-context.ts)
and decides whether presence + sync run: team workspace + active member +
live lifecycle → on with identity from workspaceMemberId; personal / removed /
frozen / deleting / deleted → off with a diagnostic reason. Encodes D's
team-only visibility gate. Decision logic is real; only the context source
(B's endpoint) remains to wire.
The daemon's single plug point for B's identity/visibility. GET /api/workspace/
context returns the caller's WorkspaceCollabContext (a faithful subset of B's
CurrentWorkspaceContext, now a shared contract DTO); the web client feeds it to
resolveCollabSession to decide whether collab runs and who the present member
is. WorkspaceContextProvider is swappable — a dev provider (settable via PUT,
seedable via OD_DEV_WORKSPACE_CONTEXT) stands in until B is reachable; a real
B-backed provider derives the context per-request from the token and omits the
PUT setter. Validated context parsing + route round-trip.
resource-drive.ts: kind-agnostic tree<->manifest+blobs primitives —
packTree (content-address a directory), pushTree (upload missing blobs +
publish a version), materializeTree / materializeRef (safe §2.7 landing).
resource-hub.ts client gains listVersions + getManifest. `od resource`
CLI gains neutral put/get (directory round-trip). Feature-specific
sharing stays a consumer layer built on top, not here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the D->C orchestration seam the D lane emits to (from the project-sync
contract): POST /api/projects/:id/collab/sync-intent accepts a ProjectSyncIntent;
project_team_share_requested marks the project pending_upload and flushes a
publish (driving E's resource mechanism behind the scheduler), then the state
machine advances pending_upload -> synced (onPublished) / sync_failed (onError).
GET /collab/status and CollabClient now carry syncState so members see it. Dual-
track: od collab share + syncState in od collab status. Demo gets a Share-to-team
button + sync-state badge. 12 new/updated daemon+web tests green.
…ter)

Adds the member-side pull to the C sync surface: ResourcePublishAdapter grows
an optional syncLatest (E v1.0 §2.4 getRef('published') — read the head without
advancing it), the stub reports the in-memory head, and the runtime exposes
pullLatest. POST /api/projects/:id/collab/pull + od collab pull return the head
version a member pulled. C owns *when* to pull (sync timing); E's client
fetches + extracts the bytes behind the adapter. 2 new tests green.
The foundation invariant (地基, C/麻薯-owned) every lane mounts at each
'copy a team resource into a personal editable copy' route: assertTeamResource
CopyAllowed / evaluateTeamResourceCopy over TeamResourceState (active|frozen|
deleted). A frozen or deleted team resource is blocked (WORKSPACE_RESOURCE_
FROZEN / _DELETED) — the AC-9 escape hole that would let a downgraded team keep
using frozen content. Pure TS so daemon/web/CLI all import it; throws a coded
TeamResourceCopyForbiddenError the daemon maps to 403. Route-mounting follows
once D1's team-resource-state model lands (mounting a stubbed-pass guard now
would be false enforcement). 7 tests green.
The new role-gating layer for the Settings shell (E-frontend). Pure derivation
over the member role (from B's foundation context): workspaceSettingsSections
ForRole / canSeeWorkspaceSettingsSection decide which *workspace* sections the
shell shows — members (owner/admin), billing (owner-only), team-space (owner/
admin); nothing for a member or outside a team context. E owns only this shell
visibility; the section internals belong to B (members) / A (billing) / D
(team-space), and there is no team-level BYOK this cycle. Wiring into the
(unconditional, 8k-line) SettingsDialog is the follow-up. 4 tests green.
…nt (D1)

Starts D1's team-resource state model and makes the D3 copy red-line REAL (not a
stubbed-pass). TeamResourceStateProvider reports whether a design-system/plugin/
skill is team-shared + its state (the E-resource-hub seam, 沅锡); GET/PUT
/api/workspace/resources/:kind/:id/state expose it, and POST .../copy-check runs
assertTeamResourceCopyAllowed against the resolved state — a frozen team resource
is rejected with 403 WORKSPACE_RESOURCE_FROZEN (proven by a test that seeds a
frozen resource). Unregistered resources resolve to personal, so the guard is
correctly dormant until E's hub reports real team resources. Wiring the same
provider+guard into the existing copy-out routes (plugin duplicate, DS copy,
skill edit-shadow) is the follow-up. 4 tests green.
Moves D3 from a check endpoint to a real copy-out route. enforceTeamResource
CopyAllowed (resolve state + assert) is called in POST /api/plugins/:id/
duplicate-project before any project is created; a frozen team plugin is
rejected 403 WORKSPACE_RESOURCE_FROZEN. Guard is optional (teamResources dep) +
dormant until the resource-hub reports a frozen team plugin, so existing
behavior is unchanged. Same helper wires into the remaining escape routes (DS
copy, skill edit-shadow) next. Helper tested (frozen → throws).
Second real escape route (after plugin duplicate): PUT /api/skills/:id clones a
built-in/shared skill into USER_SKILLS_DIR on first edit — a frozen team skill
must not be edit-shadowed into a personal editable copy. enforceTeamResource
CopyAllowed runs after the skill is resolved; a frozen team skill is rejected
403 WORKSPACE_RESOURCE_FROZEN. Optional (teamResources dep) + dormant until the
hub reports a frozen team skill, so existing behavior is unchanged. Guard helper
already tested (frozen → throws).
The resource hub now runs inside services/api, so the default hub URL is
the API port (18080). Also correct the stale module header: blob byte
transfer (presigned client-direct) is implemented, not pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lefarcen added 14 commits July 7, 2026 18:40
Read-only is now ownership-based, not role-based: the daemon records the member
who shares a project (its single writer) at sync-intent time and returns it in
the collab status; the client marks the project read-only only for viewers who
are not that owner. The sharer keeps full edit access to their own project while
everyone else sees it read-only — correct single-writer semantics, derived
in-lane without a separate project-ownership source. Falls back to the workspace
role until the owner id has been polled so a member never briefly gets edit
affordances on a shared project.

Verified end-to-end: the owner who shared collab-live-1 keeps the edit toolbar
and active composer; a different member sees the read-only notice with edit
surfaces withheld.
Widens the entry nav rail from a 56px icon column into a 236px labeled sidebar:
each nav button now shows its icon alongside a text label, matching the team
workspace shell in the demo. Pairs with the top-bar workspace switcher for the
team-workspace navigation. Team-scoped entries (members, dashboard, workspace
settings) are owned by other lanes and are left out until their views exist.
Verified across entry views (home, design systems) with no layout regression.
Generalizes the design-system team-share service into a kind-parametrized
resource-share service and mounts it for design systems, plugins, and skills:
each packs the resource's own directory and pushes it to the resource hub under
its own kind (design_system / plugin / skill), reusing the same verified publish
adapter and the one workspace-context principal. Plugin dirs resolve via the
installed-plugin record's fsPath; skill dirs via the skill registry (the adapter
now awaits an async dir resolver). Routes are mounted per kind at
/api/workspace/{design-systems,plugins,skills}/{team,:id/share}.

Verified end-to-end against the resource-hub fixture: a plugin-kind and a
skill-kind resource each publish and pull back byte-identical.
Adds `od collab share-resource <design-systems|plugins|skills> <id>` and
`od collab team-resources <kind>` covering all three shareable resource kinds
against the /api/workspace/{kind}/{team,:id/share} routes; the design-system
forms are kept as aliases. Keeps team sharing on the CLI dual-track for every
kind so external agents can drive it headless.
Replaces the plugins team tab placeholder with a working team collection: it
lists the member's installed plugins, each with a share-to-team action that
pushes the plugin to the resource hub (plugin kind) so teammates can pull it,
and marks already-shared ones. Mirrors the design-system team collection and
reads the same /api/workspace/plugins endpoints; off-team it degrades to an
empty collection. Web typecheck green.
Extends the team tab into a team-resources view: the member's personal skills
join installed plugins, each with a share-to-team action that pushes the skill
to the resource hub (skill kind). Mirrors the plugin/design-system team
collections and reads /api/workspace/skills. Verified end-to-end in the real
product: sharing a personal skill from the UI marks it shared and lists it under
/api/workspace/skills/team.
…ollab-c-lane

# Conflicts:
#	packages/contracts/src/api/project-sync.ts
#	packages/contracts/src/index.ts
Expand WorkspaceCollabContext to a faithful mirror of B's CurrentWorkspaceContext
(providerMode, billingState, planId, seatSummary, permissions, billingRecovery),
matching the shape B shipped in vela's shared workspace-context. Add the same
permission/seat derivation helpers so the daemon's dev context stub and the real
B proxy both derive through one rule set and cannot drift. The provider mode
(platform_credits vs personal_byok) is orthogonal to team collab: a BYOK
workspace keeps full team features, gated on lifecycle/role, never on provider.

Team surfaces now consume permission bits (canWriteSyncedFiles, canShareProjects,
canManageSharedResources) instead of re-deriving from role/lifecycle.
…ssion bit

Read-only now has two independent gates: the existing shared-project ownership
gate, plus a workspace-level gate that consumes B's canWriteSyncedFiles. A locked
or frozen workspace (or a removed member) freezes everyone read-only, the project
owner included, instead of only non-owners of shared projects. Consuming the
permission bit keeps this in lockstep with B rather than re-deriving the rule.

Add a red spec for the owner-frozen-when-locked case, and update the dev context
fixtures to the enriched shape parseWorkspaceCollabContext now derives.
…oint

Add createVelaWorkspaceContextProvider (T2): the daemon reuses the same vela login
session AMR and the vela CLI use (readVelaControlApiContext reads the control key +
api url from ~/.amr/config.json) to call B's authoritative GET /api/v1/workspaces/
current, then maps the CurrentWorkspaceContext onto WorkspaceCollabContext. One
vela session drives AMR, resource sharing, and the workspace context — no second
identity.

The mapper trusts B's permissions/seatSummary (re-deriving only if malformed),
derives teamId from workspaceId (the team workspace IS the team scope the resource
hub keys by), and preserves providerMode so a BYOK workspace keeps full team
features. Any failure — no session, signed out, B unreachable — degrades to null
so collab stays single-player and never throws.

Provider selection is env-gated (OD_WORKSPACE_CONTEXT_SOURCE=vela) so demo and
tools-dev runs keep the dev stub they drive via PUT.
… context

Rebuild EntryNavRail as a two-state shell gated on the real WorkspaceCollabContext
(GET /api/workspace/context): a full team rail when signed into a team workspace,
a lean local rail otherwise. The gate is workspaceType==='team', never providerMode
— a BYOK / non-AMR workspace keeps every team feature.

- Team rail: workspace switcher + plan chip, search, Home, Community (now in the
  rail, not under Home), and a TEAM section — Drafts, All projects, Design systems,
  Plugins, Members, Board, Workspace settings. Visibility comes from B's permission
  bits (canManageMembers/canInviteMembers/canViewWorkspaceSettings/canManageBilling),
  never re-derived from role. A locked workspace shows a recovery banner.
- Local rail: search, Home, Community, Design systems, Plugins + a sign-in callout.
- Team entries route to TeamSlotPlaceholder — C owns the shell + entry points; the
  Members/Board/Settings views themselves are B/A/D's lanes and are NOT built here.
- Fix the invite affordance: inviting a colleague opens the members/invite slot,
  not the onboarding flow (that jump was wrong — it is not a first-run flow).
- Move the workspace switcher out of the topbar into the rail.

Community renders in-shell; deep-linkable routes added for the new views.
Add a ResourcePublishAdapter that drives the resource hub through the vela CLI
(vela resource push/head/pull) instead of the in-process SDK. It reuses the same
vela login session AMR uses — one identity — and keeps the content-addressing in
the CLI so any vela-embedding project shares the exact code path. syncLatest maps
to the new `vela resource head` (cheap version check, no download).

Transport is opt-in via OD_RESOURCE_TRANSPORT=vela-cli and coexists with the SDK
adapter (the default) so the收口 rolls out only where the CLI is present. The
child process is injected in tests, so the wiring is verified without a live CLI
or hub. Both transports gate on the same workspace context — a personal or
signed-out session never publishes.

Also fix a latent test-only typecheck error in the T2 provider spec.
Add a Workspace section to Settings that appears only for a team member who can
view workspace settings (workspaceType==='team' && permissions.canViewWorkspace
Settings). Its entries — members, subscription & seats, auto-recharge, team space
— are shown by consuming B's permission bits directly (canManageMembers /
canInviteMembers / canManageBilling / canManageAutoRecharge), never re-derived
from role. A locked workspace shows the recovery banner.

Per the settings-IA boundary, this is shell only: each entry renders the shared
TeamSlotPlaceholder inline — the members / billing / team-space views themselves
are B/A/D's lanes and are not built here. Personal settings and BYOK stay exactly
as they were (no team-level provider this phase).

Rewrite settings-access to permission-bit gating and update the execution mock to
answer the new /api/workspace/context fetch.
…chine)

Add the client half of the team-invite handoff (C's lane): the web acceptance
page plus the contracts and local state B's server contract hands off to. A member
opens the invite link, the page previews it, guards an account mismatch, accepts
via B's API, then hands off to the desktop client through the deeplink — with
download-fallback and retry-open paths when the client is absent or fails.

- contracts/workspace-invites.ts: preview/accept DTOs, the continuation, and the
  local LocalPendingInviteContinuation / LocalWorkspaceActivation shapes, plus
  pure parseInviteDeeplink/buildInviteDeeplink. Reuses collab.ts role/lifecycle
  enums — none re-declared.
- InviteAcceptanceFlow.tsx: every state — preview, account-mismatch, accepting,
  success (retry-open), not-installed fallback, open-failed, and each terminal /
  recoverable exception.
- invite-continuation.ts: single-slot storage (self-healing, expiry-dropping),
  activation derivation, account-match heuristic. The raw invite token is never
  persisted — only the nonce/continuation.

Desktop opendesign:// registration and shell routing stay out (injectable seams);
27 unit tests cover deeplink parsing, storage round-trips, and account matching.
@lefarcen
lefarcen requested a review from nettee July 8, 2026 11:28
@lefarcen lefarcen added size/XXL PR changes 1500+ lines risk/high High risk: apps/desktop, daemon, auth, migration, workflows, package deps type/feature New feature labels Jul 8, 2026

@nettee nettee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found three blocking issues in the new collaboration persistence and authorization path. They are all in changed lines and I left the concrete fixes inline.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment on lines +119 to +153
const published = new Map<string, number>();
const syncStates = new Map<string, ProjectSyncState>();
// projectId → the member who shared it (the single writer). Members compare
// this to their own id to know whether they view the project read-only.
const owners = new Map<string, string>();
// Always track the published head + sync state so members can poll them; also
// forward to any caller-supplied callback. (exactOptionalPropertyTypes forbids
// assigning an explicit `undefined` to an optional property, hence we always
// wrap onError rather than passing options.onError through conditionally.)
const schedulerOptions: CollabPublishSchedulerOptions = {
adapter,
onPublished: (result) => {
published.set(result.projectId, result.version);
syncStates.set(result.projectId, 'synced');
options.onPublished?.(result);
},
onError: (result) => {
// A failed publish leaves the prior head standing; surface it as a
// recoverable sync state rather than wedging the project.
syncStates.set(result.projectId, 'sync_failed');
options.onError?.(result);
},
};
const scheduler = new CollabPublishScheduler(schedulerOptions);
const presenceOptions: CollabPresenceTrackerOptions = {};
if (options.onPresenceChange) presenceOptions.onChange = options.onPresenceChange;
const presence = new CollabPresenceTracker(presenceOptions);
const teamResources = options.teamResources ?? createDevTeamResourceStateProvider();
return {
presence,
scheduler,
workspaceContext,
teamResources,
publishedVersion: (projectId) => published.get(projectId) ?? null,
projectSyncState: (projectId) => syncStates.get(projectId) ?? 'local_only',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runtime keeps the published version, sync state, and owner id only in process-local maps and then reports null / local_only whenever those maps are empty. That means a daemon restart forgets that a project was already shared, so the next viewer sees the project as local-only, loses the read-only gate, and never learns which published version to pull until someone re-shares it. The changed lines show the problem directly: published, syncStates, and owners are initialized as empty Maps here, and publishedVersion / projectSyncState / projectOwnerMemberId read only from those maps. Please derive these values from durable state instead of process memory, for example by hydrating them from the resource hub (or persisted project metadata) when the runtime starts or when /collab/status is queried, and add a restart/regression test that proves an already-shared project stays shared after recreating the runtime.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment on lines +52 to +55
// Ids shared this session. The published resources are the durable record on
// the hub; this is the fast local view the team collection reads until a hub
// listing query lands.
const shared = new Set<string>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The team collection is backed only by this in-memory Set, so it only contains resources shared through the current daemon process. After a daemon restart, or from a teammate on a different machine, GET /api/workspace/.../team will come back empty even though the resource was successfully published to the hub. That breaks the user-visible behavior described in the PR body (“Team collections … members pull shared resources into their own client”), because there is no durable listing path here. Please make sharedIds() read from durable hub state instead of session memory, for example by listing team resources from the hub and filtering by kind / idPrefix, and cover the cross-process case with a test that recreates the service after one share.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment on lines +30 to +37
// Share a personal resource to the team.
app.post(`${root}/:id/share`, async (req, res) => {
const id = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id) : '';
if (!id) return res.status(400).json({ error: 'invalid resource id' });
try {
const result = await share.share(id);
if (!result) return res.json({ shared: false });
res.json({ shared: true, version: result.version });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This route publishes straight to the team as long as share.share() succeeds, but nothing on this path checks the new workspace permission bits before doing so. In the server wiring, teamShareGetPrincipal only derives identity from workspaceContext.current(); it never enforces permissions.canManageSharedResources, so any active team member who can hit this endpoint or the matching od collab command can publish shared resources even though the UI models that as an owner/admin capability. That is an authorization bug, not just a missing button gate, because the API and CLI remain writable for callers who should be read-only. Please resolve the caller workspace context per request and return 403 unless permissions.canManageSharedResources is true before invoking share.share(), and add a route-level test for a member context to prevent regressions.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Visual regression review

Head: 8837fca · Base: eb038af

47 new visual case(s) have no baseline yet; review these screenshots before accepting their baselines.

⚠️ 1 case(s) failed during diff generation; partial captures are shown below.

0 changed · 0 unchanged · 47 new without baseline · 1 failed

Capture or diff failures

New cases without baselines

PR PR PR
visual-avatar-local-agent-list
pr
visual-avatar-local-agent-list-panel
pr
visual-avatar-menu
pr
visual-avatar-menu-panel
pr
visual-avatar-open-design-account
pr
visual-critical-settings
pr
visual-critical-workspace
pr
visual-critical-workspace-preview
pr
visual-design-system-detail
pr
visual-design-systems
pr
visual-home
pr
visual-home-catalog
pr
visual-home-context-picker
pr
visual-home-context-picker-popover
pr
visual-home-plugin-filter
pr
visual-home-plugin-use-staged
pr
visual-home-plugin-use-with-query
pr
visual-home-staged-attachment
pr
visual-new-project-modal
pr
visual-onboarding-cloud
pr

27 additional new case(s) omitted from this comment.

Visual diff is advisory only and does not block merging.

Product decision: team management (members, dashboard, billing, workspace
settings) lives in the cloud web app, and the local client only links out to it
— it does not embed those views. Align the shell accordingly:

- The nav rail drops the Members and dashboard/Board entries and keeps a single
  Workspace settings entry that opens the cloud console in the browser. The plan
  chip links to the same console for billing.
- The Settings > Workspace region collapses from an in-client entry list to one
  link to that console.
- WorkspaceCollabContext carries workspaceSettingsUrl (the console URL); the
  daemon populates it from the upstream context when present, else builds it from
  OD_VELA_WEB_URL. Team workspaces only.

This corrects the earlier demo-shaped assumption that those views would be
embedded in the local client via placeholders.
@lefarcen
lefarcen requested a review from nettee July 8, 2026 12:07

@nettee nettee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found six blocking issues in the current head: the new read-only/share gates are still bypassable, and the new resource-hub persistence/pull path loses or misapplies shared state in normal flows. I left each fix inline.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Location: apps/daemon/src/resource-drive.ts RIGHT lines 131-150

This materializer only creates or overwrites entries from the incoming manifest; it never removes paths that disappeared from the previous version or replaces conflicting existing node types before writing the new tree. That means a pull leaves deleted files/directories behind, and file↔dir/symlink transitions can fail or produce a tree that no longer matches the published version. Because collab and team-resource pulls land directly into the live workspace, this breaks the PR's byte-identical sync guarantee. Please stage into a clean temp directory and swap it into place, or explicitly prune/replace any destination paths not present in the manifest, and add a regression test that deletes or type-flips a file between versions.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Inline comment could not be anchored: inline anchor is outside the PR diff anchorable ranges

Comment on lines +31 to +37
app.post(`${root}/:id/share`, async (req, res) => {
const id = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id) : '';
if (!id) return res.status(400).json({ error: 'invalid resource id' });
try {
const result = await share.share(id);
if (!result) return res.json({ shared: false });
res.json({ shared: true, version: result.version });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This share endpoint still has no server-side authorization check for permissions.canManageSharedResources. On the current wiring, share.share() only derives a principal from workspaceContext.current() and this route never rejects a member context before publishing, so any signed-in team member who can call the API or the matching od collab command can promote resources to the team even though the new UI models that as an owner/admin capability. Please resolve the caller workspace context per request and return 403 unless canManageSharedResources is true before invoking share.share(), and add a route-level test for the denied member case.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment on lines +52 to +55
// Ids shared this session. The published resources are the durable record on
// the hub; this is the fast local view the team collection reads until a hub
// listing query lands.
const shared = new Set<string>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The team collection is still backed only by this in-memory Set, and sharedIds() later just replays it. After a daemon restart, or from a teammate on a different machine, GET /api/workspace/.../team will come back empty even though the resource was already published successfully. That breaks the PR's advertised team collections because there is no durable listing path here. Please derive team listings from durable hub state instead of session memory, for example by listing team resources for this kind/id prefix, and add a cross-process regression test that recreates the service after one share.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment on lines +119 to +154
const published = new Map<string, number>();
const syncStates = new Map<string, ProjectSyncState>();
// projectId → the member who shared it (the single writer). Members compare
// this to their own id to know whether they view the project read-only.
const owners = new Map<string, string>();
// Always track the published head + sync state so members can poll them; also
// forward to any caller-supplied callback. (exactOptionalPropertyTypes forbids
// assigning an explicit `undefined` to an optional property, hence we always
// wrap onError rather than passing options.onError through conditionally.)
const schedulerOptions: CollabPublishSchedulerOptions = {
adapter,
onPublished: (result) => {
published.set(result.projectId, result.version);
syncStates.set(result.projectId, 'synced');
options.onPublished?.(result);
},
onError: (result) => {
// A failed publish leaves the prior head standing; surface it as a
// recoverable sync state rather than wedging the project.
syncStates.set(result.projectId, 'sync_failed');
options.onError?.(result);
},
};
const scheduler = new CollabPublishScheduler(schedulerOptions);
const presenceOptions: CollabPresenceTrackerOptions = {};
if (options.onPresenceChange) presenceOptions.onChange = options.onPresenceChange;
const presence = new CollabPresenceTracker(presenceOptions);
const teamResources = options.teamResources ?? createDevTeamResourceStateProvider();
return {
presence,
scheduler,
workspaceContext,
teamResources,
publishedVersion: (projectId) => published.get(projectId) ?? null,
projectSyncState: (projectId) => syncStates.get(projectId) ?? 'local_only',
projectOwnerMemberId: (projectId) => owners.get(projectId) ?? null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The project's published version, sync state, and owner id are still stored only in process-local maps and then read back exclusively from those maps. After a daemon restart, an already shared project falls back to publishedVersion: null, syncState: 'local_only', and ownerMemberId: null, so the next viewer loses the read-only gate and never learns which published version to pull until somebody shares again. Please hydrate these values from durable state instead of process memory, such as persisted project metadata or the resource hub, and add a restart/regression test that proves a previously shared project stays shared after recreating the runtime.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

async pullLatest(projectId) {
// The real hub adapter materializes the published tree locally; the stub
// has no bytes. Either way, report the head version.
if (adapter.pull) await adapter.pull({ projectId });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pullLatest() calls adapter.pull() before it checks whether a published head exists. With the real hub adapter, pull() immediately resolves the published ref via materializeRef(), so a perfectly normal pre-share pull now throws instead of returning the version: null result this route and the stub tests expect. Please check syncLatest() first and return { version: null } before pulling, or make the real adapter treat a missing published ref as a no-op.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

@@ -881,7 +884,7 @@ export function DesignFilesPanel({
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new viewerOnly gate only hides the visible create/upload actions. The panel still accepts file drops through handleDrop() above, which unconditionally calls onUploadFiles(dropped), and FileWorkspace.uploadFiles() has no viewerOnly check. A read-only member can therefore mutate a shared project simply by dragging files onto the Design Files panel, which bypasses the single-writer/read-only behavior this PR is supposed to enforce. Please block the drop/upload path when viewerOnly is true and add a focused test that a viewer-only drop never reaches uploadProjectFiles().

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

…loud web)

Product consolidated team surfaces into the cloud web app, and the invite
preview/accept page now lives there (workspace-invite-preview / -continuation).
The per the B-C contract, C's remaining part is the desktop side — parsing the
opendesign:// deeplink, validating the continuation, and persisting the local
workspace activation. That state machine (invite-continuation.ts) and the shared
contract stay; only the redundant in-client acceptance page is removed.
@open-design-crew open-design-crew Bot mentioned this pull request Jul 8, 2026
9 tasks
@lefarcen
lefarcen requested a review from nettee July 8, 2026 14:15
…eal /settings route

The console serves the current workspace's settings at /settings (workspace
implicit from the session), not a workspace-scoped path. Construct the fallback
from OD_VELA_WEB_URL + /settings so the client can link out without waiting on the
upstream context to carry the URL; an explicit URL still wins when present.

@nettee nettee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found five blocking issues that still break the promised shared-state durability and the server-side ownership/permission gates on the current head. I left the concrete fixes inline.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

scheduler,
workspaceContext,
teamResources,
publishedVersion: (projectId) => published.get(projectId) ?? null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

publishedVersion, projectSyncState, and projectOwnerMemberId are all read back only from the three in-memory maps initialized above. After a daemon restart, an already shared project therefore falls back to publishedVersion: null, syncState: 'local_only', and ownerMemberId: null, so the next viewer loses the read-only gate and no longer knows which version to pull until somebody shares again. That breaks the PR's advertised persistent shared-project behavior. Please hydrate these values from durable state instead of process memory, such as persisted project metadata or the resource hub, and add a restart/regression test that recreates the runtime after one successful share.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

async pullLatest(projectId) {
// The real hub adapter materializes the published tree locally; the stub
// has no bytes. Either way, report the head version.
if (adapter.pull) await adapter.pull({ projectId });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pullLatest() materializes the tree before it checks whether a published ref exists. With the real hub adapter, pull() calls materializeRef(..., 'published', ...), so a perfectly normal pre-share pull now throws instead of returning { version: null }. The current route test only uses the stub adapter, so this regression is invisible there. Please check syncLatest() first and return early when it is null, or make the real adapter treat a missing published ref as a no-op, and cover that path with a real-adapter regression test.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

if (result) shared.add(resourceId);
return result;
},
sharedIds: () => [...shared],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The team collection is still backed only by this session-local Set. sharedIds() never consults durable hub state, so after a daemon restart, or from a teammate on a different machine, GET /api/workspace/.../team comes back empty even though the resource was already published successfully. That breaks the PR's stated behavior that shared design systems/plugins/skills show up in a team collection for other members to pull. Please make the listing path read from durable hub state instead of process memory, for example by listing shared resources by kind/idPrefix, and add a cross-process test that recreates the service after one share.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment on lines +42 to +73
app.post('/api/projects/:id/collab/changed', (req, res) => {
scheduler.notifyChanged(req.params.id, 'change');
res.json({ ok: true });
});

// Run boundary — flush any pending publish immediately (publish the stable
// end-of-run state rather than waiting out the debounce).
app.post('/api/projects/:id/collab/publish', (req, res) => {
scheduler.notifyChanged(req.params.id, 'run');
scheduler.runBoundary(req.params.id);
res.json({ ok: true });
});

// visibility-to-sync orchestration seam. The visibility surface flips project visibility and emits a
// ProjectSyncIntent here; the sync trigger owns the reaction. `project_team_share_requested`
// marks the project pending and flushes a publish (which drives E's resource
// mechanism behind the scheduler). `project_visibility_changed` is accepted as
// a no-op signal for now (the share request is the actionable one).
app.post('/api/projects/:id/collab/sync-intent', async (req, res) => {
const event = (req.body as { event?: unknown } | undefined)?.event;
if (typeof event !== 'string' || !SYNC_INTENT_EVENTS.has(event as ProjectSyncIntentEvent)) {
return res.status(400).json({ error: 'invalid sync intent event' });
}
if (event === 'project_team_share_requested') {
// The caller sharing the project is its single writer; record their id so
// members can distinguish it from a project of their own.
const context = await workspaceContext.current({
authorization: req.headers.authorization,
});
requestTeamShare(req.params.id, context?.workspaceMemberId);
}
res.json({ ok: true, syncState: projectSyncState(req.params.id) });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These write routes never enforce the single-writer contract that the new UI is trying to represent. changed and publish accept any caller unconditionally, and sync-intent records whoever calls it as the owner without checking permissions.canWriteSyncedFiles / canShareProjects or comparing the caller against the already-recorded owner. Because od collab publish and od collab share expose these endpoints directly, a non-owner member can still mutate a local copy and publish it back to the shared project even when the web surface shows them as read-only. Please resolve the caller context on every write-shaped collab route, reject writes when the workspace is non-writable or the caller is not the recorded owner, and add route-level plus CLI negative tests for a viewer-only member.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment on lines +31 to +37
app.post(`${root}/:id/share`, async (req, res) => {
const id = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id) : '';
if (!id) return res.status(400).json({ error: 'invalid resource id' });
try {
const result = await share.share(id);
if (!result) return res.json({ shared: false });
res.json({ shared: true, version: result.version });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This POST handler treats shared: false as the only denial case and never checks the caller's workspace permissions before publishing. The shared contract added in this PR explicitly carries permissions.canManageSharedResources as the resource-sharing gate, so hiding the button in the UI is not enough: any team member can still POST here directly and publish a design system/plugin/skill to the team. Please resolve the caller's workspace context in the route, return 403 unless canManageSharedResources is true, and add a member-vs-owner/admin test so the backend matches the role-based behavior the new surfaces rely on.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

@lefarcen

lefarcen commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by the three-part stacked review that lands the same work in smaller, layer-scoped diffs: #5281 (backend contracts + daemon), #5283 (in-project collaboration client), #5284 (team-edition surfaces). Closing this in favor of those.

@lefarcen lefarcen closed this Jul 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk/high High risk: apps/desktop, daemon, auth, migration, workflows, package deps size/XXL PR changes 1500+ lines type/feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants