Skip to content

feat(app): sync web project sidebar across devices#18938

Open
thatdaveguy1 wants to merge 7 commits intoanomalyco:devfrom
thatdaveguy1:feat/sidebar-project-sync
Open

feat(app): sync web project sidebar across devices#18938
thatdaveguy1 wants to merge 7 commits intoanomalyco:devfrom
thatdaveguy1:feat/sidebar-project-sync

Conversation

@thatdaveguy1
Copy link

@thatdaveguy1 thatdaveguy1 commented Mar 24, 2026

Issue for this PR

Refs #13626

Alternative approach to #13628.

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

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:

  • syncs sidebar membership + order across fresh clients
  • migrates legacy localStorage once only when the server rail is empty and sidebar bootstrap succeeded
  • preserves existing server rail state instead of overwriting it with stale local data
  • avoids duplicate entries from path variants
  • keeps local-only UI state like selection, hover, and expanded state local

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" typecheck in packages/app
  • "/Users/davemini/.bun/bin/bun" typecheck in packages/opencode
  • PLAYWRIGHT_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.ts
  • manual two-client verification against the same server for open, close, reorder, migration when the server rail is empty, preserving existing server rail, and non-git directories staying distinct

Screenshots / recordings

  • Playwright screenshots/video were captured from the passing sidebar sync suite
  • HTML report: /tmp/opencode-lan-run/e2e-artifacts/playwright-report/index.html
  • Example artifacts:
    • /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.webm

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

…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
Copilot AI review requested due to automatic review settings March 24, 2026 11:46
@github-actions
Copy link
Contributor

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.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_sidebar table) + routes (/project/sidebar/*) that emit project.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.

Comment on lines +40 to +102
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
})
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +25 to +62
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
})
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +58
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()

Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +542 to 576
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(() => {})
})
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +471 to +477
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 })
}),
)
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 })
})
})

Copilot uses AI. Check for mistakes.
@thatdaveguy1
Copy link
Author

thatdaveguy1 commented Mar 24, 2026

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.

@thatdaveguy1
Copy link
Author

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.

@thatdaveguy1
Copy link
Author

Pushed the regression-fix follow-up in 790571a32.

This updates the branch to:

  • restore local fallback behavior for the sidebar while server sync bootstraps
  • isolate ordinary app e2e sidebar state from the shared server-backed rail
  • harden session model persistence coverage around semantic variant readiness instead of transient dropdown timing
  • remove provider-heavy prompt dependence from the session-model persistence suite
  • serialize the sidebar sync suite and run it in its own isolated backend/worker path
  • harden Windows DB cleanup retries in backend tests

Local verification is green:

  • bun typecheck in packages/app
  • bun typecheck in packages/opencode
  • bun test ./test/server/project-sidebar.test.ts in packages/opencode
  • bun script/e2e-local.ts -- e2e/projects/projects-switch.spec.ts e2e/projects/project-edit.spec.ts e2e/sidebar/sidebar-popover-actions.spec.ts e2e/session/session-model-persistence.spec.ts e2e/sidebar/sidebar-sync.spec.ts --repeat-each 2 in packages/app

That isolated e2e run passed as:

  • non-sync targeted suite: 18 passed
  • isolated sidebar-sync suite: 12 passed

CI should be rerunning on the updated branch now.

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.

3 participants