Skip to content

Team collaboration: daemon team-project catalog bridge - #5383

Open
open-design-crew[bot] wants to merge 1 commit into
codex/workspace-project-crudfrom
codex/workspace-vela-catalog-backend
Open

Team collaboration: daemon team-project catalog bridge#5383
open-design-crew[bot] wants to merge 1 commit into
codex/workspace-project-crudfrom
codex/workspace-vela-catalog-backend

Conversation

@open-design-crew

Copy link
Copy Markdown
Contributor

Why

This finishes the D-lane backend bridge between local workspace project visibility and the Vela-owned team project catalog. The local workspace_projects table remains the owner daemon's management projection; Vela becomes the cross-daemon member-discovery catalog for team-shared projects.

Without this bridge, a project moved to team visibility can publish bytes through the resource hub, but another daemon has no catalog entry to discover that shared project.

What users will see

Team workspace project lists can include projects shared by other members as read-only team projects once Vela returns them from /api/v1/team-projects. Moving a local personal project into team space now marks the Vela catalog pending, then synced or failed as the publish completes.

Surface area

  • UI — new page / dialog / panel / menu item / setting / empty state in apps/web or apps/desktop (including Electron menu bar)
  • Keyboard shortcut — new or changed
  • CLI / env var — new od subcommand or flag, new tools-dev / tools-pack / tools-pr flag, or new OD_* env var
  • API / contract — changed daemon behavior for /api/workspaces/:workspaceId/projects; calls Vela /api/v1/team-projects
  • Extension point — new entry under skills/, design-systems/, design-templates, or craft/, or change to the skills protocol
  • i18n keys — added new translation keys (see TRANSLATIONS.md for the locale workflow)
  • New top-level dependency — adding any new entry to the root package.json (dependencies or devDependencies); workspace-package package.json files are out of scope. Include a paragraph on what we get vs. what bytes we ship (see CONTRIBUTING.md → Code style)
  • Default behavior change — team workspace lists now merge Vela catalog projects when OD_RESOURCE_HUB_URL is configured
  • None — internal refactor, docs, tests, or translation update only

Screenshots

No UI change in this stacked PR.

Bug fix verification

Validation

  • corepack pnpm --filter @open-design/contracts build && corepack pnpm --filter @open-design/registry-protocol build && corepack pnpm --filter @open-design/daemon exec tsc -p tsconfig.json --noEmit && corepack pnpm --filter @open-design/daemon exec tsc -p tsconfig.tests.json --noEmit
  • corepack pnpm --filter @open-design/daemon exec vitest run -c vitest.config.ts tests/routes/workspace-projects.test.ts
  • corepack pnpm --filter @open-design/daemon exec vitest run -c vitest.config.ts tests/collab-sync-routes.test.ts
  • corepack pnpm guard

@lefarcen
lefarcen requested a review from mrcfps July 9, 2026 12:53
@lefarcen lefarcen added size/L PR changes 300-700 lines risk/medium Medium risk: regular code changes type/feature New feature labels Jul 9, 2026
@open-design-crew

Copy link
Copy Markdown
Contributor Author

简要总结:

  • 把 OD daemon 接到 Vela team-project catalog:本地 owner 管理面和跨 daemon 成员发现接起来。
  • requestTeamShare 后写 catalog:先标 pending_upload,publish 成功改 synced,失败改 failed
  • workspace 项目列表会合并 Vela 返回的远端 team project,作为只读共享项目展示。
  • 本地 workspace_projects 仍只管 owner 这台 daemon 的投影,不新增本地团队项目列表表。
  • 已补路由测试,覆盖“本地项目 + Vela 远端共享项目”合并后的只读访问模型。
  • 这个 PR 叠在 Add workspace project visibility and handoff seams #5218 上,不改 UI;Add workspace project team views #5310 继续消费同一个 workspace projects 接口即可。

@mrcfps mrcfps 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.

@app/open-design-crew Thanks for landing the backend bridge between workspace project visibility and the Vela catalog. I found one merge-safe capability mismatch in the remote summary shape that is worth tightening so the API does not advertise local mutations for catalog-only projects.

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

Comment on lines +1311 to +1318
canRename: canEdit,
canDelete: canEdit,
canDuplicate: canEdit,
canMoveToTeam: false,
canMoveToPersonal: canEdit,
canExport: canView,
canSendTo: canView,
canRestoreVersion: canEdit,

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.

Remote catalog entries are list-only summaries in this daemon: they are not backed by a local projects row, and the move/delete/batch routes below still resolve targets through getProject / workspaceProjectRowsForIds. If Vela returns access.canEdit: true for an owner/admin or future editable remote record, this response will advertise rename/delete/duplicate/move-to-personal/restore permissions that the daemon cannot actually perform, leading callers to show actions that 404 or fail later. Since this PR describes these as read-only member-discovery projects, please keep the remote mutation flags false here (or gate them on a local materialized project path/route support) and add a fixture with canEdit: true to lock the behavior.

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

lefarcen added a commit that referenced this pull request Jul 9, 2026
… cloud (C §D2.5/§D4)

Comments lived only in each daemon's local preview_comments, so a member's comment
on a shared project never reached the owner, and a comment's author couldn't be
named. Add the C-lane collab cloud: an append-only, project-scoped comment channel
plus a lightweight member directory, per C spec §D2.5 (independent light channel,
comment carries authorMemberId + the drift-ladder anchoring fields) and §D4
(bearer-auth hub, foreground 5s poll, ETag/304).

- tools/serve: collab-cloud fixture (tools-serve start collab-cloud, :18096),
  team-isolated, monotonic seq, idempotent-by-id, bearer OD_COLLAB_CLOUD_TOKEN.
- daemon: env-built client (null when OD_COLLAB_CLOUD_URL unset) + a service that
  registers the member, pushes on comment create, and polls+merges inbound
  comments (INSERT OR IGNORE by author-daemon id; re-homed onto the receiver's
  local conversation). Kept out of runtime.ts to avoid colliding with #5383.
- /collab/status gains ownerDisplayName/ownerRole and GET /api/workspace/members
  exposes the directory, so the web can render comment authors + a named
  read-only banner. STUB: real name source is B's roster; degrades to no-op
  off-team / unconfigured.
lefarcen added a commit that referenced this pull request Jul 9, 2026
…ync files

The publish mechanism worked (publish->hub->pull moves bytes) but nothing
triggered it on a real edit — reportChange/requestPublish were only wired to a
demo view's buttons, so an owner editing a shared project's files never published
and members never saw the change.

- collab-publish-watcher: subscribes to file-change events for the projects THIS
  daemon's member owns + has shared, coalescing every edit into a debounced
  publish through the scheduler. Read-only gate (team-shared AND owner === me)
  means a member's pulled copy is never watched, so an inbound pull can't loop
  into a publish and a member can't publish edits to someone else's project.
- runtime.publishedHead(): reads the hub's published ref (E's syncLatest), and
  /collab/status now reports that instead of this daemon's in-memory counter — so
  a member (who never published) sees the owner's latest version across daemons +
  restarts and knows when to pull.
Kept out of runtime.ts internals to avoid colliding with #5383.
lefarcen added a commit that referenced this pull request Jul 12, 2026
… file sync, demo-aligned UI (#5395)

* feat(daemon): persist team-collab comment anchor state

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.

* feat(web): comment drift-ladder anchor resolution engine

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.

* feat(daemon): anchor write-back route for the comment drift ladder

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.

* feat(web): render comment drift states in the preview overlay

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).

* feat(daemon): collab publish scheduler (author-side sync trigger)

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.

* feat(daemon): collab presence tracker (team presence overlay)

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.

* feat(daemon): wire collab presence into the daemon (live capability)

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.

* feat(daemon): collab sync trigger routes (client-driven publish)

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.

* feat(web): collab client integration (presence heartbeat + sync polling)

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.

* feat(cli): od collab subcommand (dual-track for presence + sync)

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).

* feat(web): presence overlay UI slice (useCollab hook + PresenceBar)

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.

* feat(web): persist lost comment anchors (durable ghost pins)

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.

* refactor(contracts): lift collab presence/sync DTOs into contracts

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.

* feat(web): collab demo surface at /collab-demo (visible end-to-end)

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.

* fix(web): make the collab demo actually load in the Next runtime

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).

* feat(web): visible comment drift-ladder demo (third vertical)

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.

* feat(daemon): scaffold Spec E resource-sharing skeleton

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>

* fix(web): drop presence promptly on tab close (sendBeacon leave)

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.

* feat(daemon): wire presigned blob transport to the resource hub

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>

* feat(web): collab session decision seam onto the B/D lanes

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.

* feat(daemon): workspace-context route (B-integration seam)

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.

* feat(daemon): neutral cloud-drive SDK over the resource hub

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>

* feat: D->C sync-intent seam + member-visible ProjectSyncState

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.

* feat(daemon): member pull trigger (C owns when; E fetches behind adapter)

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.

* feat(contracts): team-resource copy red-line guard (AC-9, D3)

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.

* feat(web): settings shell role gating helper (D4.3)

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.

* feat(daemon): team-resource state seam + live D3 copy-guard enforcement (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.

* feat(daemon): mount D3 copy-guard on the real plugin duplicate route

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).

* feat(daemon): mount D3 copy-guard on skill edit-shadow route

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).

* chore(daemon): point resource client at the API (18080), refresh header

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>

* feat(web): wire team presence into the real ProjectView (C frontend in-product)

Moves C-collab presence from the /collab-demo page into the real product.
useWorkspaceContext fetches GET /api/workspace/context (B seam); useProjectCollab
composes it with resolveCollabSession + useCollab, so presence + sync run only
when the viewer is an active team member of a live workspace. ProjectView mounts
it unconditionally and renders PresenceBar in the project header when enabled —
dormant (no heartbeat, renders nothing) for a personal workspace / no context,
so normal use is unchanged. Drift-ladder activation in FileViewer is the next
in-product wire. 3 hook tests green.

* feat(web): activate comment drift ladder in the real FileViewer for team members

Second in-product wire (after presence): the comment drift ladder now runs in
the real project view for team members. CollabProvider shares ProjectView's
useProjectCollab state down to HtmlViewer via context (no prop-threading through
the 8k-line intermediates, no second collab client); CommentPreviewOverlays gets
driftLadder=collab.enabled + currentVersion=publishedVersion, so a team member
sees anchored/reanchored/stale/lost badges instead of the exact-match silent
drop. Dormant for personal/no-context (enabled=false → exact-match as before).
onLostAnchors durability write-back is a follow-up (needs conversationId at this
layer). Typecheck + collab/comment suites green.

* feat(web): persist lost comment anchors in the real product (durability loop)

Completes the comment drift ladder in the real project view: the onLostAnchors
write-back (deferred earlier for lack of a conversation id at the FileViewer
layer) is now fed through the collab context. ProjectView — which owns the
active conversation id — builds the persist callback (persistCommentAnchors →
PATCH the anchor route) and provides it via CollabProvider; FileViewer passes it
to CommentPreviewOverlays. So a team member's comment that drifts to `lost` has
its last-good position persisted, surviving reload. Memoized context value; the
callback no-ops without a conversation. Typecheck + collab/comment suites green.

* feat(tools-serve): add temporary in-memory resource-hub fixture

A self-contained, infra-free local stand-in for the Spec E resource
storage service (which lives in vela at services/api/src/resources/*), so
teammates can develop the daemon's resource features without standing up
vela + postgres + MinIO. It faithfully implements the same wire contract
(x-internal-token + x-workspace-* auth, freeze, cross-team isolation,
content-addressed dedup, presigned-style blob transport served in-process).

The daemon makes no mock concessions — it runs its real client/SDK/CLI
against the fixture exactly as against vela. Disposable: delete the fixture
and repoint OD_RESOURCE_HUB_URL once the vela test environment is stood up;
the daemon needs zero code changes. Adds a hermetic contract test and
AGENTS.md docs (tools/serve + apps/daemon).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(collab): neutral comments — drop internal role/spec references

Rewrite the collaboration code comments to describe behavior in plain technical
terms instead of internal role names, lane letters, and unpublished spec
references, so external contributors reading the repo aren't confused. Comment-
only change (typecheck + collab/comment suites green). Also require workspaceId
on ProjectSyncIntent.

* feat(daemon): publish/pull project content through the resource hub

Bind the sync trigger to the resource-hub SDK: createResourceHubPublishAdapter
implements the publish adapter via packTree + get-or-create resource + pushTree
(publish content, move the published ref) and materializeRef (member pull lands
the tree locally). Selected automatically when OD_RESOURCE_HUB_URL + workspace
member env are set, else the local stub. Verified end-to-end against the local
resource-hub fixture: an owner publishes a project and a member pulls the exact
content into a fresh dir. (resourceId is colon-free — the hub routes it as a
path param.)

* feat(daemon): derive the resource-hub principal from the workspace context

Single identity source: add teamId to WorkspaceCollabContext and resolve the
resource-hub principal lazily from that same context (contextToResourceHubPrincipal)
rather than reading env separately — so one signed-in identity drives both the
web collab gate and the hub principal. No team identity (signed out / personal)
→ principal null → publish/pull no-op, matching the web-side degradation.
Verified end-to-end against the fixture (team context publishes + a member pulls
identical content; null context degrades).

* feat(web): community template gallery at /community (per demo)

First E-frontend surface, ported from the team-features demo's visual skeleton:
CommunityView renders the template gallery — type/subtype filters, cards with
thumbnails, remix action — behind a /community route (exempt from the first-run
onboarding redirect so it's reachable). Curated gallery + thumbnails from the
demo; wiring the browse to the real design-template / marketplace backend and a
real remix-to-new-project action are follow-ups. Typecheck green; verified
rendering in the real product.

* feat(daemon): share a design system to the team via the resource hub

A member with publish rights promotes a personal design system into the team
scope: its directory is packed and pushed to the resource hub under the
design_system kind, so teammates can pull it into their own workspace. Reuses
the project-sync publish adapter (generalized with a resource kind) rather than
a parallel path; the principal derives from the same one workspace context, so a
single signed-in identity drives every share. Off-team or with the hub
unconfigured, share degrades to a local-only no-op.

Routes: POST /api/workspace/design-systems/:id/share and
GET /api/workspace/design-systems/team (drives the team collection). Resource
ids are sanitized colon-free so the hub can route them as path params. Verified
end-to-end against the resource-hub fixture: owner share -> member pull ->
byte-identical content.

* feat(web): team collection + share action in design systems (per demo)

Adds a 'team' collection to the design-systems tab: a member promotes a personal
design system into the team scope from the detail overflow menu (Share to team),
and shared systems gather under the new team tab alongside mine/official/
enterprise. The team list and share both go through the daemon's
/api/workspace/design-systems endpoints, which publish to the resource hub, so
the UI mirrors the same content-shared source of truth as project sync. Off-team
the tab is simply empty and the fetch degrades quietly.

Two i18n keys added across all locales (share label + failure); the tab label
reuses the existing team string. Web typecheck green; the team tab renders in
the running product.

* feat(cli): od collab share-design-system + team-design-systems

Mirrors the web team design-system sharing on the CLI so external agents can
drive it headless: `od collab share-design-system <id>` promotes a personal
design system into the team scope through the resource hub, and
`od collab team-design-systems` lists the shared ones. Both hit the same
/api/workspace/design-systems endpoints the UI uses and support --json.

* feat(web): cloud sign-in callout in settings (per demo)

Ports the demo's cloud sign-in callout into the local-CLI settings pane: it
promotes the team/cloud workspace (team spaces, shared projects, member
permissions, audit dashboard) and links to onboarding. Shown in local-CLI mode
where a cloud identity is not yet established. Team features gate on a cloud
identity, not on the coding agent, so this is the entry point to them regardless
of which agent runs the design work.

Three i18n keys across all locales; callout markup + styles mirror the demo.
Web typecheck green.

* feat(web): team workspace switcher in the top bar (per demo)

Adds a workspace switcher to the entry top bar: when the signed-in identity is a
team workspace it surfaces the current team and a menu (current team, invite,
new team). It reads the one workspace context the rest of the collab surface
reads, so a personal or local session renders nothing — the switcher appears
only once you are in a team. Membership management lives in the cloud/onboarding
flow, so those actions route there rather than faking a roster the client does
not own.

Adds a teamName field to WorkspaceCollabContext (falls back to teamId) so the
switcher can show a friendly name; the daemon dev context parser accepts it.
Three i18n keys across all locales; self-contained component + CSS module.
Verified rendering against a team context in the real product.

* feat(web): read-only view for a member of a team-shared project

A team member viewing a project shared to the team now sees it
single-writer/read-only: a notice explains they can view and comment but not
edit, the file workspace withholds its edit affordances (launcher actions,
project header actions), and file rename/delete no-op. The signal is derived in
useProjectCollab from the one workspace context — a non-managing role viewing a
shared project (syncState past local_only) — so an owner/admin still edits and a
personal or unshared project is never read-only. The precise per-project
ownership check belongs to the project-visibility owner; this is the working
signal until that lands.

One i18n key across all locales; notice styles alongside the workspace drawer.
Verified end-to-end: a member context + a shared project renders the read-only
notice in the real product.

* feat(web): extend read-only view to file actions and chat send

Completes the team-shared read-only view: the design-files panel withholds its
create/upload toolbar and empty-state actions, and the chat composer's send is
disabled so a viewer cannot drive artifact changes through chat. Viewing and
commenting stay available. Threaded from the same viewerOnly signal; verified in
the real product against a member context on a shared project.

* feat(collab): exempt a shared project's owner from the read-only view

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.

* feat(web): labeled navigation sidebar (per demo)

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.

* feat(daemon): team sharing for plugins and skills (generalize DS share)

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.

* feat(cli): generalize od collab resource sharing to plugins and skills

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.

* feat(web): real team plugins collection with share-to-team

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.

* feat(web): team skills collection alongside plugins in the team tab

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.

* feat(collab): mirror the full workspace context onto B's shipped shape

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.

* feat(collab): freeze a non-writable workspace read-only via B's permission 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.

* feat(collab): real workspace-context provider backed by B's vela endpoint

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.

* feat(web): team-edition navigation shell driven by the real workspace 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.

* feat(collab): vela resource CLI transport for publish/pull (opt-in)

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.

* feat(web): role-gated Workspace section shell in Settings

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.

* feat(web): invite-acceptance client flow (contracts + page + state machine)

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.

* feat: link team management out to the cloud console, not in-client

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.

* chore(web): drop the in-client invite-acceptance page (moved to the cloud 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.

* fix(collab): build the workspace settings link from the cloud app's real /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.

* refactor(collab): allow an async resolveDir on the team-share service

The skill share resolver awaits the skill index, and the publish adapter already
awaits resolveDir before packing — so widen the declared type to
string | Promise<string> to match how it is used, per review.

* feat: desktop team-invite deeplink wake-up

Complete C's side of the invite hand-off: after the cloud web accepts an invite it
opens opendesign://workspace/invite/continue?...&nonce=..., which now wakes the
desktop client.

- Desktop registers the opendesign:// scheme and routes the deeplink (open-url on
  macOS, second-instance argv on Windows/Linux, initial argv on cold start) to the
  daemon, then focuses the window. The parse + forward core takes no electron
  import so it is unit-tested.
- Daemon POST /api/workspace/invite/continue consumes the one-time continuation on
  B (POST /workspace-invites/continuations/{nonce}/consume) with the signed-in
  vela session — never a client-supplied identity — and returns the resolved
  workspace context so the client switches into the team workspace. B enforces
  subject match and single consume; failures degrade to typed HTTP errors.

Unit tests cover the daemon consume (session bearer, B's 403/409/410, no-session,
transport error) and the desktop parse/forward (nonce POST, non-invite ignore,
daemon-unavailable, consume-failed, unreachable).

* fix(collab): gate resource sharing on canManageSharedResources; shrink a demo asset

Two review fixes:

- The team-share route now enforces the workspace permission: only a member who
  can manage shared resources (owner/admin on a writable workspace) may share.
  A team member without it gets 403 WORKSPACE_RESOURCE_SHARE_DENIED — the gate the
  web surface already hides the action behind can no longer be bypassed by calling
  the route directly. No team identity stays a silent no-op.
- Replace the 2 MB community-template PNG with a 162 KB WebP (matching the other
  gallery assets) so it no longer bloats the repo.

* fix(collab): route team resource share through the vela CLI transport; harden read-only + share gates

- Team resource sharing (design systems / plugins / skills) now honors the
  vela-cli transport (OD_RESOURCE_TRANSPORT=vela-cli), so a share drives the
  same signed-in session as project sync instead of failing with an untrusted
  caller when no internal token is held.
- Project team-share intent gains a server-side canShareProjects gate: a member
  without share rights is refused (403) rather than trusting the client.
- Shared-project read-only fails closed — a non-owner of any role stays
  read-only until the polled owner id explicitly matches, closing the load
  window where an admin briefly saw edit affordances.
- DesignFilesPanel viewer-only mode now suppresses every mutation entry point
  (drop-upload, batch delete, row menu), not just the top-bar buttons.
- Community 'Remix' threads the chosen template id + a seeded Home-composer
  prompt instead of dropping the selection on a generic navigation.
- useCollab memberKey uses a printable JSON key instead of raw NUL separators
  that rendered the source as a binary blob.

* test(web): reach the Projects view via /projects after the nav-rail refactor

The rail no longer renders a single 'Projects' button (it split into the
drafts/all-projects team slots), so the onboarding test drove a removed
testid. Start on /projects and click the DesignsTab new-project CTA directly.

* feat(web): render real project grids for drafts / all-projects views

Replace the 'coming soon' TeamSlotPlaceholder for the drafts and all-projects
nav destinations with a real RecentProjectsStrip project grid, matching the
demo shell. These are the C-owned shell views, not a downstream-lane placeholder.

* feat(web): port the full team-edition nav rail to match the demo shell

Rewrite EntryNavRail to the demo structure wired to real workspace context:
account section (real displayName) + credits chip (plan tier; balance pending
a vela billing CLI 收口), search, 最近, Community, an inline team switcher
(real teamName), and the team destinations 草稿 / 全部项目 / 设计体系 / 扩展.
Members / dashboard / workspace-settings link out to B's vela/web console
(derived from workspaceSettingsUrl); no fake demo data is carried over.

* feat(collab): A-lane billing 收口 — real credits + plan tier in the nav chip

Surface the caller's real Vela billing (plan tier + credit balance) in the
nav credits chip via the same CLI 收口 as resources: the daemon shells out to
`vela billing summary --format json` (integrations/vela-billing.ts), exposes it
at GET /api/workspace/billing, and the web nav fetches it (useWorkspaceBilling)
to render the real tier + balance instead of a placeholder. Degrades cleanly to
the context plan-tier hint when the CLI/session is unavailable. Verified
end-to-end against a live vela billing backend (tier=team, real balance).

* feat(web): extensions marketplace view (专家套件 / 技能 × 官方/团队/个人)

Port the demo's PluginMarketplaceDemo UX as ExtensionsMarketplace (in
PluginsView): two tabs (专家套件/技能) each with 官方/团队/个人 scope filters,
wired entirely to real daemon data — official = registry/marketplace list,
团队 = /api/workspace/{plugins,skills}/team, 个人 = installed / user skills.
Share-to-team reuses the real POST /api/workspace/{plugins,skills}/:id/share.
Route the plugins view to it; wire the demo's plugin-marketplace stylesheet.

* feat(web): project → team-space share entry on the project card

Add a 转入团队空间 action to the project card ⋯ menu (drafts / all-projects /
recent), matching the demo. It fires the project_team_share_requested
sync-intent the daemon already gates on canShareProjects, shows a loading
overlay while sharing, and marks the card with a 共享 badge on success.

* feat(collab): A-lane billing checkout 收口 + team-wide shared-project discovery

- Billing checkout: daemon fetchBillingCheckoutUrl (spawns `vela billing
  checkout`) + POST /api/workspace/billing/checkout; the nav credits chip opens
  a panel with an 升级 action that opens the returned Stripe URL.
- Team project discovery: GET /api/workspace/projects/team lists the team's
  shared projects team-wide (via the hub listResources), and the 全部项目 view
  surfaces them for any member (owner + members, bidirectional).

Checkpoint — the 全部项目 team-project surface is reworked next to reuse the
normal project-card grid and register pulled projects for read-only open.

* feat(web): match demo — port CreditsPanel + all-projects normal grid + register-on-pull

- Credits chip opens the demo's CreditsPanel (ported verbatim: plan header +
  升级 CTA, dashed divider, 剩余积分 row, 查看使用情况), wired to real billing.
- 全部项目 reuses the normal RecentProjectsStrip grid for team-shared projects
  (drop the custom section), subtitle 团队所有人的项目.
- Opening a shared project pulls + registers it (insertProject, name from the
  pulled manifest) so it opens read-only for non-owner members.

* feat(web): port demo InviteDialog + re-port share panel to latest demo

- 邀请同事 opens the demo's InviteDialog (verbatim) → POST /api/workspace/invite
  → B create-invite (degrades when B endpoint absent).
- FileViewer share menu re-ported to demo 1ee7a866f: Share link/Export/发送到...
  tabs, 2-level shareAccess (仅自己/工作空间成员) via chrome-share-card, plus a
  发布单个文件给所有人 publish + copy-link affordance.

* fix(web): drive team-shared badge from the live team-projects query

The 转入团队空间 badge read only from RecentProjectsStrip's in-session Set, so a refresh reset the card to its 本地 state and re-enabled the action even though the share had already landed on the hub. Source the badge from the live useTeamProjects query (the team-wide list the daemon re-reads on every load) unioned with the optimistic in-session set, so a moved project stays 已在团队空间 after reload across the home strip, drafts, and 全部项目 grids.

The truth lives on the hub, not the client; the client queries it live and the in-session Set only bridges the gap until the next query.

* feat(web): poll team-shared projects so teammates see shares without refreshing

useTeamProjects only fetched on mount, so one member never saw another's share
until they reloaded. Add a ~15s poll that refetches the team list in the
background (no loading spinner), plus a mounted guard so it never sets state after
unmount. The read is daemon-local today (cheap), so the poll just refetches the
whole list; document that once this proxies vela over the CLI (a slow cross-network
call) it should first probe vela's version/last-modified and only pull the full
list when it changed. Also mark team project visibility as the D-lane directory
service's responsibility — this hub-derived read is a temporary, locally
debuggable stand-in until that vela endpoint lands.

* fix(daemon): derive team-shared read-only from the hub so members can't edit others' work

Opening a pulled team-shared project let a member fully edit + Chat on it: the
collab runtime never recorded it as shared, so /collab/status returned
{syncState: local_only, ownerMemberId: null}. useProjectCollab's gate is
(shared && !isOwner), so local_only meant viewerOnly=false — no read-only notice,
full edit, share/download all live on someone else's project.

Derive read-only from the team hub at status-read time instead of caching it from
a pull: /collab/status now, when it has no local share lifecycle (local_only),
resolves the project's owner from the hub (server-authoritative, not a
client-supplied id). A hub-listed project reports synced + its owner, so a
non-owner member flips to viewerOnly=true. The owner resolves to their own id and
stays editable. Deriving every read makes read-only survive a daemon restart
(which clears the in-memory maps) and an already-pulled project opened without a
re-pull — neither of which a pull-time record would cover. Red specs in
collab-sync-routes.

* feat(web): enforce read-only UI for shared team projects a member doesn't own

A member opening a team-shared project could still edit it: FileViewer had no
concept of read-only, so share/download/export/mark/edit/source/version-restore
stayed live and the composer could send to Chat. Thread the real viewerOnly
(projectCollab.viewerOnly, from /collab/status) through FileViewer + HtmlViewer
and gate every mutating affordance on it: hide share/download/export, disable
mark/edit/source-switch, block version restore, and disable comment send-to-chat
while keeping comment viewing/saving (comment-only). ProjectView gates the title
(contentEditable), design-system picker, and composer placeholder; FileWorkspace
passes viewerOnly down + shows the lock read-only banner; DesignFilesPanel
disables the row checkbox. Driven entirely by the real viewerOnly, no demo mock;
terminal source becomes D's ProjectAccessFlags.

* fix(web): keep comments sendable in read-only + align read-only notice to demo wording

Two read-only fixes from testing the shared-project viewer:
- The read-only port wrongly disabled the comment composers (BoardComposerPopover,
  CommentSideDock) on viewerOnly, so a member could not save comments at all —
  breaking the comment-only invariant (read-only = view + comment). Drop the
  viewerOnly gate there; only the Chat composer (ProjectView) stays send-disabled.
- Re-word workspace.readonlyNotice to match the demo's phrasing ('...you can view
  and comment, but not change artifacts through Chat or the editing tools') across
  all 19 locales, instead of hardcoding a literal. The demo personalizes it with
  the owner's name ('这是 X 创建的共享项目'); that needs the member roster (not wired),
  so the copy stays generic for now.

* feat(web): make the chat composer non-editable in a read-only shared project

Read-only only disabled send, so a member could still type in the composer and
click the starter cards on a shared project they don't own. Thread the real
viewerOnly through ChatPane -> ChatComposer -> LexicalComposerInput as
inputDisabled: seed initialConfig.editable=false and reactively editor.setEditable
so a mid-session flip takes, swallow Enter, and mute the input (composer-input--
readonly). Hide the empty-state starter cards + title in read-only. Comment
composer stays usable (comment-only); no demo mock.

* feat(collab): cross-daemon comment sync + member directory via collab cloud (C §D2.5/§D4)

Comments lived only in each daemon's local preview_comments, so a member's comment
on a shared project never reached the owner, and a comment's author couldn't be
named. Add the C-lane collab cloud: an append-only, project-scoped comment channel
plus a lightweight member directory, per C spec §D2.5 (independent light channel,
comment carries authorMemberId + the drift-ladder anchoring fields) and §D4
(bearer-auth hub, foreground 5s poll, ETag/304).

- tools/serve: collab-cloud fixture (tools-serve start collab-cloud, :18096),
  team-isolated, monotonic seq, idempotent-by-id, bearer OD_COLLAB_CLOUD_TOKEN.
- daemon: env-built client (null when OD_COLLAB_CLOUD_URL unset) + a service that
  registers the member, pushes on comment create, and polls+merges inbound
  comments (INSERT OR IGNORE by author-daemon id; re-homed onto the receiver's
  local conversation). Kept out of runtime.ts to avoid colliding with #5383.
- /collab/status gains ownerDisplayName/ownerRole and GET /api/workspace/members
  exposes the directory, so the web can render comment authors + a named
  read-only banner. STUB: real name source is B's roster; degrades to no-op
  off-team / unconfigured.

* feat(web): show comment authors + named read-only banner + live comment refresh

Wire the web to the collab-cloud backend so shared-project collaboration reads as
a team, not opaque ids:
- useTeamMembers() resolves the collab-cloud member directory (memberId →
  {displayName, role}); comment cards render an avatar (initial + hashed color) +
  'displayName · Role', matching the demo. Unresolved authors fall back to the
  label-only card.
- The read-only banner now names the owner: workspace.readonlyNoticeBy ('这是
  {owner} 创建的共享项目…') when /collab/status carries ownerDisplayName, else the
  name-less fallback. New i18n key across all 19 locales + types.
- The comment panel polls refreshPreviewComments every 5s (+ on tab focus) for a
  live team member, so comments synced in from another daemon appear without a
  reload; in-flight composer drafts are untouched.

* feat(daemon): auto-publish owner edits + report hub head so members sync files

The publish mechanism worked (publish->hub->pull moves bytes) but nothing
triggered it on a real edit — reportChange/requestPublish were only wired to a
demo view's buttons, so an owner editing a shared project's files never published
and members never saw the change.

- collab-publish-watcher: subscribes to file-change events for the projects THIS
  daemon's member owns + has shared, coalescing every edit into a debounced
  publish through the scheduler. Read-only gate (team-shared AND owner === me)
  means a member's pulled copy is never watched, so an inbound pull can't loop
  into a publish and a member can't publish edits to someone else's project.
- runtime.publishedHead(): reads the hub's published ref (E's syncLatest), and
  /collab/status now reports that instead of this daemon's in-memory counter — so
  a member (who never published) sees the owner's latest version across daemons +
  restarts and knows when to pull.
Kept out of runtime.ts internals to avoid colliding with #5383.

* feat(web): auto-pull the published head into a member's read-only copy

Completes the file-sync link: the daemon now auto-publishes an owner's edits and
reports the hub head on /collab/status, so a viewing member just needs to pull
when the head advances. Add CollabClient.pull() (POST /collab/pull), expose it on
useCollab, and in useProjectCollab auto-pull when viewerOnly && publishedVersion >
the pulled cursor (in-flight guard, advance-on-success only, cursor reset on
projectId change). The owner never pulls (gated on viewerOnly). The pull writes
the new bytes into the member's project dir, and the existing live-reload chain
(project file-events SSE -> iframe ?v=mtime cache-bust) refreshes the FileViewer
in place — no extra reload wiring, active tab preserved.

* fix(daemon): keep the owner editable after auto-publish (derive owner when null)

The file-sync watcher sets an owner's in-memory syncState to 'synced' on publish,
but the owner-id derive was gated on syncState==='local_only', so after a publish
(or a restart that cleared the in-memory owners map) /collab/status returned a
null ownerMemberId. The owner's own client then failed its isOwner check and went
read-only on its OWN project. Derive the owner from the hub whenever ownerMemberId
is null (not only on local_only), so the owner resolves to themselves (isOwner ->
editable) while a non-owner member stays read-only.

* feat(web): gate comment edit/delete/send-to-agent by author + project owner

Per the product model (2026-07-09): only a comment's author can edit their own
comment; delete + send-to-agent are the author OR the project owner; other members
can do neither to someone else's comment. Expose isOwner on useProjectCollab
(ownerMemberId === my member id, fail-closed), and in the comment popover/side-dock
compute per-comment canEdit=authoredByMe, canDelete=canSend=authoredByMe||isOwner.
Hide the edit affordance (read-only note) for non-authors, the delete button for
non-author non-owners, and scope send-to-agent + batch-select to sendable comments.
A null author (create flow / off-team / legacy row) counts as mine, so single-user
and off-team behavior is unchanged; author-based gate is independent of the
file-write viewerOnly.

* fix(collab): comment sync — multi-author per element, edit/delete propagation, permissions

Fixes the comment-sync bugs found in testing:
- Same-element collision (麻薯's comment overwrote 小组员's): preview_comments unique
  key now includes author_member_id, so two members' comments on one element
  coexist (NULL-safe for single-user). Idempotent DROP/CREATE/INSERT-SELECT
  migration mirroring the existing slide_key one.
- Edit/delete were create-only append: mergeSyncedPreviewComment is now
  UPSERT-by-updatedAt + tombstone (deleted flag); comments.ts fires
  onCommentUpdated/onCommentDeleted, server pushes edits + deletions to the cloud;
  the fixture upserts by id at a fresh head seq so pollers re-pull edits.
- Permissions (product model): status-change + delete 403 unless caller is the
  author OR the project owner (owner resolved server-side; hot-path short-circuit
  when caller===author); edit is structurally author-only via the author-keyed
  upsert; anchor stays a local drift-ladder read-back (ungated, not synced).
- Fixture gains opt-in JSON persistence via OD_COLLAB_CLOUD_STORE so demo comment
  data survives a fixture restart.

* fix(collab): exclude .file-versions from the member mirror on publish

A shared project's collab publish packs the whole project dir via packTree
(resource-drive.ts), including the author's .file-versions/ history — so members
pulled the version history into their mirror, violating C spec §779 (成员 mirror
无历史) + invariant ① (成员只看最新). Add an optional exclude predicate to
packTree (kept kind-agnostic) and have the collab publish adapter skip
.file-versions, .live-artifacts, and .od-skills (author-only / daemon-internal
dirs), so the member mirror carries only the latest content. Normal content
(index.html, assets/) is untouched. E2e test drives publish() against a seeded
temp project + fake hub and asserts the excluded dirs never reach the manifest.

* fix(web): open an import dialog from the extensions 新增 button instead of jumping home

The ExtensionsMarketplace 新增 button called onCreatePlugin → startPluginAuthoring,
which seeded a plugin-authoring prompt and navigated to Home — so 新增 read as a
jump-to-home, not an add entry point. Port the demo's create/import dialog
(新增 Plugin/Skill: Plugin/Skill tabs, 从链接导入 URL + 导入并上传, 上传本地文件夹 +
选择文件夹 + 上传专家套件) and open it in place. Wire real daemon paths:
installPluginSource(url), uploadPluginFolder(webkitdirectory), and Skill folder
import (SKILL.md frontmatter → POST /api/skills/import); skill-import-from-URL has
no endpoint yet (UI shell + TODO). Inline Chinese demo copy, no new i18n keys.

* feat(web): align FileViewer top chrome to demo — 历史版本 button + unified share menu

Move the version-history entry to a prominent top-right 历史版本 chrome button
(before share), gated versioningAvailable && !viewerOnly so read-only members
never see the version panel (spec invariant ①, version-history bug #2) — it opens
the existing FileVersionManagerModal. Merge the plain 分享 + orange 下载 buttons
into one chrome-action-unified menu with 分享/导出/发送到... tabs (download folded
into 导出), disabled for members. Reuses the existing unified-menu CSS + real share
content (no demo mock). New i18n key fileViewer.versions.entryFull across 19 locales.

* feat(web): open the upgrade plan dialog from the credits 升级 button

The 升级 button called checkout directly (null when A#660 absent → nothing). Port
demo's InsufficientCreditsDialog (积分已用尽 + 个人版 Plus/Pro/Max/团队版 tiers +
年付/月付 + 确认支付并升级) verbatim; 升级 now opens it, and 确认支付并升级 calls the
existing /api/workspace/billing/checkout (opens Stripe on a URL, stays open on null).

* feat(web): rich recent-project cards — creator name + shared badge (demo)

Port the demo's richer RecentProjectsStrip: each card shows its creator (resolved
from the collab-cloud member directory — 李娜创建/我创建, not a demo mock) plus the
shared badge, matching the demo home grid. EntryShell/HomeView thread the data.

* feat(web): share-tab visibility selector + disabled affordance for non-author comments

- Port the demo's shareAccess visibility selector into the HtmlViewer unified share
  menu's 分享 tab: the two chrome-share-card blocks (在工作空间中分享项目 with a
  私有/工作空间 dropdown, + 发布单个文件给所有人 with publish/copy-link), above the
  existing share-link/deploy/social content. shareAccess is UI-only
  (document.body.dataset.artifactShareAccess); reuses existing shell.css classes,
  no new i18n. (The selector was already in ReactComponentViewer; HtmlViewer lacked
  it — the top-bar unify used the deploy content only.)
- Non-author comment note now reads as disabled: composer-note--readonly adds
  cursor:not-allowed + user-select:none + a muted fill while keeping readOnly (text
  stays fully legible), instead of a plain selectable read-only textarea.

* fix(web): keep the credits 升级 button dark on hover

The global button:hover rule lightened the button's fill to near-white, and
.credits-panel__upgrade:hover only set filter, so the dark button went all-white
(white text on white). Pin background:#1a1a1a + color:#fff on hover so it stays a
dark button with a subtle brightness lift.

* Implement Vela-backed team collaboration

* Polish team workspace sharing UI

* Fix team workspace CI regressions

* Fix collab workspace UI regressions

* Wire project workspace sharing to Vela catalog

* Respect AMR_HOME for Vela session reads

* Honor VELA_PROFILE for Vela session selection

* Fix Vela-backed shared project opening

* Ensure team shares publish files before catalog

* Hide unsynced Vela team project entries

* Fix upgrade dialog credit state copy

* Stabilize design-system workspace tests

* Use Vela team billing catalog for upgrades

* Align workspace share panel with demo

* Preserve team project card metadata

* fix: align team share chrome with demo

* fix: remove duplicate handoff header action

* fix: align project grid card sizing

* fix: align signed-out entry rail

* fix: align entry rail footer

* fix: align home recent layout with demo

* fix: preserve repeated collab comments

* fix: avoid republishing shared team projects

* fix: show unshare action for team projects

* Align billing upgrade with Vela Web

* Address collab invite review gaps

* Align home recent layout with team demo

* Fix shared project pull resource lookup

* Make home template rail collapsible

* Hide entry marketing topbar on project lists

* Refine account menu language picker

* Align settings general tab and language flyout

* fix collab review safety gates

* test collab locked owner pull gate

* fix web tests for workspace shell updates

* test: align e2e flows with workspace shell

* test: open settings reliably in visual suite

* fix: handle collab review blockers

* fix: add workspace collab cli parity

* fix: fail closed while workspace context loads

---------

Co-authored-by: hanyuanxi <fedbaby4l66@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: lefarcen <ontf116@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk/medium Medium risk: regular code changes size/L PR changes 300-700 lines type/feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants