Skip to content

feat(web): roam the personal channel order with the account - #260

Open
isaiahknight-va wants to merge 2 commits into
openclaw:mainfrom
isaiahknight-va:typ/roaming-channel-order
Open

isaiahknight-va wants to merge 2 commits into
openclaw:mainfrom
isaiahknight-va:typ/roaming-channel-order

Conversation

@isaiahknight-va

@isaiahknight-va isaiahknight-va commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Problem

The personal channel order (drag, keyboard, and touch "Move channel", shipped in 0.2.0) is saved only in the browser's localStorage, keyed by user and workspace. It syncs across tabs of one browser through the storage event, and no further: an order set on a desktop does not exist on the phone, and a fresh browser starts from the server's default ordering. There is no server surface for it at all.

Change

The sidebar channel order roams with the account, on the same rail appearance preferences already use.

  • GET /api/me returns sidebar_preferences.channel_order, an object keyed by workspace id whose value is that user's ordered channel ids. PATCH /api/me accepts sidebar_preferences: { channel_order: { "<workspace_id>": [ids] } }: a workspace key present replaces that workspace's list, omitted workspaces are unchanged, an empty array clears the order. New schemas SidebarPreferences and SidebarPreferencesPatch; the appearance and sidebar patches are independent of each other.
  • Server validation in the store layer, both databases: the caller must be a member of each workspace key (403 otherwise, a new ErrNotWorkspaceMember mapped beside the existing manager error); ids that are not channels of that workspace are dropped, never rejected, so a deleted channel can never wedge a save; archived channels keep their position; duplicates collapse to the first occurrence; 500 ids per workspace and 100 workspaces per patch. Storage is one row per (user, workspace) in user_sidebar_channel_order, cascading with workspace membership.
  • Client: apps/web/src/lib/channel-order.ts (type-only imports, the request function is injected) owns the merge and the write. localStorage stays the pre-paint cache and the offline fallback, the cross-tab storage event is unchanged, and parseChannelOrder is byte-identical. On load the account value wins and is written into the cache; a workspace reordered in this session keeps its local order until the next load (the analogue of the appearance revision guard). Every reorder writes locally first, then patches the account for that one workspace, debounced 400 ms and best effort (a failed patch logs and leaves the local order in place). Pending writes are flushed with keepalive on pagehide and when the document becomes hidden, so a reorder made just before closing the tab still roams.
  • Roaming is load-time, not realtime: a second device shows the order on its next load. Same-browser tabs still sync instantly through the storage event.

Tests

  • Store (both databases): round trip, workspace isolation, unknown ids dropped, empty clears, non-member 403, cap, membership cascade, independence from appearance preferences; normalizer and filter unit cases. Handler: PATCH shape, GET round trip, section independence both directions, 403.
  • Web unit: 12 cases on the merge rules, the debounce (two rapid reorders send one patch), best-effort failure, and the flush (latest order once, keepalive: true, empty timer map afterward).
  • E2E: new "a reordered sidebar roams to a second browser context" in tests/e2e/sidebar-channel-order.spec.ts; the existing chat reorder test now asserts the roamed values and polls /api/me before each reload (its previous assertions were passing on a race with the write); the "unavailable storage" test now asserts that a reorder still roams when localStorage is blocked. Both retitled to say what they prove.
  • Go coverage 86.6 percent against the 85 percent gate; full e2e suite 386 passed; pnpm typecheck, pnpm -r typecheck, lint, and format clean; embedded assets regenerated and reproducible. Positive controls: breaking the server-wins merge fails the new e2e; breaking the drop-unknown-ids rule fails four store tests.

Real behavior proof

Binaries built from exact upstream main (19e4c4e) and this branch's head, each driven by the same script in headless Chromium: context A creates a workspace with three channels and drags the last one to the top with the real move handle; context B is a brand-new browser context on the same account (empty localStorage) that loads the workspace once.

$ node verify-roaming-order.mjs parent-main ./clickclack-chorder-parent 18110 proof/   # built at 19e4c4e8 (upstream main)
[parent-main] context A before drag: aa-mu0egzz4, mm-mu0egzz4, zz-mu0egzz4
[parent-main] context A after drag:  zz-mu0egzz4, aa-mu0egzz4, mm-mu0egzz4
[parent-main] context B (fresh):     aa-mu0egzz4, mm-mu0egzz4, zz-mu0egzz4
[parent-main] /api/me sidebar_preferences.channel_order for this workspace: absent
[parent-main] verdict: FAIL (second context shows the default order)

$ node verify-roaming-order.mjs head ./clickclack-chorder-head 18111 proof/   # built at e5941b1b (this PR)
[head] context A before drag: aa-mu0eh2ve, mm-mu0eh2ve, zz-mu0eh2ve
[head] context A after drag:  zz-mu0eh2ve, aa-mu0eh2ve, mm-mu0eh2ve
[head] context B (fresh):     zz-mu0eh2ve, aa-mu0eh2ve, mm-mu0eh2ve
[head] /api/me sidebar_preferences.channel_order for this workspace: 3 ids
[head] verdict: PASS (order roamed)

Screenshots (context B after its first load):

Parent 19e4c4e This branch
second context shows the default order second context shows the roamed order

Full transcript and driver script: proof-terminal.txt, verify-roaming-order.mjs.

Running in production on a self-hosted instance since the day of filing.

Review follow-up (second commit)

The four synchronization findings from the first review, each closed with a test that fails when the fix is removed (transcript: positive-controls.txt):

  1. Cleared order was indistinguishable from never set. An explicit clear is now stored as a row with an empty list and returned by GET /api/me as [] for that workspace, so the client clears its cache instead of restoring the old cached order; rows are removed only by the membership cascade. Handler test asserts the raw JSON carries [], not null; e2e reorders, clears through the API, reloads, and sees the default order with an empty cache.
  2. Overlapping writes could land out of order. Sends are serialized per user and workspace: an in-flight request is never cancelled, and only the newest order queued behind it is sent when it settles (one in-flight write plus three further reorders produces exactly two requests, the second carrying the latest order). The pagehide flush joins the same queue instead of racing it; a rejected send does not block the next.
  3. A stale boot snapshot could replay over a cross-tab edit. An account snapshot now applies at most once per profile object and workspace (a fresh /api/me may apply again), and a storage event marks that workspace locally newer exactly as a local reorder does. Pinned by a unit test; the two-tab e2e passes but, stated plainly, does not fail when the guard is removed, because switching workspaces remounts the app and refetches /api/me on that path, so the unit test is the real regression guard and the e2e carries a comment saying so.
  4. Local orders beyond the 500-id roaming cap were truncated on reload. The merge now keeps the account list as the ordered prefix and appends the local ids it does not name, in local order; an empty account list still clears (a clear is an intent, not a truncation). Unit test with 600 ids; e2e creates 520 channels, reorders, reloads, and finds all 520 cached with the tail byte-identical (evidence-c-over-cap-preserved.txt).

Two behavior notes for the reviewer: after another tab writes the cache, this tab keeps that order for the rest of its session even against a newer order from a third device, until reload (the conservative direction); and a flush queued behind an in-flight send is lost if the page dies before the in-flight request settles, in which case the local order survives and re-roams on the next reorder.

Populated-database upgrade evidence. SQLite: a .backup copy of a live self-hosted database (495 messages, 16 users, 2 workspaces) boots under this head with every count unchanged and /api/me answering 200; a second, older populated snapshot shows the real upgrade path, 52 to 53 migrations, the new table created empty, counts unchanged (evidence-a-sqlite-upgrade.txt). PostgreSQL: the parent binary (19e4c4e) creates and populates a scratch database through the API, then this head starts against it, 38 to 39 migrations, 0036_user_sidebar_channel_order applied with 0 rows, messages, users, and workspaces unchanged, /api/me 200 (evidence-b-postgres-upgrade.txt). Only schema names and counts appear in the transcripts.

Gates after the follow-up: Go coverage 86.7 percent, web unit 94 passed, full e2e 389 passed, PostgreSQL store tests executed for real against a scratch database, typecheck, lint, and format clean.

Filed by Tater, AI COO agent at The Yummy Potato, LLC; operated and approved by @isaiahknight-va.

The sidebar channel order lived only in that browser's localStorage, so a
reorder on the desktop app was invisible on the laptop and on the phone, and
clearing site data lost it. Isaiah asked for the order he arranges once to
follow him: "It should work on every instance I'm on!"

Appearance preferences already roam this way, so the sidebar follows that
precedent: a sibling sidebar_preferences object on /api/me carrying
channel_order keyed by workspace id, one row per (user, workspace) in both
stores, and localStorage kept as the pre-paint cache and the offline fallback.
The account copy wins on load and is written back into the cache, and each
reorder writes locally first and then patches the account, debounced and best
effort, so drag, keyboard, and touch moves never wait on the network. A
reorder made just before the page goes away is flushed on pagehide with
keepalive, so it roams instead of dying with the tab.

The store validates in both databases: membership is required for every
workspace key, ids that are not channels of that workspace are dropped rather
than rejected so a deleted channel cannot wedge a save, repeated ids keep their
first position, and one workspace holds at most 500 ids.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@isaiahknight-va
isaiahknight-va requested a review from a team as a code owner September 13, 2026 22:49
@clawsweeper

clawsweeper Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

🦞👀
ClawSweeper picked this up.

Pull request received. I will update this pull request when review starts.

ClawSweeper review complete

ClawSweeper finished reviewing this revision. The review result is being finalized.

View the workflow run.

@clawsweeper clawsweeper Bot added P2 Normal priority bug or improvement with limited blast radius. merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. proof: sufficient Contributor real behavior proof is sufficient. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. labels Sep 13, 2026
@clawsweeper

clawsweeper Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed September 13, 2026, 7:39 PM ET / 23:39 UTC (Revision 2).

ClawSweeper review

What this changes

Saves personal sidebar channel order with the account across browsers, adding database storage, profile API fields, browser synchronization, documentation, and regression coverage.

Merge readiness

Ready for maintainer review

The contribution remains useful: main still stores channel order only locally. The follow-up resolves all four earlier findings, and the supplied runtime and upgrade evidence supports proceeding without another repair round.

Priority: P2
Reviewed head: 799f4feac6350370d5429c34cb2c4ab99aa061f4

Review scores

Measure Result What it means
Overall readiness 🐚 platinum hermit (4/6) A useful, coherent patch with strong runtime evidence and all four previous blockers addressed.
Proof confidence 🦞 diamond lobster (5/6) Sufficient (terminal): The inspected Chromium screenshots and terminal trace exercise real sidebar dragging, profile persistence, and a fresh second browser against main and the PR implementation. Follow-up browser evidence covers clears and the 500-ID boundary, while populated SQLite and PostgreSQL transcripts support upgrade safety; isolated sequencing tests remain supplemental.
Patch quality 🐚 platinum hermit (4/6) No actionable review findings were identified.

Verification

Check Result Evidence
Real behavior Verified Sufficient (terminal): The inspected Chromium screenshots and terminal trace exercise real sidebar dragging, profile persistence, and a fresh second browser against main and the PR implementation. Follow-up browser evidence covers clears and the 500-ID boundary, while populated SQLite and PostgreSQL transcripts support upgrade safety; isolated sequencing tests remain supplemental.
Evidence reviewed 7 items Repository policy and patch identity: Read the complete root AGENTS.md; no nested AGENTS.md or maintainer-notes directory was found. SQL schemas and queries accompany the generated storedb changes. The checkout matches the supplied original PR head, and the final working-tree check was clean.
Still absent from main and latest release: Inspected the pinned main profile handler and sidebar, plus the v0.5.0 sidebar. These retain browser-local ordering without sidebar account preferences. The bounded recent-PR search found no replacement implementing this capability.
Earlier synchronization findings addressed: Both stores now retain explicit empty orders and serialize them as arrays. The browser serializes sends per scope, applies each account snapshot once, marks cross-tab changes locally newer, and preserves the local tail beyond 500 IDs. Reviewed the corresponding unit, handler, store, and browser regressions against the earlier review. The cross-tab browser test’s limited sensitivity is expressly disclosed; the focused unit test supplies that regression guard.
Findings None None.
Security None None.

How this fits together

ClickClack’s sidebar turns a workspace’s channels and a user’s saved preferences into navigation order. The change connects browser reordering to the profile API and database so another device can restore that order.

flowchart LR
  A[User reorders channels] --> B[Browser cache]
  A --> C[Debounced write queue]
  C --> D[Profile API]
  D --> E[Membership check and database]
  E --> F[Account snapshot on next load]
  F --> B
  B --> G[Ordered sidebar]
Loading

Before merge

None.

Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Production and test growth Production +982/-48; tests +1841/-9 Production includes SQL and generated API/store code; the growth supports account persistence across both backends and browser synchronization, excluding docs and embedded assets.
Prior findings 4 addressed The follow-up repairs resets, send sequencing, stale snapshot reconciliation, and preservation beyond the roaming cap.

Technical review

Best possible solution:

Keep account-scoped ordering on the existing profile API, with responsive local rendering, explicit resets, preserved local tails, and documented best-effort synchronization.

Do we have a high-confidence way to reproduce the issue?

Not applicable as a bug reproduction: this adds account roaming, and the supplied before/after browser transcript demonstrates the existing limitation and the new behavior.

Is this the best way to solve the issue?

Yes. Extending the existing account-preference API is a coherent solution, and the follow-up addresses the previously identified synchronization defects without replacing the local interaction path.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning medium; reviewed against 19e4c4e8631e.

Labels

Label justifications:

  • P2: Cross-device sidebar preferences are a bounded usability improvement without an urgent runtime regression.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (terminal): The inspected Chromium screenshots and terminal trace exercise real sidebar dragging, profile persistence, and a fresh second browser against main and the PR implementation. Follow-up browser evidence covers clears and the 500-ID boundary, while populated SQLite and PostgreSQL transcripts support upgrade safety; isolated sequencing tests remain supplemental.
  • proof: sufficient: Contributor real behavior proof is sufficient. The inspected Chromium screenshots and terminal trace exercise real sidebar dragging, profile persistence, and a fresh second browser against main and the PR implementation. Follow-up browser evidence covers clears and the 500-ID boundary, while populated SQLite and PostgreSQL transcripts support upgrade safety; isolated sequencing tests remain supplemental.

Evidence

What I checked:

  • Repository policy and patch identity: Read the complete root AGENTS.md; no nested AGENTS.md or maintainer-notes directory was found. SQL schemas and queries accompany the generated storedb changes. The checkout matches the supplied original PR head, and the final working-tree check was clean. (AGENTS.md:1, 799f4feac635)
  • Still absent from main and latest release: Inspected the pinned main profile handler and sidebar, plus the v0.5.0 sidebar. These retain browser-local ordering without sidebar account preferences. The bounded recent-PR search found no replacement implementing this capability. (apps/web/src/components/navigation/Sidebar.svelte:104, 648202b79b2f)
  • Earlier synchronization findings addressed: Both stores now retain explicit empty orders and serialize them as arrays. The browser serializes sends per scope, applies each account snapshot once, marks cross-tab changes locally newer, and preserves the local tail beyond 500 IDs. Reviewed the corresponding unit, handler, store, and browser regressions against the earlier review. The cross-tab browser test’s limited sensitivity is expressly disclosed; the focused unit test supplies that regression guard. (apps/web/src/lib/channel-order.ts:208, 799f4feac635)
  • Real browser proof inspected: Inspected both prepared screenshots, the linked terminal transcript, and the driver as text without executing it. The real server and Chromium run shows a fresh second browser retaining the dragged order on e5941b1, while pinned main shows the default order; the profile response independently confirms persistence. Follow-up evidence shows reset/reload and 520-ID preservation through the production browser/API path. Sources: https://raw.githubusercontent.com/isaiahknight-va/clickclack/typ/roaming-channel-order-proof/proof-terminal.txt and https://raw.githubusercontent.com/isaiahknight-va/clickclack/typ/roaming-channel-order-proof/evidence-c-over-cap-preserved.txt. These are linked artifacts from the supplied body snapshot, whose sourceRevision is e53cf300f68cada942370165c8be15cf2019debf2c6c5137c57ce67b3d65e06e.
  • Populated upgrade evidence: Read the SQLite and PostgreSQL transcripts. The older populated SQLite snapshot advances from 52 to 53 migrations with 418 messages preserved; PostgreSQL advances from 38 to 39 migrations with its six messages preserved. Both create the new table empty and return HTTP 200 from /api/me. SQLite identifies e5941b1; PostgreSQL identifies that commit plus the follow-up working tree. The migration files are unchanged by the follow-up. Existing preference independence is additionally covered by both store suites and handler tests; the transcripts do not claim a value-by-value preference comparison.
  • Account isolation and additive persistence: The profile handler derives the user from the authenticated actor, retains the bot-token write rejection, and uses the existing CSRF-protected API helper. Both stores check workspace membership inside the transaction, use parameterized queries, and cascade preference rows when membership is deleted. Stored channel IDs affect presentation only; they do not grant channel access. No dependency, action, install-script, or permission changes were introduced. (apps/api/internal/store/sqlite/sidebar.go:39, 799f4feac635)

Likely related people:

  • Peter Steinberger: Suggested for follow-up; no historical authorship or introduction is verified. (role: unverified routing candidate; confidence: low)
  • Jacqueline Henriksen: Suggested for follow-up; no historical authorship or introduction is verified. (role: unverified routing candidate; confidence: low)
  • Shakker: Suggested for follow-up; no historical authorship or introduction is verified. (role: unverified routing candidate; confidence: low)

Rating scale

Score Internal tier Crab rank Meaning
6/6 S 🦀 challenger crab Exceptional readiness
5/6 A 🦞 diamond lobster Very strong readiness
4/6 B 🐚 platinum hermit Good normal PR; ordinary maintainer review
3/6 C 🦐 gold shrimp Useful, but confidence is limited
2/6 D 🦪 silver shellfish Proof or implementation needs work
1/6 F 🧂 unranked krab Not merge-ready
N/A NA 🌊 off-meta tidepool Rating does not apply

Overall follows the weaker of proof and patch quality.
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

Workflow

  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

History

Review history (1 earlier review cycle)
  • reviewed 2026-09-13T22:52:38.069Z sha e5941b1 :: blocked before merge. :: [P2] Preserve a distinguishable cleared order in account snapshots | [P2] Serialize account writes for each channel-order scope | [P1] Avoid replaying stale account snapshots over cross-tab edits | [P1] Retain local positions beyond the roaming limit

Review of the roaming sidebar order found four ways the account copy and the
browser copy could disagree. Each is closed here, with the test that catches it.

Preserve a distinguishable cleared order. Patching an empty list deleted the
row, so the next GET omitted the workspace, which reads exactly like a
workspace that never saved an order: the browser restored its cached order and
the clear came back undone on the next load. A clear now stores a row holding an
empty list and GET returns that workspace with an empty array, in both stores.
The row goes away only when the membership it hangs off does, which the existing
cascade test still pins. The now unused delete query is gone from both query
files and the generated code.

Serialize account writes per scope. The debounce launched its request without
waiting for the previous one, so a second reorder, or a pagehide flush, could put
two writes for one workspace in flight at once and let the older one land last.
Each scope now keeps one request in flight, and an order that arrives during a
send waits for it. Only the newest waiting order is sent, so three reorders
during one in-flight write make two requests, not three. A flush joins the same
queue, and a failed send releases the scope for the next order.

Avoid replaying stale account snapshots over cross-tab edits. An account
snapshot now applies at most once per loaded profile and workspace, so returning
to a workspace re-resolves from the cache instead of replaying a boot-time
snapshot over an order another tab has since saved. A fresh /api/me is a new
profile object and applies again. A storage event now also marks its workspace
locally newer, exactly as a local reorder does, including for a workspace the
tab is not currently showing.

Retain local positions beyond the roaming limit. The patch sends only the first
500 ids, and the reply replaced the whole local list, so on a device holding more
than that every position past the cap was discarded on the next load. The account
order now leads and the local ids it does not name follow in their local order.
An explicit clear, the empty list, still clears everything; it is an intent, not
a truncation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@clawsweeper clawsweeper Bot added rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. and removed status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. labels Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P2 Normal priority bug or improvement with limited blast radius. proof: sufficient Contributor real behavior proof is sufficient. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants