feat(workspaces): account-wide overview + cross-device sync + safety net#39
Merged
Conversation
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 ~'.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
workspaces_syncSQLite mirror, 30-second background reconcile loop, full Better-Auth-gated CRUD with per-user isolation and product-boundary testsgit clone --no-checkout+git worktree add, with an upfront warning about uncommitted-work loss)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 SettingsTest plan
npm run check— TypeScript cleannpm 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-existingagent_browser::resolve_binary_finds_native_binary_from_project_rootenv-specific failure on main, unrelated)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)Pull to this devicefrom a sibling row, clickUndowithin 10 s and verify the workspace migrates back, dismiss the welcome banner once and verify it stays dismissed across reloadsFiles
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 filescm-pulse-attentionkeyframe), tauri commands.ts, hosts.rs (extractedworkspace_pull_back_impl), state_impl.rs (create_synced_workspace_shell), database.rs (sync table + git_head_sha column), git.rs (git_clonehelper)~/codemux-api/api/src/index.tsadds thecodemux_workspacestable + CRUD endpoints + git_head_sha;tests/workspaces.test.tsadds 36 endpoint testsdocs/features/workspaces-overview.md+docs/features/workspaces-sync.md;docs/INDEX.mdupdatedSecurity & data-safety
authenticateBearer; every query is scoped byuser_idfrom the authenticated session