Team collaboration: daemon team-project catalog bridge - #5383
Team collaboration: daemon team-project catalog bridge#5383open-design-crew[bot] wants to merge 1 commit into
Conversation
|
简要总结:
|
mrcfps
left a comment
There was a problem hiding this comment.
@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.| canRename: canEdit, | ||
| canDelete: canEdit, | ||
| canDuplicate: canEdit, | ||
| canMoveToTeam: false, | ||
| canMoveToPersonal: canEdit, | ||
| canExport: canView, | ||
| canSendTo: canView, | ||
| canRestoreVersion: canEdit, |
There was a problem hiding this comment.
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.
… 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.
…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.
… 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>
Why
This finishes the D-lane backend bridge between local workspace project visibility and the Vela-owned team project catalog. The local
workspace_projectstable 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
apps/weborapps/desktop(including Electron menu bar)odsubcommand or flag, newtools-dev/tools-pack/tools-prflag, or newOD_*env var/api/workspaces/:workspaceId/projects; calls Vela/api/v1/team-projectsskills/,design-systems/,design-templates, orcraft/, or change to the skills protocolTRANSLATIONS.mdfor the locale workflow)package.json(dependenciesordevDependencies); workspace-packagepackage.jsonfiles are out of scope. Include a paragraph on what we get vs. what bytes we ship (seeCONTRIBUTING.md→ Code style)OD_RESOURCE_HUB_URLis configuredScreenshots
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 --noEmitcorepack pnpm --filter @open-design/daemon exec vitest run -c vitest.config.ts tests/routes/workspace-projects.test.tscorepack pnpm --filter @open-design/daemon exec vitest run -c vitest.config.ts tests/collab-sync-routes.test.tscorepack pnpm guard