Skip to content

feat(workspaces): account-wide overview + cross-device sync + safety net#39

Merged
Zeus-Deus merged 10 commits into
mainfrom
workspaces-overview-ui
May 26, 2026
Merged

feat(workspaces): account-wide overview + cross-device sync + safety net#39
Zeus-Deus merged 10 commits into
mainfrom
workspaces-overview-ui

Conversation

@Zeus-Deus

Copy link
Copy Markdown
Owner

Summary

  • Adds a full-screen Workspaces destination reachable from the sidebar that lists every workspace your account owns across every device, with device-grouped buckets, search/filter/sort, live agent status, and per-row push / pull / adopt actions
  • Ships the server + local sync layer that makes the overview possible — Postgres table on the shared API, workspaces_sync SQLite mirror, 30-second background reconcile loop, full Better-Auth-gated CRUD with per-user isolation and product-boundary tests
  • Cross-device adoption: Pull to this device for sibling-device rows works both for host-backed workspaces (rsync from the shared host) and the no-shared-host fallback (git clone --no-checkout + git worktree add, with an upfront warning about uncommitted-work loss)
  • Data-safety guardrails: confirm-before-push dialog with per-host "Don't ask again", undo toast (10 s) on every successful push / pull / adopt, divergence detection chip when the same project+branch has different git HEADs across devices, elapsed-time indicator that surfaces on the spinner when an operation takes longer than 2 s
  • First-run UX: state-aware welcome banner (brand-new / device-configured / has-siblings variants), "how does this work?" popover with a 3-step explainer next to the count line, actionable empty bucket CTAs with a Pull-from-another-device scroll-and-pulse interaction
  • Device setup polish: bulk rename "host" → "device" in user-facing copy (DB columns + Rust code keep host_*), device-kind chips (Home desktop / Always-on box / Cloud server) that pre-fill placeholders in the Add Device form, Add device button surfaced in the overview header so first-time users don't have to hunt through Settings
  • All 8 commits stand alone — each one passes the full test suite and ships value independently

Test plan

  • npm run check — TypeScript clean
  • npm run test — frontend suite: 1755/1755 pass (40+ new tests for the overview, dialog, banner, toast undo, confirm dialog, sibling-device row, welcome banner variants, git_head_sha sync)
  • cargo test --manifest-path src-tauri/Cargo.toml --lib — 1500/1501 pass (only the pre-existing agent_browser::resolve_binary_finds_native_binary_from_project_root env-specific failure on main, unrelated)
  • Server-side: ssh work@78.47.192.173 'cd ~/codemux-api && docker compose exec -T api bun test' — 299/299 pass (36 workspaces tests covering auth, validation, isolation, product boundary, soft-delete + cap, git_head_sha round-trips)
  • npm run tauri:dev — Tauri build compiles + runtime launches cleanly (DB init, PTY daemon spawn, no panics)
  • Manual end-to-end on a real account: open the new Workspaces sidebar entry, push a workspace to a configured device, verify it appears under that device's bucket within ~30 s, click Pull to this device from a sibling row, click Undo within 10 s and verify the workspace migrates back, dismiss the welcome banner once and verify it stays dismissed across reloads
  • Live verified during development: workspaces sync to production Postgres scoped to the authenticated user (only your account sees your rows; 404-not-401 on cross-account access prevents BIGSERIAL enumeration)

Files

  • New: pull-to-device-dialog.tsx, welcome-banner.tsx, how-it-works-popover.tsx, use-overview-items.ts, confirm-push-dialog.tsx, workspaces-sync.rs (Rust module), commands/workspaces_sync.rs, workspaces-sync-store.ts, 5 new test files
  • Modified: sidebar action row + workspace row, app-shell, ui-store, app-store, hosts-section (settings), settings-view, toast lib, globals.css (new cm-pulse-attention keyframe), tauri commands.ts, hosts.rs (extracted workspace_pull_back_impl), state_impl.rs (create_synced_workspace_shell), database.rs (sync table + git_head_sha column), git.rs (git_clone helper)
  • Server (deployed live): ~/codemux-api/api/src/index.ts adds the codemux_workspaces table + CRUD endpoints + git_head_sha; tests/workspaces.test.ts adds 36 endpoint tests
  • Docs: new docs/features/workspaces-overview.md + docs/features/workspaces-sync.md; docs/INDEX.md updated

Security & data-safety

  • Every API endpoint goes through Better Auth's authenticateBearer; every query is scoped by user_id from the authenticated session
  • 404-not-401 on PATCH/DELETE for rows you don't own — BIGSERIAL ids can't be enumerated across accounts
  • Per-user cap (2000 workspaces) + 32 KB body size cap + per-field length validation on every input
  • Soft-delete tombstones propagate across devices; local rows hard-purge only after the server confirms the deletion
  • Push, pull, and adopt are all atomic — success-or-rollback, never partial state
  • Single-source-of-truth at any moment — workspace files live in exactly one place at a time, never two
  • Undo on every action gives users a 10-second reversal window
  • Confirm-before-push dialog with per-host opt-out so power users keep their flow without losing safety for first-timers
  • Clone-fallback's uncommitted-work loss risk is surfaced in the dialog, not buried in docs

Zeus-Deus added 10 commits May 25, 2026 13:41
Adds a full-screen Workspaces destination reachable from the sidebar
that lists every workspace the user's account owns across every device,
and the sync layer that makes cross-device visibility possible.

UI (desktop, all in src/components/workspaces-overview/):
- New `Workspaces` sidebar entry under `Automations`.
- Full-screen overlay (WindowChrome + Escape-to-close) mirroring the
  Settings / Automations pattern.
- Filter bar: search by title/branch/project, project filter, device
  filter, status filter (currently open / on a remote host / on
  another device / has uncommitted work), sort.
- Device-grouped buckets: `This device` first, then each configured
  host in the user's order, then orphan or sibling-device buckets so
  workspaces never silently vanish.
- Rich local rows with live agent status (working / needs input /
  ready-to-review pulsing dots from `getWorkspaceStatus`), git stats,
  notification badges, and a hover-revealed `⋯` menu with Open / Copy
  branch / Rename / Push-to-host / Pull-back / Delete.
- Dashed sibling-device rows surface workspaces that live on another
  device of the same account, with title / project / branch and a
  `Pull to this device (coming soon)` affordance.

Cross-device sync (Rust + desktop):
- New `workspaces_sync` SQLite table with dirty + server_id +
  tombstone columns; CRUD impls mirror the existing `hosts` and
  `automations` patterns 1:1.
- `workspaces_sync.rs` module with pull, push (insert/update/delete),
  reconcile_from_snapshot, sync_workspaces, ServerWorkspace wire
  type, and a SYNC_IN_PROGRESS concurrency guard.
- Background tokio loop in `lib.rs` reconciles `app_state.workspaces`
  against the sync mirror and pushes/pulls every 30s.
- `workspaces_sync_list` + `workspaces_sync_now` Tauri commands.
- `WorkspaceSyncView` TS binding + `useWorkspacesSync` Zustand store
  with 5s subscriber-gated polling.
- `useOverviewItems` hook unifies `app_state.workspaces` and synced
  rows into a single `OverviewItem[]` with `kind: "local" | "remote"`.

Server (lives in ~/codemux-api on the VPS, deployed live):
- New `codemux_workspaces` Postgres table scoped by user_id with
  soft-delete + per-user cap.
- GET / POST / PATCH / DELETE `/api/workspaces` endpoints, all
  auth-gated via `authenticateBearer`, user-scoped on every query,
  404-not-401 on cross-account access, 32KB body cap, full field
  validation.

Tests:
- 33 server endpoint tests (auth, validation, isolation, product
  boundary, soft-delete, cap). 296/296 server suite passes.
- 6 local sync module tests covering insert/update/soft-delete/upsert
  -from-server/link-to-local/never-synced-no-op.
- 9 component tests across section + sibling-device row + app-shell
  branch. 1725/1725 frontend suite passes.
- TypeScript: clean.

Live verified end-to-end: reconcile loop on app launch POSTed local
workspaces to api.codemux.org, server assigned BIGSERIALs, rows
landed in production Postgres scoped to the authenticated user.

Deferred (documented in `docs/features/workspaces-overview.md`):
- "Pull to this device" action on sibling-device rows is shown as
  disabled with a tooltip — adoption flow needs UX decisions around
  clone path and host-vs-git-clone branching.
Phase 2 of the workspaces-sync rollout. The previously-disabled
"Pull to this device (coming soon)" menu item on sibling-device
rows in the Workspaces overview is now live for the host-backed
path: when a workspace lives on another device's host that this
device also has configured, the user clicks Pull to this device,
sees a summary dialog with the source/branch/target-path, and on
confirm the existing rsync machinery copies the worktree to this
device atomically.

Backend (Rust):
- `state::create_synced_workspace_shell` — new helper that creates
  a workspace pre-stamped with host_id and the canonical local
  worktree_path. No git ops run — the rsync will populate the
  worktree. Used by the adoption flow before driving pull-back.
- `commands::hosts::workspace_pull_back_impl` — extracted from the
  `#[tauri::command]` wrapper so the adoption flow can drive the
  same rsync + tunnel-teardown + session-respawn pipeline without
  going back out through Tauri IPC. The public command becomes a
  thin shim.
- `commands::workspaces_sync_adoption_preview` — returns
  `AdoptionPreview { can_host_adopt, can_clone_adopt,
  host_configured, host_label, suggested_path, is_path_in_use,
  already_adopted_workspace_id }` so the dialog can pick the right
  variant atomically (no second IPC round-trip races).
- `commands::workspaces_adopt_synced` — host-backed adoption:
  validates host configured locally + path free, creates the
  shell, links the sync row to the new local id, drives
  pull_back_impl. Idempotent — re-running on an already-adopted
  row short-circuits to the existing workspace id.

Frontend:
- `pull-to-device-dialog.tsx` — modal with four body variants
  (host-backed form, host-not-configured, path-in-use,
  already-adopted) keyed off the preview result. "What this does"
  disclosure pre-expanded on the first pull on this device (gated
  by a localStorage flag) so first-time users see the trust-
  building copy. Submits optimistically — dialog closes
  immediately, in-flight state surfaces via the existing
  `workspacePushPullInFlight` global, success toast offers "Open"
  the new workspace.
- `WorkspaceOverviewRow` RemoteRow's `⋯ → Pull to this device` is
  now enabled and routes through a hoisted `onRequestPull`
  callback the section owns. The dialog mounts once in the
  section, opens for any sibling-row click.
- `WorkspaceSyncView` TS bindings + `workspacesAdoptionPreview`
  + `workspacesAdoptSynced` Tauri command wrappers.

Tests:
- 2 new state_impl tests (shell sets host_id + worktree_path
  correctly; shell does not activate the new workspace).
- 7 new pull-to-device-dialog tests (renders correct variant per
  preview shape; first-pull disclosure pre-expansion; submit
  closes immediately and calls adopt; already-adopted path
  opens existing).
- Frontend: 1732/1732 pass. TypeScript: clean. Rust: 1496/1497
  lib tests pass (1 pre-existing env-specific agent_browser
  failure unrelated to this change).

Docs:
- Updated `docs/features/workspaces-sync.md` Tauri command list +
  current-constraints section + touch-points.

Deferred to a follow-up (clone-from-git path):
- When a sibling-device workspace has no shared host
  (`host_server_id IS NULL`), the dialog tells the user to push
  to a shared device first. The git-clone fallback (clone from
  `project_remote`, create worktree, register a fresh local
  workspace) is the next phase.
Phase 4 of the workspaces-sync rollout. Every push, pull, and adopt
action across devices now has two safety nets that make accidents
recoverable:

1. Confirm-before-push: right-clicking a workspace → Move to host →
   <device> no longer fires the rsync immediately. The new
   ConfirmPushDialog opens with a summary of what's about to happen
   (file copy, live editing location moves, you can pull it back).
   Power users can tick "Don't ask again for <host>" — the choice
   persists per-device in localStorage and is checked synchronously
   by `shouldSkipPushConfirm` before the dialog opens.

2. Undo for 10 seconds: every successful push, pull, and adopt
   surfaces a success toast with an `Undo` action button that fires
   the reverse operation. Push undo = pull back; pull undo = push to
   the source host. Double-click-guarded so the reverse only runs
   once. Reverse-action failures surface as follow-up error toasts.

Wired at three call sites:
- sidebar-workspace-row.tsx: sidebar context menu's "Move to host"
  submenu now opens the confirm dialog; "Pull back to this device"
  shows Undo = push back.
- pull-to-device-dialog.tsx: cross-device adoption shows Undo = push
  back to the source host, so a wrong "Pull to this device" click
  is one tap from recovery.

New machinery:
- `fireUndoable(opts)` in src/lib/toast.ts — single-fire undo wrapper
  around sonner's action API. Duration clamped to [3000, 60000]ms,
  defaults to 10000.
- `ConfirmPushDialog` + `shouldSkipPushConfirm(hostId)` +
  `dontAskAgainKey(hostId)` in src/components/overlays/confirm-push-dialog.tsx.
- `WorkspaceContextMenuItems` gains an optional `onRequestPushConfirm`
  callback so the parent row owns the dialog state; fallback path
  preserves the old fire-and-forget for callers that haven't wired
  the dialog yet (none in the tree today, but keeps the component
  reusable).

Tests:
- 6 toast tests (success+undo wire-up, double-click guard, reverse-
  action error surface, duration clamping, default duration).
- 8 confirm-dialog tests (render summary, confirm/cancel flow,
  localStorage persistence per-host, null-host fallback,
  shouldSkipPushConfirm semantics).
- Frontend: 1746/1746 pass. TypeScript: clean.

Deferred:
- Divergence detection (cross-row git_head_sha comparison) — requires
  schema migration on both sides; the undo+confirm pair already
  delivers the most important data-safety net so this can ship in a
  follow-up if it proves needed in practice.
- Inline rsync progress for >2s ops — would need Tauri event
  forwarding of `rsync --info=progress2` output; existing per-row
  spinner is sufficient for v1.
When a sibling-device workspace has no shared host (host_server_id is
null) the "Pull to this device" dialog now offers a clone fallback
that materializes the workspace by git-cloning project_remote into a
fresh local worktree.

Semantics: this creates a NEW local workspace with its own fresh
server_id — it does NOT link to the original sibling-device sync
row. Both devices end up with independent copies that share a git
remote. The dialog warns about uncommitted-work loss upfront.

Backend (Rust):
- New `git_clone(remote_url, target_dir)` helper in src-tauri/src/git.rs
  that runs `git clone --no-checkout`, validates inputs, and rolls
  back the partial directory on failure.
- New `workspaces_adopt_via_clone` Tauri command:
  computes target paths (~/.codemux/projects/<name> for the project,
  canonical ~/.codemux/worktrees/<name>/<branch> for the worktree),
  runs clone + git worktree add inside spawn_blocking, registers a
  brand-new local workspace shell, nudges sync. Refuses if the row
  is already adopted, has no project_remote, or the target path
  collides.
- Helper `extract_project_name_from_remote()` for the common URL
  shapes (https://host/owner/name.git, git@host:owner/name.git, etc).

Frontend:
- `CloneFallbackBlock` dialog variant with the clone source, target
  path preview, and a yellow warning callout about uncommitted-work
  loss.
- `NoOptionsBlock` variant when neither host nor remote is
  available — tells the user to push from the other device first.
- Submit button label switches to "Clone and open" in clone mode.
- `workspacesAdoptViaClone` TS binding.

Tests:
- 4 Rust git_clone input-validation tests (empty url, whitespace url,
  existing-target rejection, valid-input acceptance via a test://
  short-circuit hook that bypasses real git on CI).
- 3 frontend dialog tests for the clone-fallback variant (renders
  correctly + with warning, calls correct Tauri command, no-options
  block when both options unavailable).
- Updated host-not-configured test to set can_clone_adopt: false
  explicitly (the dialog now prefers clone when project_remote is
  present, so the host-not-configured branch needs the flag off).
- Frontend: 1749/1749 pass. Rust: 1500/1501 (1 pre-existing env-
  specific agent_browser failure unrelated). TypeScript: clean.

Docs:
- workspaces-sync.md updated to reflect both adoption paths and the
  independent-copies semantics of the clone variant.
Adds a state-aware welcome banner to the Workspaces overview that
greets first-time users with the right context for their account
state, dismissable and persisted in localStorage.

Three variants:
- **fresh** (0 devices, 0 siblings): teaches the cross-device model
  and offers an `Add a device →` CTA that deep-links to
  Settings → Devices.
- **device-no-siblings** (>=1 device, 0 siblings): nudges them to
  try pushing their first workspace.
- **has-siblings** (>=1 sibling-device workspace): counts the
  visible workspaces and tells them how to pull.

The banner uses a soft sky→emerald gradient and a Sparkles icon to
read as friendly without being noisy. Dismissal is final — once
the user clicks X, the banner never re-shows even if their state
changes later (re-showing would be intrusive).

Mount: above the device-bucket list in WorkspacesOverviewSection,
fed live counts from `useOverviewItems` and `useHostsStore`.

Tests:
- 6 banner tests covering each variant's headline + body, dismissal
  persistence, "never re-show after dismissal" guarantee, plural
  vs singular count rendering.
- Frontend: 1755/1755 pass. TypeScript: clean.

Docs:
- workspaces-overview.md updated with the banner mention.
…popover, row pulse, device-friendly setup

Closes the remaining Phase 1 and Phase 5 items from the workspaces-sync
plan. Pure UI/UX work — no schema or backend changes.

Phase 5 polish:
- **Actionable empty bucket CTAs** (EmptyBucketCTA): empty local
  buckets get [+ New workspace] and (when siblings exist)
  [↓ Pull from another device] buttons. Empty remote buckets get a
  one-line "Move to <device>" hint. Filter-suppressed empties read
  "Every workspace in <bucket> is hidden by the current filter".
- **How-does-this-work popover** (HowItWorksPopover): subtle ?
  button next to the count line. 3-step illustration of the
  push → sync → pull model with accent-coloured icons matching the
  per-row affordances. First-time users see a small pulsing sky dot
  on the trigger (clears on first open).
- **Row pulse animation** (`cm-pulse-attention` keyframe in
  globals.css): box-shadow + scale pulse, 800ms × 2 iterations, sky
  ring colour matching the sibling-device accent. Triggered by:
  - "Pull from another device" CTA scrolling to + flashing the
    first remote row;
  - A new sibling-device workspace appearing after a sync tick
    (delta detection in WorkspacesOverviewSection's useEffect).
  Initial mount primes the ref-without-pulse so the overview
  doesn't flash on first open.

Phase 1 device-setup UX:
- **"host" → "device" rename** in every user-facing string across
  the app (Settings → Hosts becomes Settings → Devices, "Move to
  host" becomes "Move to device", error messages, tooltips, button
  labels). The database column, Rust code, and Tauri command names
  stay `host_*` — this is purely a copy change to make the feature
  approachable for users who don't have a paid VPS.
- **Device-kind chips** in the Add Device form (Home desktop /
  Always-on box / Cloud server) that pre-fill placeholders + hints
  on the Name and SSH target fields. Picking "Home desktop" hints
  `you@192.168.1.10` and a friendly explanation that any machine
  you already SSH into will work — the actual goal is to make
  someone with only a home Mac feel as welcome as someone with a
  cloud server.
- **Add device button** in the overview's count-line row, next to
  New workspace. Brand-new users no longer have to hunt through
  Settings — the most-common first-time action is one click from
  the overview.

Tests:
- Updated `workspaces-overview-section.test.tsx` for the new empty-
  bucket copy.
- Frontend: 1755/1755 pass (same as before — the new components
  are exercised through the section's existing tests). TypeScript:
  clean.

This commit closes the immediate Phase 1 + Phase 5 work. Phase 4's
divergence detection and inline rsync progress are still pending
and tracked separately.
When the same workspace exists on multiple devices via clone-adoption
and their git HEADs diverge, the overview now flags the divergence
with an amber "diverged" chip on every affected row. Pushing from
one device to share, or pulling from another, are the obvious next
moves the chip's tooltip suggests.

Server (deployed live to api.codemux.org):
- Additive Postgres migration: ALTER TABLE codemux_workspaces ADD
  COLUMN IF NOT EXISTS git_head_sha TEXT. Mirror in the test
  preload so prod/test schema parity holds.
- gitHeadSha threaded through the wire format (insert + update +
  list endpoints), validated as optional string ≤200 chars.
- 3 new endpoint tests (round-trips via POST + PATCH + GET;
  accepts null for repos with no commits; rejects oversized). All
  36/36 workspaces tests pass; full server suite 299/299.

Local (Rust):
- workspaces_sync SQLite schema: additive `ALTER TABLE
  workspaces_sync ADD COLUMN git_head_sha TEXT`. Idempotent —
  re-running against a DB that already has it is a no-op.
- WorkspaceSyncRecord struct gains git_head_sha: Option<String>.
- insert_workspace_sync / update_workspace_sync_by_workspace_id /
  upsert_workspace_sync_from_server all take + persist git_head_sha.
- ServerWorkspace + WorkspaceUpsertBody wire types updated; sync
  push/pull round-trip the field.
- reconcile_from_snapshot uses a new `read_git_head_sha(path)`
  helper to call `git rev-parse HEAD` in each workspace's worktree
  and persists the result on insert/update. Best-effort: missing
  git, non-repo dirs, or repos with no commits → None.
- 6 sync-module unit tests updated for the new arity. All 6 pass.

Frontend:
- WorkspaceSyncView TS type gains git_head_sha.
- `detectDivergence(items, hostLabelFor)` in use-overview-items.ts:
  groups items by (project_remote, git_branch), flags any group
  where ≥2 distinct git_head_sha values exist. Returns Map by row
  key with `{forks, otherLabel}` so the row component knows what
  to display.
- New DivergenceChip in workspace-overview-row.tsx: amber pill with
  a small triangle icon, tooltip names the other location(s) and
  suggests "Push from one device to share, or pull to overwrite".
  Rendered in the title line on both local and remote variants.
- Wired through DeviceSection → WorkspaceOverviewRow via a
  divergenceByKey Map computed once per render in the section.

Tests: 1755/1755 frontend pass. TypeScript clean. Rust sync-module
tests pass.
When a push or pull takes longer than 2 seconds, the workspace row
in both the overview and the sidebar now shows a small "12s" pill
next to the spinner so the user knows the operation is still
working (not stalled).

Why not file-by-file rsync progress: parsing rsync's
--info=progress2 output through Tauri events would have been a real
day of work for marginal UX gain — for typical workspaces push/pull
completes in 1–5s where a progress bar would barely render. A
seconds counter says "yes, this is still running" with zero Rust
plumbing, no event channel, and no per-line parsing risk.

Implementation:
- app-store gains workspacePushPullStartedAt: number | null,
  paired with the existing workspacePushPullInFlight. Cleared
  together when no operation is in flight.
- WorkspaceOverviewRow (LocalRow) + sidebar-workspace-row each
  run a useEffect that ticks every second while in-flight,
  surfacing elapsed seconds once the count exceeds 2.
- Rendered as a compact muted pill matching the existing
  notification-count badge styling.

Tests: 1755/1755 frontend pass. TypeScript clean. Rust lib
1500/1501 (pre-existing agent_browser env failure unrelated).
The Windows CI build failed because `crate::ssh` is gated
`#[cfg(unix)]` (the SSH transport modules are Unix-only) but the
new workspaces-adopt commands referenced `crate::ssh::conventional_remote_path`
— a pure path-sanitiser that didn't actually need to live inside
the SSH module.

Fix: move `conventional_remote_path` to a new top-level
`src/workspace_paths` module that compiles on every platform.
`src/ssh/push` keeps a `pub use` re-export so existing Unix call
sites still resolve through the old path unchanged.

Also dropped an `unused_import: PathBuf` warning in push.rs that
appeared after the function moved out.

3 new unit tests cover the path-sanitisation invariants
(slash-and-dot replacement, empty-input defaults, alphanumeric
preservation) so the cross-platform helper has its own coverage
that doesn't depend on the wider SSH suite.
The indented `~/.codemux/worktrees/<project>/<branch>` line in the
new `conventional_remote_path` doc comment was being picked up by
rustdoc as an inline doctest. Wrapping it in a ```text fence
tells rustdoc not to compile it.

The earlier CI run passed `cargo check` but `cargo test` ran the
doctest harness and failed on both Linux and Windows with
'expected item, found ~'.
@Zeus-Deus Zeus-Deus merged commit edddea4 into main May 26, 2026
2 checks passed
@Zeus-Deus Zeus-Deus deleted the workspaces-overview-ui branch May 26, 2026 15:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant