feat(app): sync web project sidebar across devices#18938
feat(app): sync web project sidebar across devices#18938thatdaveguy1 wants to merge 7 commits intoanomalyco:devfrom
Conversation
…recovery - gate one-time migration on globalSync.ready to prevent overwriting existing server rail - use Filesystem.resolve() for canonical path normalization (symlinks, Windows, trailing slashes) - remove dead createEffect that computed but never mutated state - apply mutation responses immediately to local store (open/close/reorder) - keep optimistic order during reorder until server responds; clear on failure - use language.t() for toast strings instead of hardcoded English - rename table sidebar → project_sidebar for upstream clarity - add path normalization tests (trailing slash, dedup) - rewrite e2e tests to prove server sync and migration safety
|
The following comment was made by an LLM, it may be inaccurate: Based on my search, I found one potential related PR: Related PR:
Why it's related: This PR appears to address server project syncing into the web client, which is closely related to PR #18938's goal of syncing the web project sidebar across devices. Both PRs involve syncing project lists/sidebar state, though #13628 focuses on syncing server projects into the client list while #18938 implements a more comprehensive server-backed rail system with device sync capabilities. However, note that #18938 explicitly closes issue #13626 (mentioned in the PR description), which is different from #13628. These appear to be separate but related efforts. |
There was a problem hiding this comment.
Pull request overview
Adds a server-backed “project sidebar rail” so the web UI sidebar membership + ordering are synced across clients (with one-time migration from legacy local storage), and wires the web app to consume/update this rail via SDK endpoints and global events.
Changes:
- Introduces server-side sidebar persistence (
project_sidebartable) + routes (/project/sidebar/*) that emitproject.sidebar.updated. - Updates the JS SDK types/client to support listing/open/close/reorder sidebar items and receiving the new event.
- Updates the app to bootstrap sidebar state from the server, migrate legacy local sidebar once, and sync drag-reorder + open/close across devices (plus new server/e2e coverage).
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/sdk/js/src/v2/gen/types.gen.ts | Adds SidebarItem, project.sidebar.updated event type, and sidebar API endpoint types. |
| packages/sdk/js/src/v2/gen/sdk.gen.ts | Adds project.sidebar SDK client (list/open/close/reorder) and accessor on Project. |
| packages/opencode/test/server/project-sidebar.test.ts | New server tests covering sidebar CRUD, ordering, normalization, and event emission. |
| packages/opencode/src/storage/schema.ts | Exposes ProjectSidebarTable in the storage schema barrel. |
| packages/opencode/src/server/routes/project.ts | Adds /project/sidebar, /open, /close, /reorder routes backed by Sidebar module. |
| packages/opencode/src/project/sidebar.ts | Implements sidebar list/open/close/reorder + emits project.sidebar.updated. |
| packages/opencode/src/project/sidebar.sql.ts | Defines the project_sidebar drizzle table. |
| packages/opencode/migration/20260323171438_sidebar/snapshot.json | Migration snapshot updated to include project_sidebar. |
| packages/opencode/migration/20260323171438_sidebar/migration.sql | Creates project_sidebar table in SQLite. |
| packages/app/src/pages/layout.tsx | Commits sidebar reorder to server on drag end; removes dev DebugBar usage. |
| packages/app/src/context/layout.tsx | Switches sidebar rendering source-of-truth to global synced rail; adds optimistic reorder + legacy migration logic; wires open/close/reorder to server. |
| packages/app/src/context/global-sync/event-reducer.ts | Handles project.sidebar.updated and updates sidebar store. |
| packages/app/src/context/global-sync/event-reducer.test.ts | Adds unit test for new sidebar-updated event reducer behavior. |
| packages/app/src/context/global-sync/bootstrap.ts | Bootstraps sidebar list from server and tracks sidebar_status. |
| packages/app/src/context/global-sync.tsx | Adds sidebar state to global store and plumbs setSidebar into global event handling. |
| packages/app/e2e/sidebar/sidebar-sync.spec.ts | New Playwright suite validating sync, reorder, migration, and dedupe behaviors across clients. |
| packages/app/e2e/fixtures.ts | Uses shared enableE2E helper during seeded storage setup. |
| packages/app/e2e/actions.ts | Adds enableE2E helper that configures window.__opencode_e2e + model localStorage. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export function open(worktree: string): Item[] { | ||
| const resolved = canonical(worktree) | ||
| return Database.use((db) => { | ||
| const existing = db | ||
| .select() | ||
| .from(ProjectSidebarTable) | ||
| .where(eq(ProjectSidebarTable.worktree, resolved)) | ||
| .get() | ||
| if (existing) return list() | ||
|
|
||
| const rows = db.select().from(ProjectSidebarTable).orderBy(ProjectSidebarTable.sort_order).all() | ||
| for (const row of rows) { | ||
| db.update(ProjectSidebarTable) | ||
| .set({ sort_order: row.sort_order + 1 }) | ||
| .where(eq(ProjectSidebarTable.worktree, row.worktree)) | ||
| .run() | ||
| } | ||
| db.insert(ProjectSidebarTable).values({ worktree: resolved, sort_order: 0 }).run() | ||
|
|
||
| const items = db.select().from(ProjectSidebarTable).orderBy(ProjectSidebarTable.sort_order).all() | ||
| emit(items) | ||
| return items | ||
| }) | ||
| } | ||
|
|
||
| export function close(worktree: string): Item[] { | ||
| const resolved = canonical(worktree) | ||
| return Database.use((db) => { | ||
| const removed = db | ||
| .delete(ProjectSidebarTable) | ||
| .where(eq(ProjectSidebarTable.worktree, resolved)) | ||
| .returning() | ||
| .get() | ||
| if (!removed) return list() | ||
|
|
||
| const rows = db.select().from(ProjectSidebarTable).orderBy(ProjectSidebarTable.sort_order).all() | ||
| for (let i = 0; i < rows.length; i++) { | ||
| if (rows[i].sort_order !== i) { | ||
| db.update(ProjectSidebarTable) | ||
| .set({ sort_order: i }) | ||
| .where(eq(ProjectSidebarTable.worktree, rows[i].worktree)) | ||
| .run() | ||
| } | ||
| } | ||
|
|
||
| const items = db.select().from(ProjectSidebarTable).orderBy(ProjectSidebarTable.sort_order).all() | ||
| emit(items) | ||
| return items | ||
| }) | ||
| } | ||
|
|
||
| export function reorder(worktrees: string[]): Item[] { | ||
| const resolved = [...new Set(worktrees.map(canonical))] | ||
| return Database.use((db) => { | ||
| db.delete(ProjectSidebarTable).run() | ||
| for (let i = 0; i < resolved.length; i++) { | ||
| db.insert(ProjectSidebarTable).values({ worktree: resolved[i], sort_order: i }).run() | ||
| } | ||
|
|
||
| const items = db.select().from(ProjectSidebarTable).orderBy(ProjectSidebarTable.sort_order).all() | ||
| emit(items) | ||
| return items | ||
| }) |
There was a problem hiding this comment.
Sidebar.open/close/reorder perform multi-statement writes (shifting sort_order, delete+insert loops) but are not wrapped in a DB transaction. If the process crashes mid-operation or concurrent requests interleave, the sidebar rail can be left in a partially-updated state. Please wrap each mutation in Database.transaction(...) so the update is atomic.
| function emit(items: Item[]) { | ||
| GlobalBus.emit("event", { | ||
| payload: { | ||
| type: Event.Updated.type, | ||
| properties: { items }, | ||
| }, | ||
| }) | ||
| } | ||
|
|
||
| export function list(): Item[] { | ||
| return Database.use((db) => | ||
| db.select().from(ProjectSidebarTable).orderBy(ProjectSidebarTable.sort_order).all(), | ||
| ) | ||
| } | ||
|
|
||
| export function open(worktree: string): Item[] { | ||
| const resolved = canonical(worktree) | ||
| return Database.use((db) => { | ||
| const existing = db | ||
| .select() | ||
| .from(ProjectSidebarTable) | ||
| .where(eq(ProjectSidebarTable.worktree, resolved)) | ||
| .get() | ||
| if (existing) return list() | ||
|
|
||
| const rows = db.select().from(ProjectSidebarTable).orderBy(ProjectSidebarTable.sort_order).all() | ||
| for (const row of rows) { | ||
| db.update(ProjectSidebarTable) | ||
| .set({ sort_order: row.sort_order + 1 }) | ||
| .where(eq(ProjectSidebarTable.worktree, row.worktree)) | ||
| .run() | ||
| } | ||
| db.insert(ProjectSidebarTable).values({ worktree: resolved, sort_order: 0 }).run() | ||
|
|
||
| const items = db.select().from(ProjectSidebarTable).orderBy(ProjectSidebarTable.sort_order).all() | ||
| emit(items) | ||
| return items | ||
| }) |
There was a problem hiding this comment.
Sidebar emits the "project.sidebar.updated" event from inside Database.use while still in the middle of the write flow. To avoid emitting an update if the DB work later fails/rolls back (and to match patterns elsewhere that defer side effects), emit after the transaction commits (e.g., compute/return items inside the transaction and call emit outside, or use Database.effect(...) from within a Database.transaction).
| const rows = db.select().from(ProjectSidebarTable).orderBy(ProjectSidebarTable.sort_order).all() | ||
| for (const row of rows) { | ||
| db.update(ProjectSidebarTable) | ||
| .set({ sort_order: row.sort_order + 1 }) | ||
| .where(eq(ProjectSidebarTable.worktree, row.worktree)) | ||
| .run() | ||
| } | ||
| db.insert(ProjectSidebarTable).values({ worktree: resolved, sort_order: 0 }).run() | ||
|
|
There was a problem hiding this comment.
Sidebar.open shifts existing rows by selecting all and then issuing one UPDATE per row. This is O(n) round-trips and can become noticeably slow with a larger rail. Consider a single bulk UPDATE (e.g., set sort_order = sort_order + 1) and then insert the new row, ideally within the same transaction.
| let migrated = false | ||
| createEffect(() => { | ||
| if (!globalSync.ready) return | ||
| if (globalSync.data.sidebar_status !== "ready") return | ||
| if (migrated) return | ||
| migrated = true | ||
|
|
||
| const key = `sidebar.migrated.${server.key}` | ||
| if (localStorage.getItem(key)) { | ||
| for (const item of globalSync.data.sidebar) globalSync.project.loadSessions(item.worktree) | ||
| return | ||
| } | ||
|
|
||
| if (globalSync.data.sidebar.length > 0) { | ||
| localStorage.setItem(key, "1") | ||
| for (const item of globalSync.data.sidebar) globalSync.project.loadSessions(item.worktree) | ||
| return | ||
| } | ||
|
|
||
| const legacy = server.projects.list() | ||
| if (legacy.length === 0) { | ||
| localStorage.setItem(key, "1") | ||
| return | ||
| } | ||
|
|
||
| const worktrees = [...new Set(legacy.map((p) => rootFor(p.worktree)))] | ||
| void globalSdk.client.project.sidebar | ||
| .reorder({ worktrees }) | ||
| .then((res) => { | ||
| localStorage.setItem(key, "1") | ||
| if (res.data) globalSync.set("sidebar", res.data) | ||
| for (const w of worktrees) globalSync.project.loadSessions(w) | ||
| }) | ||
| .catch(() => {}) | ||
| }) |
There was a problem hiding this comment.
The migration guard uses a module-level migrated boolean. This prevents the effect from re-running when server.key changes (user switches servers) and also prevents retry if the initial reorder request fails (the .catch(() => {}) leaves localStorage unset but migrated remains true). Consider scoping the guard to server.key (e.g., store lastMigratedServerKey) and only marking migrated after you either set the localStorage flag or definitively decide migration is not needed; also avoid swallowing the migration error silently.
| const enriched = createMemo(() => | ||
| sidebarWorktrees().map((worktree) => { | ||
| const key = workspaceKey(worktree) | ||
| const local = server.projects.list().find((x) => workspaceKey(x.worktree) === key) | ||
| return enrich({ worktree, expanded: local?.expanded ?? true }) | ||
| }), | ||
| ) |
There was a problem hiding this comment.
enriched builds sidebar projects by doing server.projects.list().find(...) for each sidebar worktree. That makes this computation O(n²) and re-runs on every reactive update. Consider precomputing a Map from workspaceKey(worktree) -> local project once per memo evaluation and then doing O(1) lookups inside the map.
| const enriched = createMemo(() => | |
| sidebarWorktrees().map((worktree) => { | |
| const key = workspaceKey(worktree) | |
| const local = server.projects.list().find((x) => workspaceKey(x.worktree) === key) | |
| return enrich({ worktree, expanded: local?.expanded ?? true }) | |
| }), | |
| ) | |
| const enriched = createMemo(() => { | |
| const projects = server.projects.list() | |
| const byWorkspaceKey = new Map<string, Project>() | |
| for (const project of projects) { | |
| byWorkspaceKey.set(workspaceKey(project.worktree), project) | |
| } | |
| return sidebarWorktrees().map((worktree) => { | |
| const key = workspaceKey(worktree) | |
| const local = byWorkspaceKey.get(key) | |
| return enrich({ worktree, expanded: local?.expanded ?? true }) | |
| }) | |
| }) |
|
Quick clarification on the overlap note: this is intended as an alternative approach to #13628, not a duplicate implementation. The key difference is scope and source of truth:
That keeps the sidebar as curated active state instead of turning it into project history. It also adds migration safety and fresh-context UI e2e coverage for open, close, reorder, and migration. |
|
Quick status update: I reproduced the post-PR regressions locally and have fixes staged locally for the app fallback path, e2e sidebar registration, and Windows DB cleanup. Targeted checks are green: app/opencode typecheck, backend sidebar tests, sidebar sync e2e, projects switch e2e, project edit e2e, sidebar popover e2e, and session model persistence e2e all pass when run in isolation against the shared LAN testbed. The remaining issue is e2e isolation: the sidebar sync spec mutates the shared server-backed sidebar rail, so in mixed multi-worker runs it can temporarily hide unrelated test projects and cause false failures. I am fixing that next so we do not push another red iteration. |
|
Pushed the regression-fix follow-up in This updates the branch to:
Local verification is green:
That isolated e2e run passed as:
CI should be rerunning on the updated branch now. |
Issue for this PR
Refs #13626
Alternative approach to #13628.
Type of change
What does this PR do?
This syncs the web project sidebar across clients using a server-backed, worktree-keyed rail.
It keeps the rail as curated active state instead of treating all known server projects as sidebar entries:
This differs from #13628 by syncing a dedicated sidebar rail, not hydrating the full server project list into every client.
It also turns off the web debug performance bar in the layout.
How did you verify your code works?
"/Users/davemini/.bun/bin/bun" test --preload ./happydom.ts ./src/context/global-sync/event-reducer.test.ts"/Users/davemini/.bun/bin/bun" test ./test/server/project-sidebar.test.ts"/Users/davemini/.bun/bin/bun" typecheckinpackages/app"/Users/davemini/.bun/bin/bun" typecheckinpackages/opencodePLAYWRIGHT_BASE_URL="http://127.0.0.1:4446" PLAYWRIGHT_SERVER_HOST="192.168.1.82" PLAYWRIGHT_SERVER_PORT="4098" "/Users/davemini/.bun/bin/bunx" playwright test e2e/sidebar/sidebar-sync.spec.tsScreenshots / recordings
/tmp/opencode-lan-run/e2e-artifacts/playwright-report/index.html/tmp/opencode-lan-run/e2e-artifacts/test-results/sidebar-sidebar-sync-sideb-6dc9f--syncs-to-a-fresh-client-UI-chromium/test-finished-1.png/tmp/opencode-lan-run/e2e-artifacts/test-results/sidebar-sidebar-sync-sideb-8619d--syncs-to-a-fresh-client-UI-chromium/test-finished-1.png/tmp/opencode-lan-run/e2e-artifacts/test-results/sidebar-sidebar-sync-sideb-9f56f--syncs-to-a-fresh-client-UI-chromium/test-finished-1.png/tmp/opencode-lan-run/e2e-artifacts/test-results/sidebar-sidebar-sync-sideb-3e478-rail-from-legacy-local-rail-chromium/video.webmChecklist