primeThreadActivation(event, thread.id)}
- onClick={() => activateThreadFromSidebarIntent(thread.id)}
+ onClick={(event) => {
+ if (
+ event.target instanceof Element &&
+ event.target.closest("[data-pinned-thread-drag-handle]") !== null
+ ) {
+ return;
+ }
+ activateThreadFromSidebarIntent(thread.id);
+ }}
onDoubleClick={(event) => {
event.preventDefault();
event.stopPropagation();
@@ -4111,6 +4214,12 @@ export default function Sidebar() {
}}
onPointerUp={(event) => handleThreadRenamePointerUp(event, thread.id)}
onKeyDown={(event) => {
+ if (
+ event.target instanceof Element &&
+ event.target.closest("[data-pinned-thread-drag-handle]") !== null
+ ) {
+ return;
+ }
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
activateThreadFromSidebarIntent(thread.id);
@@ -4125,6 +4234,19 @@ export default function Sidebar() {
}}
>
+
+
}
/>
-
- {pinnedThreads.map((thread) => renderPinnedThreadRow(thread))}
-
+
+ thread.id)}
+ strategy={verticalListSortingStrategy}
+ >
+
+ {pinnedThreads.map((thread) => (
+
+ {(dragHandleProps) => renderPinnedThreadRow(thread, dragHandleProps)}
+
+ ))}
+
+
+
>
) : (
@@ -5920,7 +6064,7 @@ export default function Sidebar() {
>
project.id)}
+ items={standardProjects.map((project) => project.id)}
strategy={verticalListSortingStrategy}
>
{standardProjects.map((project) => (
diff --git a/apps/web/src/lib/desktopProjectRecovery.test.ts b/apps/web/src/lib/desktopProjectRecovery.test.ts
index e66c5e716..da73eab5e 100644
--- a/apps/web/src/lib/desktopProjectRecovery.test.ts
+++ b/apps/web/src/lib/desktopProjectRecovery.test.ts
@@ -82,6 +82,7 @@ function makeThread(
function makeSnapshot(overrides: Partial = {}): OrchestrationReadModel {
return {
snapshotSequence: 1,
+ sidebarLayout: null,
updatedAt: "2026-04-20T08:00:00.000Z",
projects: [makeProject()],
threads: [makeThread()],
@@ -96,6 +97,7 @@ function makeShellSnapshot(
const thread = makeThread();
return {
snapshotSequence: 1,
+ sidebarLayout: null,
updatedAt: "2026-04-20T08:00:00.000Z",
projects: [
{
diff --git a/apps/web/src/lib/threadCreatePromotion.test.ts b/apps/web/src/lib/threadCreatePromotion.test.ts
index c562a34c6..758a18dac 100644
--- a/apps/web/src/lib/threadCreatePromotion.test.ts
+++ b/apps/web/src/lib/threadCreatePromotion.test.ts
@@ -94,6 +94,7 @@ describe("threadCreatePromotion", () => {
useComposerDraftStore.getState().setProjectDraftThreadId(projectId, threadId);
useStore.getState().syncServerShellSnapshot({
snapshotSequence: 1,
+ sidebarLayout: null,
projects: [
{
id: projectId,
diff --git a/apps/web/src/pinnedThreadsStore.test.ts b/apps/web/src/pinnedThreadsStore.test.ts
deleted file mode 100644
index 794eac825..000000000
--- a/apps/web/src/pinnedThreadsStore.test.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-// FILE: pinnedThreadsStore.test.ts
-// Purpose: Verifies the global pinned-thread store mutates ids predictably.
-// Layer: UI state store test
-
-import { beforeEach, describe, expect, it } from "vitest";
-import { ThreadId } from "@jcode/contracts";
-import { usePinnedThreadsStore } from "./pinnedThreadsStore";
-
-describe("usePinnedThreadsStore", () => {
- beforeEach(() => {
- usePinnedThreadsStore.setState({ pinnedThreadIds: [] });
- });
-
- it("toggles a pinned thread id on and off", () => {
- usePinnedThreadsStore.getState().togglePinnedThread("thread-1" as ThreadId);
- expect(usePinnedThreadsStore.getState().pinnedThreadIds).toEqual(["thread-1"]);
-
- usePinnedThreadsStore.getState().togglePinnedThread("thread-1" as ThreadId);
- expect(usePinnedThreadsStore.getState().pinnedThreadIds).toEqual([]);
- });
-
- it("prunes thread ids that are no longer present", () => {
- usePinnedThreadsStore.setState({
- pinnedThreadIds: ["thread-2" as ThreadId, "thread-1" as ThreadId],
- });
-
- usePinnedThreadsStore.getState().prunePinnedThreads(["thread-1" as ThreadId]);
- expect(usePinnedThreadsStore.getState().pinnedThreadIds).toEqual(["thread-1"]);
- });
-});
diff --git a/apps/web/src/pinnedThreadsStore.ts b/apps/web/src/pinnedThreadsStore.ts
deleted file mode 100644
index 6f0198a1e..000000000
--- a/apps/web/src/pinnedThreadsStore.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-// FILE: pinnedThreadsStore.ts
-// Purpose: Persists the globally pinned chat thread ids used by the sidebar.
-// Layer: UI state store
-// Exports: usePinnedThreadsStore
-
-import { type ThreadId } from "@jcode/contracts";
-import { create } from "zustand";
-import { createJSONStorage, persist } from "zustand/middleware";
-import { getLocalStorage } from "./lib/storage";
-
-interface PinnedThreadsStoreState {
- pinnedThreadIds: ThreadId[];
- pinThread: (threadId: ThreadId) => void;
- unpinThread: (threadId: ThreadId) => void;
- togglePinnedThread: (threadId: ThreadId) => void;
- prunePinnedThreads: (threadIds: readonly ThreadId[]) => void;
-}
-
-const PINNED_THREADS_STORAGE_KEY = "jcode:pinned-threads:v1";
-
-function normalizePinnedThreadIds(threadIds: readonly ThreadId[]): ThreadId[] {
- const seen = new Set();
- const normalized: ThreadId[] = [];
-
- for (const threadId of threadIds) {
- if (threadId.length === 0 || seen.has(threadId)) {
- continue;
- }
- seen.add(threadId);
- normalized.push(threadId);
- }
-
- return normalized;
-}
-
-export const usePinnedThreadsStore = create()(
- persist(
- (set) => ({
- pinnedThreadIds: [],
- pinThread: (threadId) => {
- if (threadId.length === 0) return;
- set((state) => {
- if (state.pinnedThreadIds.includes(threadId)) {
- return state;
- }
- return {
- pinnedThreadIds: [threadId, ...state.pinnedThreadIds],
- };
- });
- },
- unpinThread: (threadId) => {
- if (threadId.length === 0) return;
- set((state) => {
- if (!state.pinnedThreadIds.includes(threadId)) {
- return state;
- }
- return {
- pinnedThreadIds: state.pinnedThreadIds.filter((candidate) => candidate !== threadId),
- };
- });
- },
- togglePinnedThread: (threadId) => {
- if (threadId.length === 0) return;
- set((state) => {
- if (state.pinnedThreadIds.includes(threadId)) {
- return {
- pinnedThreadIds: state.pinnedThreadIds.filter((candidate) => candidate !== threadId),
- };
- }
- return {
- pinnedThreadIds: [threadId, ...state.pinnedThreadIds],
- };
- });
- },
- prunePinnedThreads: (threadIds) => {
- const allowedThreadIds = new Set(threadIds);
- set((state) => {
- const nextPinnedThreadIds = state.pinnedThreadIds.filter((threadId) =>
- allowedThreadIds.has(threadId),
- );
- return nextPinnedThreadIds.length === state.pinnedThreadIds.length
- ? state
- : { pinnedThreadIds: nextPinnedThreadIds };
- });
- },
- }),
- {
- name: PINNED_THREADS_STORAGE_KEY,
- storage: createJSONStorage(() => getLocalStorage()),
- partialize: (state) => ({
- pinnedThreadIds: normalizePinnedThreadIds(state.pinnedThreadIds),
- }),
- merge: (persistedState, currentState) => {
- const candidate =
- (persistedState as Partial> | undefined)
- ?.pinnedThreadIds ?? [];
- return {
- ...currentState,
- pinnedThreadIds: normalizePinnedThreadIds(candidate),
- };
- },
- },
- ),
-);
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index 8b5e01c20..eefc81bc1 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -77,6 +77,18 @@ import { shouldRepairDesktopProjectBootstrapSnapshot } from "../lib/desktopProje
import { parseDiffRouteSearch } from "../diffRouteSearch";
import { resolveSplitViewThreadIds, selectSplitView, useSplitViewStore } from "../splitViewStore";
import { providerDiscoveryQueryKeys } from "../lib/providerDiscoveryReactQuery";
+import {
+ createSidebarLayoutRouter,
+ sidebarLayoutLegacySubjectsReady,
+ type SidebarLayoutSnapshotSource,
+} from "../sidebarLayoutRouter";
+import {
+ confirmSidebarLayoutLegacyMigration,
+ readSidebarLayoutLegacyCandidates,
+ type SidebarLayoutLegacyCandidates,
+} from "../sidebarLayoutLegacyMigration";
+import { sidebarLayoutStore } from "../sidebarLayoutStore";
+import { classifyShellStreamEvent } from "../shellEventOrdering";
const SHELL_SNAPSHOT_BOOTSTRAP_FALLBACK_DELAY_MS = 1_500;
const THREAD_DETAIL_CATCHUP_INTERVAL_MS = 1_500;
@@ -700,6 +712,35 @@ function EventRouter() {
const reconcileThreadSubscriptionsRef = useRef<
((threadIds: readonly ThreadId[]) => Promise) | null
>(null);
+ const sidebarLayoutRouterRef = useRef | null>(null);
+ const sidebarLayoutRouter =
+ sidebarLayoutRouterRef.current ??
+ createSidebarLayoutRouter({
+ store: sidebarLayoutStore,
+ confirmLegacyMigration: (layout) =>
+ confirmSidebarLayoutLegacyMigration(window.localStorage, layout),
+ onInitializationRejected: (failure) => {
+ toastManager.add({
+ type: "error",
+ title: "Sidebar layout did not sync",
+ description:
+ failure.error instanceof Error
+ ? failure.error.message
+ : "The server rejected sidebar setup. Retry to use this profile's saved order.",
+ actionProps: {
+ children: "Retry",
+ onClick: () => {
+ failure.retry();
+ },
+ },
+ });
+ },
+ });
+ sidebarLayoutRouterRef.current = sidebarLayoutRouter;
+ const sidebarLayoutLegacyCandidatesRef = useRef<
+ | { readonly collected: false }
+ | { readonly collected: true; readonly candidates: SidebarLayoutLegacyCandidates | null }
+ >({ collected: false });
workspacePagesRef.current = workspacePages;
pathnameRef.current = pathname;
@@ -722,6 +763,47 @@ function EventRouter() {
const threadSnapshotRequestInFlight = new Set();
const threadReplayRequestInFlight = new Set();
let reconcileThreadSubscriptionsChain = Promise.resolve();
+ const acceptSidebarLayoutSnapshot = (
+ snapshot: OrchestrationShellSnapshot,
+ source: SidebarLayoutSnapshotSource,
+ ): void => {
+ const lifecycle = {
+ projects: snapshot.projects.map((project) => ({
+ id: project.id,
+ kind: project.kind,
+ createdAt: project.createdAt,
+ deletedAt: null,
+ })),
+ threads: snapshot.threads.map((thread) => ({ id: thread.id, deletedAt: null })),
+ };
+ if (
+ !sidebarLayoutLegacyCandidatesRef.current.collected &&
+ sidebarLayoutLegacySubjectsReady(source, lifecycle)
+ ) {
+ sidebarLayoutLegacyCandidatesRef.current = {
+ collected: true,
+ candidates: readSidebarLayoutLegacyCandidates(
+ window.localStorage,
+ snapshot.sidebarLayout,
+ {
+ projects: snapshot.projects.map((project) => ({
+ id: project.id,
+ workspaceRoot: project.workspaceRoot,
+ })),
+ threadIds: snapshot.threads.map((thread) => thread.id),
+ },
+ ),
+ };
+ }
+ const legacyCandidateState = sidebarLayoutLegacyCandidatesRef.current;
+ sidebarLayoutRouter.acceptSnapshot({
+ sidebarLayout: snapshot.sidebarLayout,
+ lifecycle,
+ ...(legacyCandidateState.collected
+ ? { legacyCandidates: legacyCandidateState.candidates }
+ : {}),
+ });
+ };
const beginThreadSubscription = (threadId: ThreadId) => {
threadSnapshotSequenceById.delete(threadId);
@@ -766,6 +848,7 @@ function EventRouter() {
pendingShellEvents = [];
for (const event of nextPending) {
shellSnapshotSequence = Math.max(shellSnapshotSequence, event.sequence);
+ sidebarLayoutRouter.acceptShellEvent(event);
applyShellEvent(event);
}
};
@@ -837,16 +920,26 @@ function EventRouter() {
const loadShellSnapshotOnce = async () => {
const snapshot = await api.orchestration.getShellSnapshot();
if (!shouldApplyBootstrapShellSnapshot(snapshot)) {
+ if (
+ snapshot.sidebarLayout === null &&
+ !sidebarLayoutLegacyCandidatesRef.current.collected
+ ) {
+ acceptSidebarLayoutSnapshot(snapshot, "query");
+ } else {
+ sidebarLayoutRouter.acceptConfirmedLayout(snapshot.sidebarLayout);
+ }
return;
}
shellSnapshotSequence = snapshot.snapshotSequence;
syncServerShellSnapshot(snapshot);
+ acceptSidebarLayoutSnapshot(snapshot, "query");
reconcilePromotedDraftsFromShellThreads(snapshot.threads);
removeOrphanedTerminalsForCurrentState();
flushShellBuffer(snapshot.snapshotSequence);
};
const ensureScopedSubscriptions = async () => {
+ sidebarLayoutRouter.reconnect();
shellSnapshotSequence = -1;
pendingShellEvents = [];
subscribedThreadIds.clear();
@@ -974,20 +1067,25 @@ function EventRouter() {
if (item.kind === "snapshot") {
shellSnapshotSequence = item.snapshot.snapshotSequence;
syncServerShellSnapshot(item.snapshot);
+ acceptSidebarLayoutSnapshot(item.snapshot, "stream");
reconcilePromotedDraftsFromShellThreads(item.snapshot.threads);
removeOrphanedTerminalsForCurrentState();
flushShellBuffer(item.snapshot.snapshotSequence);
return;
}
- if (shellSnapshotSequence < 0) {
- pendingShellEvents.push(item);
- return;
- }
- if (item.sequence <= shellSnapshotSequence) {
- return;
+ const disposition = classifyShellStreamEvent(shellSnapshotSequence, item.sequence);
+ switch (disposition) {
+ case "buffer":
+ pendingShellEvents.push(item);
+ return;
+ case "ignore":
+ return;
+ case "apply":
+ break;
}
shellSnapshotSequence = item.sequence;
+ sidebarLayoutRouter.acceptShellEvent(item);
applyShellEvent(item);
if (item.kind === "thread-upserted") {
reconcilePromotedDraftsFromShellThreads([item.thread]);
@@ -1203,6 +1301,7 @@ function EventRouter() {
setWorkspaceHomeDir,
syncServerShellSnapshot,
syncServerThreadDetailHotPath,
+ sidebarLayoutRouter,
]);
useLayoutEffect(() => {
diff --git a/apps/web/src/shellEventOrdering.test.ts b/apps/web/src/shellEventOrdering.test.ts
new file mode 100644
index 000000000..059dc30bc
--- /dev/null
+++ b/apps/web/src/shellEventOrdering.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+import { classifyShellStreamEvent } from "./shellEventOrdering";
+
+describe("shell event ordering", () => {
+ it("buffers every entity event before the first shell snapshot", () => {
+ // Given: no shell snapshot has established an event fence.
+ const shellSnapshotSequence = -1;
+
+ // When: an entity upsert arrives.
+ const disposition = classifyShellStreamEvent(shellSnapshotSequence, 4);
+
+ // Then: the event is buffered for the snapshot flush instead of routed immediately.
+ expect(disposition).toBe("buffer");
+ });
+
+ it.each([
+ ["stale project remove", 9],
+ ["equal project upsert", 10],
+ ["stale thread remove", 8],
+ ["equal thread upsert", 10],
+ ])("ignores %s behind the latest shell event fence", (_caseName, eventSequence) => {
+ // Given: a snapshot or newer event established sequence 10.
+ const shellSnapshotSequence = 10;
+
+ // When: a lower or equal entity event arrives.
+ const disposition = classifyShellStreamEvent(shellSnapshotSequence, eventSequence);
+
+ // Then: it cannot reach either lifecycle routing or the rendered store.
+ expect(disposition).toBe("ignore");
+ });
+
+ it("applies an event strictly newer than the latest shell event fence", () => {
+ // Given: sequence 10 is the latest applied shell item.
+ const shellSnapshotSequence = 10;
+
+ // When: sequence 11 arrives.
+ const disposition = classifyShellStreamEvent(shellSnapshotSequence, 11);
+
+ // Then: it is routed and advances the fence once.
+ expect(disposition).toBe("apply");
+ });
+});
diff --git a/apps/web/src/shellEventOrdering.ts b/apps/web/src/shellEventOrdering.ts
new file mode 100644
index 000000000..09d6a949e
--- /dev/null
+++ b/apps/web/src/shellEventOrdering.ts
@@ -0,0 +1,11 @@
+export type ShellStreamEventDisposition = "buffer" | "ignore" | "apply";
+
+export function classifyShellStreamEvent(
+ shellSnapshotSequence: number,
+ eventSequence: number,
+): ShellStreamEventDisposition {
+ if (shellSnapshotSequence < 0) {
+ return "buffer";
+ }
+ return eventSequence > shellSnapshotSequence ? "apply" : "ignore";
+}
diff --git a/apps/web/src/sidebarLayout.logic.test.ts b/apps/web/src/sidebarLayout.logic.test.ts
new file mode 100644
index 000000000..bce1cc21a
--- /dev/null
+++ b/apps/web/src/sidebarLayout.logic.test.ts
@@ -0,0 +1,326 @@
+import { describe, expect, it } from "vitest";
+import { CommandId, ProjectId, ThreadId, type SidebarLayout } from "@jcode/contracts";
+import * as sidebarLayout from "./sidebarLayout.logic";
+
+const acceptConfirmed = sidebarLayout.acceptConfirmedSidebarLayout;
+const deriveDisplayed = sidebarLayout.deriveDisplayedSidebarLayout;
+const selectPins = sidebarLayout.selectDisplayedPinnedThreadOrder;
+const selectProjects = sidebarLayout.selectDisplayedProjectOrder;
+
+const projectA = ProjectId.makeUnsafe("project-a");
+const projectB = ProjectId.makeUnsafe("project-b");
+const projectC = ProjectId.makeUnsafe("project-c");
+const projectD = ProjectId.makeUnsafe("project-d");
+const threadA = ThreadId.makeUnsafe("thread-a");
+const threadB = ThreadId.makeUnsafe("thread-b");
+const threadC = ThreadId.makeUnsafe("thread-c");
+const commandA = CommandId.makeUnsafe("command-a");
+const commandB = CommandId.makeUnsafe("command-b");
+
+const layout = (
+ revision: number,
+ projectOrder: readonly ProjectId[] = [projectA, projectB, projectC],
+ pinnedThreadOrder: readonly ThreadId[] = [threadA, threadB, threadC],
+): SidebarLayout => ({
+ projectOrder,
+ pinnedThreadOrder,
+ revision,
+ updatedAt: `2026-07-18T00:00:${String(revision).padStart(2, "0")}.000Z`,
+});
+
+const lifecycle: sidebarLayout.SidebarLayoutLifecycle = {
+ projects: [
+ { id: projectA, kind: "project", createdAt: "2026-01-01T00:00:00Z", deletedAt: null },
+ { id: projectB, kind: "project", createdAt: "2026-01-02T00:00:00Z", deletedAt: null },
+ { id: projectC, kind: "project", createdAt: "2026-01-03T00:00:00Z", deletedAt: null },
+ ],
+ threads: [
+ { id: threadA, deletedAt: null },
+ { id: threadB, deletedAt: null },
+ { id: threadC, deletedAt: null },
+ ],
+};
+
+describe("confirmed sidebar layout", () => {
+ it.each([
+ ["older", layout(4, [projectC, projectB, projectA])],
+ ["equal conflicting", layout(5, [projectC, projectB, projectA])],
+ ])("rejects an %s revision", (_caseName, incoming) => {
+ // Given
+ const confirmed = layout(5);
+ // When
+ const accepted = acceptConfirmed(confirmed, incoming);
+ // Then
+ expect(accepted).toBe(confirmed);
+ });
+
+ it("accepts a newer revision", () => {
+ // Given
+ const confirmed = layout(5);
+ const incoming = layout(6, [projectB, projectA, projectC]);
+ // When
+ const accepted = acceptConfirmed(confirmed, incoming);
+ // Then
+ expect(accepted).toBe(incoming);
+ });
+
+ it("treats an identical equal revision as idempotent", () => {
+ // Given
+ const confirmed = layout(5);
+ const duplicate = { ...confirmed };
+ // When
+ const accepted = acceptConfirmed(confirmed, duplicate);
+ // Then
+ expect(accepted).toBe(confirmed);
+ });
+});
+
+describe("pending intent reconciliation", () => {
+ const pending: readonly sidebarLayout.PendingSidebarLayoutIntent[] = [
+ {
+ commandId: commandA,
+ intent: { type: "sidebar-layout.project.move", projectId: projectA, beforeProjectId: null },
+ },
+ ];
+
+ it("keeps an intent when its layout event arrives before its RPC receipt", () => {
+ // Given
+ const confirmedRevision = 8;
+ // When
+ const reconciled = sidebarLayout.reconcilePendingSidebarLayoutIntents(
+ pending,
+ confirmedRevision,
+ );
+ // Then
+ expect(reconciled).toEqual(pending);
+ });
+
+ it("clears event-before-RPC intent after the receipt sequence is recorded", () => {
+ // Given
+ const accepted = sidebarLayout.recordPendingSidebarLayoutAcceptance(pending, commandA, 8);
+ // When
+ const reconciled = sidebarLayout.reconcilePendingSidebarLayoutIntents(accepted, 8);
+ // Then
+ expect(reconciled).toEqual([]);
+ });
+
+ it.each(["RPC-before-event", "an unrelated newer snapshotSequence"])(
+ "keeps an intent for %s below its accepted layout revision",
+ () => {
+ // Given: snapshotSequence is deliberately absent from this API.
+ const accepted = sidebarLayout.recordPendingSidebarLayoutAcceptance(pending, commandA, 8);
+ // When
+ const reconciled = sidebarLayout.reconcilePendingSidebarLayoutIntents(accepted, 7);
+ // Then
+ expect(reconciled).toEqual(accepted);
+ },
+ );
+
+ it("records an idempotent retry receipt on the existing command", () => {
+ // Given
+ const firstReceipt = sidebarLayout.recordPendingSidebarLayoutAcceptance(pending, commandA, 8);
+ // When
+ const retriedReceipt = sidebarLayout.recordPendingSidebarLayoutAcceptance(
+ firstReceipt,
+ commandA,
+ 8,
+ );
+ // Then
+ expect(retriedReceipt).toEqual(firstReceipt);
+ });
+
+ it("removes only a rejected intent so the displayed state rolls back", () => {
+ // Given
+ const twoPending: readonly sidebarLayout.PendingSidebarLayoutIntent[] = [
+ ...pending,
+ {
+ commandId: commandB,
+ intent: { type: "sidebar-layout.thread.unpin", threadId: threadA },
+ },
+ ];
+ // When
+ const remaining = sidebarLayout.rejectPendingSidebarLayoutIntent(twoPending, commandA);
+ // Then
+ expect(remaining.map((item) => item.commandId)).toEqual([commandB]);
+ });
+});
+
+describe("optimistic replay and lifecycle normalization", () => {
+ it("rebases two local intents over a newer remote layout", () => {
+ // Given
+ const pending: readonly sidebarLayout.PendingSidebarLayoutIntent[] = [
+ {
+ commandId: commandA,
+ intent: {
+ type: "sidebar-layout.project.move",
+ projectId: projectB,
+ beforeProjectId: projectA,
+ },
+ },
+ {
+ commandId: commandB,
+ intent: {
+ type: "sidebar-layout.project.move",
+ projectId: projectC,
+ beforeProjectId: null,
+ },
+ },
+ ];
+ // When
+ const displayed = deriveDisplayed(
+ layout(9, [projectC, projectA, projectB]),
+ pending,
+ lifecycle,
+ );
+ // Then
+ expect(selectProjects(displayed)).toEqual([projectB, projectA, projectC]);
+ });
+
+ it("replays pin, pinned move, and unpin without mutating confirmed state", () => {
+ // Given
+ const confirmed = layout(3, [projectA, projectB, projectC], [threadA]);
+ const pending: readonly sidebarLayout.PendingSidebarLayoutIntent[] = [
+ {
+ commandId: commandA,
+ intent: {
+ type: "sidebar-layout.thread.pin",
+ threadId: threadB,
+ beforeThreadId: threadA,
+ },
+ },
+ {
+ commandId: commandB,
+ intent: {
+ type: "sidebar-layout.pinned-thread.move",
+ threadId: threadA,
+ beforeThreadId: threadB,
+ },
+ },
+ {
+ commandId: CommandId.makeUnsafe("command-c"),
+ intent: { type: "sidebar-layout.thread.unpin", threadId: threadB },
+ },
+ ];
+ // When
+ const displayed = deriveDisplayed(confirmed, pending, lifecycle);
+ // Then
+ expect(selectPins(displayed)).toEqual([threadA]);
+ expect(confirmed.pinnedThreadOrder).toEqual([threadA]);
+ });
+
+ it("uses initialization while canonical layout is absent", () => {
+ // Given
+ const pending: readonly sidebarLayout.PendingSidebarLayoutIntent[] = [
+ {
+ commandId: commandA,
+ intent: {
+ type: "sidebar-layout.initialize",
+ projectOrder: [projectC, projectA],
+ pinnedThreadOrder: [threadB],
+ },
+ },
+ ];
+ // When
+ const displayed = deriveDisplayed(null, pending, lifecycle);
+ // Then
+ expect(selectProjects(displayed)).toEqual([projectC, projectA, projectB]);
+ });
+
+ it("does not replay initialization over a reconnect snapshot", () => {
+ // Given
+ const pending: readonly sidebarLayout.PendingSidebarLayoutIntent[] = [
+ {
+ commandId: commandA,
+ intent: {
+ type: "sidebar-layout.initialize",
+ projectOrder: [projectC, projectA],
+ pinnedThreadOrder: [threadB],
+ },
+ },
+ ];
+ // When
+ const displayed = deriveDisplayed(layout(10), pending, lifecycle);
+ // Then
+ expect(selectProjects(displayed)).toEqual([projectA, projectB, projectC]);
+ });
+
+ it("appends every unseen active project kind by createdAt then id and omits deleted IDs", () => {
+ // Given
+ const lifecycleWithChanges: sidebarLayout.SidebarLayoutLifecycle = {
+ projects: [
+ { id: projectA, kind: "project", createdAt: "2026-01-01", deletedAt: "2026-06-01" },
+ { id: projectB, kind: "chat", createdAt: "2026-01-03", deletedAt: null },
+ { id: projectC, kind: "project", createdAt: "2026-01-02", deletedAt: null },
+ { id: projectD, kind: "chat", createdAt: "2026-01-02", deletedAt: null },
+ ],
+ threads: [
+ { id: threadA, deletedAt: "2026-06-01" },
+ { id: threadB, deletedAt: null },
+ ],
+ };
+ // When
+ const displayed = deriveDisplayed(
+ layout(2, [projectA, projectB, projectB], [threadA, threadB, threadB]),
+ [],
+ lifecycleWithChanges,
+ );
+ // Then
+ expect(selectProjects(displayed)).toEqual([projectB, projectC, projectD]);
+ expect(selectPins(displayed)).toEqual([threadB]);
+ });
+
+ it("ignores unknown subjects and appends on a missing anchor", () => {
+ // Given
+ const pending: readonly sidebarLayout.PendingSidebarLayoutIntent[] = [
+ {
+ commandId: commandA,
+ intent: {
+ type: "sidebar-layout.project.move",
+ projectId: ProjectId.makeUnsafe("unknown-project"),
+ beforeProjectId: projectA,
+ },
+ },
+ {
+ commandId: commandB,
+ intent: {
+ type: "sidebar-layout.project.move",
+ projectId: projectA,
+ beforeProjectId: ProjectId.makeUnsafe("missing-anchor"),
+ },
+ },
+ ];
+ // When
+ const displayed = deriveDisplayed(layout(4), pending, lifecycle);
+ // Then
+ expect(selectProjects(displayed)).toEqual([projectB, projectC, projectA]);
+ });
+});
+
+describe("DnD next-sibling anchors", () => {
+ it.each([
+ ["first to middle", projectA, projectC, [projectB, projectC, projectA, projectD], projectD],
+ ["last to first", projectD, projectA, [projectD, projectA, projectB, projectC], projectA],
+ ["upward", projectC, projectA, [projectC, projectA, projectB, projectD], projectA],
+ ["downward", projectB, projectD, [projectA, projectC, projectD, projectB], null],
+ ["self", projectB, projectB, [projectA, projectB, projectC, projectD], projectC],
+ ])("derives the %s anchor from the final project list", (_name, moved, over, final, anchor) => {
+ // Given
+ const order = [projectA, projectB, projectC, projectD];
+ // When
+ const result = sidebarLayout.getDndNextSiblingAnchor(order, moved, over);
+ // Then
+ expect(result).toEqual({ finalOrder: final, beforeId: anchor });
+ });
+
+ it.each([
+ ["upward", threadC, threadA, [threadC, threadA, threadB], threadA],
+ ["downward to end", threadA, threadC, [threadB, threadC, threadA], null],
+ ])("derives the %s pinned-thread anchor", (_name, moved, over, final, anchor) => {
+ // Given
+ const order = [threadA, threadB, threadC];
+ // When
+ const result = sidebarLayout.getDndNextSiblingAnchor(order, moved, over);
+ // Then
+ expect(result).toEqual({ finalOrder: final, beforeId: anchor });
+ });
+});
diff --git a/apps/web/src/sidebarLayout.logic.ts b/apps/web/src/sidebarLayout.logic.ts
new file mode 100644
index 000000000..88739c815
--- /dev/null
+++ b/apps/web/src/sidebarLayout.logic.ts
@@ -0,0 +1,267 @@
+import type {
+ CommandId,
+ DispatchableClientOrchestrationCommand,
+ OrchestrationProject,
+ OrchestrationThread,
+ ProjectId,
+ SidebarLayout,
+ ThreadId,
+} from "@jcode/contracts";
+
+type LayoutCommand = Extract<
+ DispatchableClientOrchestrationCommand,
+ {
+ readonly type:
+ | "sidebar-layout.initialize"
+ | "sidebar-layout.project.move"
+ | "sidebar-layout.thread.pin"
+ | "sidebar-layout.thread.unpin"
+ | "sidebar-layout.pinned-thread.move";
+ }
+>;
+
+type IntentOf = Omit;
+
+export type SidebarLayoutIntent = LayoutCommand extends infer Command
+ ? Command extends LayoutCommand
+ ? IntentOf
+ : never
+ : never;
+
+export type PendingSidebarLayoutIntent = {
+ readonly commandId: CommandId;
+ readonly intent: SidebarLayoutIntent;
+ readonly acceptedSequence?: number;
+};
+
+type ProjectLifecycle = Pick;
+type ThreadLifecycle = Pick;
+
+export type SidebarLayoutLifecycle = {
+ readonly projects: readonly ProjectLifecycle[];
+ readonly threads: readonly ThreadLifecycle[];
+};
+
+export type DisplayedSidebarLayout = Pick;
+
+export type DndNextSiblingAnchor = {
+ readonly finalOrder: readonly Id[];
+ readonly beforeId: Id | null;
+};
+
+class UnexpectedSidebarLayoutIntentError extends Error {
+ constructor() {
+ super("Unexpected sidebar layout intent");
+ this.name = "UnexpectedSidebarLayoutIntentError";
+ }
+}
+
+function assertNever(_value: never): never {
+ throw new UnexpectedSidebarLayoutIntentError();
+}
+
+export function acceptConfirmedSidebarLayout(
+ current: SidebarLayout | null,
+ incoming: SidebarLayout,
+): SidebarLayout {
+ if (current === null || incoming.revision > current.revision) {
+ return incoming;
+ }
+ return current;
+}
+
+export function recordPendingSidebarLayoutAcceptance(
+ pending: readonly PendingSidebarLayoutIntent[],
+ commandId: CommandId,
+ acceptedSequence: number,
+): readonly PendingSidebarLayoutIntent[] {
+ return pending.map((item) =>
+ item.commandId === commandId ? { ...item, acceptedSequence } : item,
+ );
+}
+
+export function reconcilePendingSidebarLayoutIntents(
+ pending: readonly PendingSidebarLayoutIntent[],
+ confirmedRevision: number | null,
+): readonly PendingSidebarLayoutIntent[] {
+ if (confirmedRevision === null) {
+ return pending;
+ }
+ return pending.filter(
+ (item) => item.acceptedSequence === undefined || item.acceptedSequence > confirmedRevision,
+ );
+}
+
+export function rejectPendingSidebarLayoutIntent(
+ pending: readonly PendingSidebarLayoutIntent[],
+ commandId: CommandId,
+): readonly PendingSidebarLayoutIntent[] {
+ return pending.filter((item) => item.commandId !== commandId);
+}
+
+function uniqueLiveIds(ids: readonly Id[], activeIds: ReadonlySet): readonly Id[] {
+ const seen = new Set();
+ return ids.filter((id) => {
+ if (!activeIds.has(id) || seen.has(id)) {
+ return false;
+ }
+ seen.add(id);
+ return true;
+ });
+}
+
+function normalizeLayout(
+ layout: DisplayedSidebarLayout,
+ lifecycle: SidebarLayoutLifecycle,
+): DisplayedSidebarLayout {
+ const activeProjects = lifecycle.projects
+ .filter((project) => project.deletedAt === null)
+ .toSorted(
+ (left, right) =>
+ left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id),
+ );
+ const activeProjectIds = new Set(activeProjects.map((project) => project.id));
+ const projectOrder = [...uniqueLiveIds(layout.projectOrder, activeProjectIds)];
+ const seenProjectIds = new Set(projectOrder);
+ for (const project of activeProjects) {
+ if (!seenProjectIds.has(project.id)) {
+ projectOrder.push(project.id);
+ seenProjectIds.add(project.id);
+ }
+ }
+
+ const activeThreadIds = new Set(
+ lifecycle.threads.filter((thread) => thread.deletedAt === null).map((thread) => thread.id),
+ );
+ return {
+ projectOrder,
+ pinnedThreadOrder: uniqueLiveIds(layout.pinnedThreadOrder, activeThreadIds),
+ };
+}
+
+function moveBefore(items: readonly Id[], subject: Id, anchor: Id | null): readonly Id[] {
+ if (!items.includes(subject) || anchor === subject) {
+ return items;
+ }
+ const remaining = items.filter((item) => item !== subject);
+ if (anchor === null) {
+ return [...remaining, subject];
+ }
+ const anchorIndex = remaining.indexOf(anchor);
+ if (anchorIndex < 0) {
+ return [...remaining, subject];
+ }
+ return [...remaining.slice(0, anchorIndex), subject, ...remaining.slice(anchorIndex)];
+}
+
+function applyIntent(
+ layout: DisplayedSidebarLayout,
+ intent: SidebarLayoutIntent,
+ context: {
+ readonly activeThreadIds: ReadonlySet;
+ readonly canonicalExists: boolean;
+ },
+): DisplayedSidebarLayout {
+ switch (intent.type) {
+ case "sidebar-layout.initialize":
+ return context.canonicalExists
+ ? layout
+ : {
+ projectOrder: intent.projectOrder,
+ pinnedThreadOrder: intent.pinnedThreadOrder,
+ };
+ case "sidebar-layout.project.move":
+ return {
+ ...layout,
+ projectOrder: moveBefore(
+ layout.projectOrder,
+ intent.projectId,
+ intent.beforeProjectId ?? null,
+ ),
+ };
+ case "sidebar-layout.thread.pin":
+ return context.activeThreadIds.has(intent.threadId)
+ ? {
+ ...layout,
+ pinnedThreadOrder: moveBefore(
+ layout.pinnedThreadOrder.includes(intent.threadId)
+ ? layout.pinnedThreadOrder
+ : [...layout.pinnedThreadOrder, intent.threadId],
+ intent.threadId,
+ intent.beforeThreadId ?? null,
+ ),
+ }
+ : layout;
+ case "sidebar-layout.thread.unpin":
+ return {
+ ...layout,
+ pinnedThreadOrder: layout.pinnedThreadOrder.filter(
+ (threadId) => threadId !== intent.threadId,
+ ),
+ };
+ case "sidebar-layout.pinned-thread.move":
+ return {
+ ...layout,
+ pinnedThreadOrder: moveBefore(
+ layout.pinnedThreadOrder,
+ intent.threadId,
+ intent.beforeThreadId ?? null,
+ ),
+ };
+ default:
+ return assertNever(intent);
+ }
+}
+
+export function deriveDisplayedSidebarLayout(
+ confirmed: SidebarLayout | null,
+ pending: readonly PendingSidebarLayoutIntent[],
+ lifecycle: SidebarLayoutLifecycle,
+): DisplayedSidebarLayout {
+ const emptyLayout: DisplayedSidebarLayout = {
+ projectOrder: [],
+ pinnedThreadOrder: [],
+ };
+ const normalized = normalizeLayout(confirmed ?? emptyLayout, lifecycle);
+ const context = {
+ activeThreadIds: new Set(
+ lifecycle.threads.filter((thread) => thread.deletedAt === null).map((thread) => thread.id),
+ ),
+ canonicalExists: confirmed !== null,
+ };
+ const replayed = pending.reduce(
+ (current, item) => applyIntent(current, item.intent, context),
+ normalized,
+ );
+ return normalizeLayout(replayed, lifecycle);
+}
+
+export function selectDisplayedProjectOrder(layout: DisplayedSidebarLayout): readonly ProjectId[] {
+ return layout.projectOrder;
+}
+
+export function selectDisplayedPinnedThreadOrder(
+ layout: DisplayedSidebarLayout,
+): readonly ThreadId[] {
+ return layout.pinnedThreadOrder;
+}
+
+export function getDndNextSiblingAnchor(
+ order: readonly Id[],
+ movedId: Id,
+ overId: Id,
+): DndNextSiblingAnchor {
+ const normalized = [...new Set(order)];
+ const movedIndex = normalized.indexOf(movedId);
+ const overIndex = normalized.indexOf(overId);
+ const remaining = normalized.filter((id) => id !== movedId);
+ const finalOrder =
+ movedIndex < 0 || overIndex < 0 || movedId === overId
+ ? normalized
+ : [...remaining.slice(0, overIndex), movedId, ...remaining.slice(overIndex)];
+ const finalMovedIndex = finalOrder.indexOf(movedId);
+ return {
+ finalOrder,
+ beforeId: finalMovedIndex < 0 ? null : (finalOrder.at(finalMovedIndex + 1) ?? null),
+ };
+}
diff --git a/apps/web/src/sidebarLayoutLegacyMigration.test.ts b/apps/web/src/sidebarLayoutLegacyMigration.test.ts
new file mode 100644
index 000000000..dbdae48c6
--- /dev/null
+++ b/apps/web/src/sidebarLayoutLegacyMigration.test.ts
@@ -0,0 +1,281 @@
+import { ProjectId, ThreadId, type SidebarLayout } from "@jcode/contracts";
+import { describe, expect, it } from "vitest";
+import {
+ SIDEBAR_LAYOUT_MIGRATION_MARKER_KEY,
+ confirmSidebarLayoutLegacyMigration,
+ readSidebarLayoutLegacyCandidates,
+} from "./sidebarLayoutLegacyMigration";
+
+class MemoryStorage implements Storage {
+ readonly #values = new Map();
+
+ get length(): number {
+ return this.#values.size;
+ }
+
+ clear(): void {
+ this.#values.clear();
+ }
+
+ getItem(key: string): string | null {
+ return this.#values.get(key) ?? null;
+ }
+
+ key(index: number): string | null {
+ return [...this.#values.keys()][index] ?? null;
+ }
+
+ removeItem(key: string): void {
+ this.#values.delete(key);
+ }
+
+ setItem(key: string, value: string): void {
+ this.#values.set(key, value);
+ }
+}
+
+class FailingReadStorage extends MemoryStorage {
+ override getItem(_key: string): string | null {
+ throw new DOMException("Storage denied", "SecurityError");
+ }
+}
+
+class FailOnceRemoveStorage implements Storage {
+ #failed = false;
+
+ constructor(private readonly storage: Storage) {}
+
+ get length(): number {
+ return this.storage.length;
+ }
+
+ clear(): void {
+ this.storage.clear();
+ }
+
+ getItem(key: string): string | null {
+ return this.storage.getItem(key);
+ }
+
+ key(index: number): string | null {
+ return this.storage.key(index);
+ }
+
+ removeItem(key: string): void {
+ if (!this.#failed && key === "dpcode:pinned-threads:v1") {
+ this.#failed = true;
+ throw new DOMException("Interrupted", "QuotaExceededError");
+ }
+ this.storage.removeItem(key);
+ }
+
+ setItem(key: string, value: string): void {
+ this.storage.setItem(key, value);
+ }
+}
+
+const projectA = ProjectId.makeUnsafe("project-a");
+const projectB = ProjectId.makeUnsafe("project-b");
+const threadA = ThreadId.makeUnsafe("thread-a");
+const threadB = ThreadId.makeUnsafe("thread-b");
+const hydrated = {
+ projects: [
+ { id: projectA, workspaceRoot: "/work/a" },
+ { id: projectB, workspaceRoot: "C:\\Work\\B" },
+ ],
+ threadIds: [threadA, threadB],
+} as const;
+const confirmedLayout: SidebarLayout = {
+ projectOrder: [projectA, projectB],
+ pinnedThreadOrder: [threadA],
+ revision: 1,
+ updatedAt: "2026-07-18T00:00:00.000Z",
+};
+
+describe("sidebar layout legacy candidates", () => {
+ it("maps mixed current, DPCode, and T3Code values to deduplicated hydrated IDs", () => {
+ // Given
+ const storage = new MemoryStorage();
+ storage.setItem(
+ "jcode:renderer-state:v8",
+ JSON.stringify({ projectOrderCwds: ["/work/a/", "/unknown", "C:\\Work\\B"] }),
+ );
+ storage.setItem(
+ "dpcode:renderer-state:v8",
+ JSON.stringify({ projectOrderCwds: ["/work/a", "C:/Work/B"] }),
+ );
+ storage.setItem(
+ "t3code:renderer-state:v7",
+ JSON.stringify({ projectOrderCwds: ["C:/Work/B", "/unknown"] }),
+ );
+ storage.setItem(
+ "dpcode:pinned-threads:v1",
+ JSON.stringify({
+ state: { pinnedThreadIds: ["thread-b", "unknown", "thread-b"] },
+ version: 0,
+ }),
+ );
+ storage.setItem(
+ "jcode:pinned-threads:v1",
+ JSON.stringify({ state: { pinnedThreadIds: ["thread-a"] }, version: 0 }),
+ );
+ storage.setItem("t3code:pinned-threads:v1", JSON.stringify(["thread-b"]));
+
+ // When
+ const candidates = readSidebarLayoutLegacyCandidates(storage, null, hydrated);
+
+ // Then
+ expect(candidates).toEqual({
+ projectOrder: [projectA, projectB],
+ pinnedThreadOrder: [threadA, threadB],
+ });
+ });
+
+ it("returns empty candidates when storage is missing or malformed", () => {
+ // Given
+ const storage = new MemoryStorage();
+ storage.setItem("jcode:renderer-state:v8", "not-json");
+ storage.setItem("dpcode:renderer-state:v8", JSON.stringify({ projectOrderCwds: [7, null] }));
+ storage.setItem("jcode:pinned-threads:v1", JSON.stringify({ state: { pinnedThreadIds: 4 } }));
+
+ // When
+ const candidates = readSidebarLayoutLegacyCandidates(storage, null, hydrated);
+
+ // Then
+ expect(candidates).toEqual({ projectOrder: [], pinnedThreadOrder: [] });
+ });
+
+ it("does not return initialization candidates when the server layout is already initialized", () => {
+ // Given
+ const storage = new MemoryStorage();
+ storage.setItem("jcode:renderer-state:v8", JSON.stringify({ projectOrderCwds: ["/work/a"] }));
+
+ // When
+ const candidates = readSidebarLayoutLegacyCandidates(storage, confirmedLayout, hydrated);
+
+ // Then
+ expect(candidates).toBeNull();
+ });
+
+ it("does not return initialization candidates after a marked reload", () => {
+ // Given
+ const storage = new MemoryStorage();
+ storage.setItem(SIDEBAR_LAYOUT_MIGRATION_MARKER_KEY, "1");
+ storage.setItem("dpcode:pinned-threads:v1", JSON.stringify(["thread-a"]));
+
+ // When
+ const candidates = readSidebarLayoutLegacyCandidates(storage, null, hydrated);
+
+ // Then
+ expect(candidates).toBeNull();
+ });
+
+ it("does not return initialization candidates when storage access fails", () => {
+ // Given
+ const storage = new FailingReadStorage();
+
+ // When
+ const candidates = readSidebarLayoutLegacyCandidates(storage, null, hydrated);
+
+ // Then
+ expect(candidates).toBeNull();
+ });
+});
+
+describe("sidebar layout legacy cleanup", () => {
+ it("preserves expansion and local names while removing only legacy authority fields", () => {
+ // Given
+ const storage = new MemoryStorage();
+ storage.setItem(
+ "jcode:renderer-state:v8",
+ JSON.stringify({
+ expandedProjectCwds: ["/work/a"],
+ projectOrderCwds: ["/work/b", "/work/a"],
+ projectNamesByCwd: { "/work/a": "Local A" },
+ }),
+ );
+ storage.setItem(
+ "t3code:renderer-state:v7",
+ JSON.stringify({ expandedProjectCwds: ["/work/b"], projectOrderCwds: ["/work/b"] }),
+ );
+ storage.setItem("jcode:pinned-threads:v1", "current pins");
+ storage.setItem("dpcode:pinned-threads:v1", "dp pins");
+ storage.setItem("t3code:pinned-threads:v1", "t3 pins");
+
+ // When
+ const cleaned = confirmSidebarLayoutLegacyMigration(storage, confirmedLayout);
+
+ // Then
+ expect(cleaned).toBe(true);
+ expect(JSON.parse(storage.getItem("jcode:renderer-state:v8") ?? "null")).toEqual({
+ expandedProjectCwds: ["/work/a"],
+ projectNamesByCwd: { "/work/a": "Local A" },
+ });
+ expect(JSON.parse(storage.getItem("t3code:renderer-state:v7") ?? "null")).toEqual({
+ expandedProjectCwds: ["/work/b"],
+ });
+ expect(storage.getItem("jcode:pinned-threads:v1")).toBeNull();
+ expect(storage.getItem("dpcode:pinned-threads:v1")).toBeNull();
+ expect(storage.getItem("t3code:pinned-threads:v1")).toBeNull();
+ expect(storage.getItem(SIDEBAR_LAYOUT_MIGRATION_MARKER_KEY)).toBe("1");
+ });
+
+ it("does not clean any field before initialized server state is confirmed", () => {
+ // Given
+ const storage = new MemoryStorage();
+ const rendererValue = JSON.stringify({ projectOrderCwds: ["/work/a"], extra: true });
+ storage.setItem("jcode:renderer-state:v8", rendererValue);
+ storage.setItem("jcode:pinned-threads:v1", "pins");
+
+ // When
+ const cleaned = confirmSidebarLayoutLegacyMigration(storage, null);
+
+ // Then
+ expect(cleaned).toBe(false);
+ expect(storage.getItem("jcode:renderer-state:v8")).toBe(rendererValue);
+ expect(storage.getItem("jcode:pinned-threads:v1")).toBe("pins");
+ expect(storage.getItem(SIDEBAR_LAYOUT_MIGRATION_MARKER_KEY)).toBeNull();
+ });
+
+ it("retries remaining field cleanup after an interrupted marked attempt", () => {
+ // Given
+ const storage = new MemoryStorage();
+ storage.setItem("jcode:renderer-state:v8", JSON.stringify({ projectOrderCwds: ["/work/a"] }));
+ storage.setItem("dpcode:pinned-threads:v1", "pins");
+ const interruptedStorage = new FailOnceRemoveStorage(storage);
+
+ // When
+ const firstAttempt = confirmSidebarLayoutLegacyMigration(interruptedStorage, confirmedLayout);
+ const retry = confirmSidebarLayoutLegacyMigration(interruptedStorage, confirmedLayout);
+
+ // Then
+ expect(firstAttempt).toBe(false);
+ expect(retry).toBe(true);
+ expect(storage.getItem(SIDEBAR_LAYOUT_MIGRATION_MARKER_KEY)).toBe("1");
+ expect(storage.getItem("dpcode:pinned-threads:v1")).toBeNull();
+ expect(JSON.parse(storage.getItem("jcode:renderer-state:v8") ?? "null")).toEqual({});
+ });
+
+ it("does not replay stale candidates after cleanup is interrupted once the marker is durable", () => {
+ // Given: cleanup can write the marker but is interrupted while removing a pin key.
+ const storage = new MemoryStorage();
+ storage.setItem(
+ "jcode:renderer-state:v8",
+ JSON.stringify({ expandedProjectCwds: ["/work/a"], projectOrderCwds: ["/work/a"] }),
+ );
+ storage.setItem("dpcode:pinned-threads:v1", JSON.stringify(["thread-a"]));
+ const interruptedStorage = new FailOnceRemoveStorage(storage);
+
+ // When: an initialized layout is confirmed and an old profile reload sees a null snapshot.
+ const cleaned = confirmSidebarLayoutLegacyMigration(interruptedStorage, confirmedLayout);
+ const reloadedCandidates = readSidebarLayoutLegacyCandidates(storage, null, hydrated);
+
+ // Then: stale values cannot initialize again, while presentation state remains intact.
+ expect(cleaned).toBe(false);
+ expect(storage.getItem(SIDEBAR_LAYOUT_MIGRATION_MARKER_KEY)).toBe("1");
+ expect(reloadedCandidates).toBeNull();
+ expect(JSON.parse(storage.getItem("jcode:renderer-state:v8") ?? "null")).toEqual({
+ expandedProjectCwds: ["/work/a"],
+ });
+ });
+});
diff --git a/apps/web/src/sidebarLayoutLegacyMigration.ts b/apps/web/src/sidebarLayoutLegacyMigration.ts
new file mode 100644
index 000000000..81c6db89c
--- /dev/null
+++ b/apps/web/src/sidebarLayoutLegacyMigration.ts
@@ -0,0 +1,196 @@
+// FILE: sidebarLayoutLegacyMigration.ts
+// Purpose: Reads and retires one-time browser authority for sidebar order and pins.
+// Layer: Web migration utility
+// Exports: candidate reader, confirmed-state cleanup, durable marker key
+
+import type { ProjectId, SidebarLayout, ThreadId } from "@jcode/contracts";
+import { normalizeWorkspaceRootForComparison } from "@jcode/shared/threadWorkspace";
+
+export const SIDEBAR_LAYOUT_MIGRATION_MARKER_KEY = "jcode:sidebar-layout-migrated:v1";
+
+const RENDERER_STATE_KEYS = [
+ "jcode:renderer-state:v8",
+ "dpcode:renderer-state:v8",
+ "t3code:renderer-state:v8",
+ "t3code:renderer-state:v7",
+ "t3code:renderer-state:v6",
+ "t3code:renderer-state:v5",
+ "t3code:renderer-state:v4",
+ "t3code:renderer-state:v3",
+] as const;
+
+const PINNED_THREAD_KEYS = [
+ "jcode:pinned-threads:v1",
+ "dpcode:pinned-threads:v1",
+ "t3code:pinned-threads:v1",
+] as const;
+
+export type HydratedSidebarLayoutSubjects = {
+ readonly projects: readonly {
+ readonly id: ProjectId;
+ readonly workspaceRoot: string;
+ }[];
+ readonly threadIds: readonly ThreadId[];
+};
+
+export type SidebarLayoutLegacyCandidates = Pick<
+ SidebarLayout,
+ "projectOrder" | "pinnedThreadOrder"
+>;
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function parseJson(raw: string): unknown | null {
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ return parsed;
+ } catch (error) {
+ if (error instanceof SyntaxError) {
+ return null;
+ }
+ throw error;
+ }
+}
+
+function stringItems(value: unknown): readonly string[] {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+ return value.filter((candidate): candidate is string => typeof candidate === "string");
+}
+
+function rendererProjectOrder(raw: string): readonly string[] {
+ const parsed = parseJson(raw);
+ if (!isRecord(parsed)) {
+ return [];
+ }
+ if (isRecord(parsed["state"])) {
+ return stringItems(parsed["state"]["projectOrderCwds"]);
+ }
+ return stringItems(parsed["projectOrderCwds"]);
+}
+
+function parsePinnedThreadOrder(raw: string): readonly string[] {
+ const parsed = parseJson(raw);
+ if (Array.isArray(parsed)) {
+ return stringItems(parsed);
+ }
+ if (!isRecord(parsed)) {
+ return [];
+ }
+ if (isRecord(parsed["state"])) {
+ return stringItems(parsed["state"]["pinnedThreadIds"]);
+ }
+ return stringItems(parsed["pinnedThreadIds"]);
+}
+
+function collectStoredValues(storage: Storage, keys: readonly string[]): readonly string[] {
+ const values: string[] = [];
+ for (const key of keys) {
+ const value = storage.getItem(key);
+ if (value !== null) {
+ values.push(value);
+ }
+ }
+ return values;
+}
+
+export function readSidebarLayoutLegacyCandidates(
+ storage: Storage,
+ serverLayout: SidebarLayout | null,
+ subjects: HydratedSidebarLayoutSubjects,
+): SidebarLayoutLegacyCandidates | null {
+ if (serverLayout !== null) {
+ return null;
+ }
+
+ try {
+ if (storage.getItem(SIDEBAR_LAYOUT_MIGRATION_MARKER_KEY) !== null) {
+ return null;
+ }
+
+ const projectByRoot = new Map(
+ subjects.projects.map((project) => [
+ normalizeWorkspaceRootForComparison(project.workspaceRoot),
+ project.id,
+ ]),
+ );
+ const projectOrder: ProjectId[] = [];
+ for (const raw of collectStoredValues(storage, RENDERER_STATE_KEYS)) {
+ for (const root of rendererProjectOrder(raw)) {
+ const projectId = projectByRoot.get(normalizeWorkspaceRootForComparison(root));
+ if (projectId !== undefined && !projectOrder.includes(projectId)) {
+ projectOrder.push(projectId);
+ }
+ }
+ }
+
+ const hydratedThreadIds = new Map(
+ subjects.threadIds.map((threadId) => [threadId, threadId]),
+ );
+ const pinnedThreadOrder: ThreadId[] = [];
+ for (const raw of collectStoredValues(storage, PINNED_THREAD_KEYS)) {
+ for (const candidate of parsePinnedThreadOrder(raw)) {
+ const threadId = hydratedThreadIds.get(candidate);
+ if (threadId !== undefined && !pinnedThreadOrder.includes(threadId)) {
+ pinnedThreadOrder.push(threadId);
+ }
+ }
+ }
+
+ return { projectOrder, pinnedThreadOrder };
+ } catch (error) {
+ if (error instanceof Error) {
+ return null;
+ }
+ throw error;
+ }
+}
+
+export function removeSidebarLayoutLegacyProjectOrder(raw: string): string | null {
+ const parsed = parseJson(raw);
+ if (!isRecord(parsed)) {
+ return null;
+ }
+ if (isRecord(parsed["state"])) {
+ const { projectOrderCwds: _projectOrder, ...presentationState } = parsed["state"];
+ const { state: _state, ...envelope } = parsed;
+ return JSON.stringify({ ...envelope, state: presentationState });
+ }
+ const { projectOrderCwds: _projectOrder, ...presentationState } = parsed;
+ return JSON.stringify(presentationState);
+}
+
+export function confirmSidebarLayoutLegacyMigration(
+ storage: Storage,
+ serverLayout: SidebarLayout | null,
+): boolean {
+ if (serverLayout === null) {
+ return false;
+ }
+
+ try {
+ storage.setItem(SIDEBAR_LAYOUT_MIGRATION_MARKER_KEY, "1");
+ for (const key of RENDERER_STATE_KEYS) {
+ const raw = storage.getItem(key);
+ if (raw === null) {
+ continue;
+ }
+ const cleaned = removeSidebarLayoutLegacyProjectOrder(raw);
+ if (cleaned !== null) {
+ storage.setItem(key, cleaned);
+ }
+ }
+ for (const key of PINNED_THREAD_KEYS) {
+ storage.removeItem(key);
+ }
+ return true;
+ } catch (error) {
+ if (error instanceof Error) {
+ return false;
+ }
+ throw error;
+ }
+}
diff --git a/apps/web/src/sidebarLayoutRouter.test.ts b/apps/web/src/sidebarLayoutRouter.test.ts
new file mode 100644
index 000000000..633375115
--- /dev/null
+++ b/apps/web/src/sidebarLayoutRouter.test.ts
@@ -0,0 +1,351 @@
+import {
+ CommandId,
+ ProjectId,
+ ThreadId,
+ type DispatchResult,
+ type OrchestrationShellStreamEvent,
+ type SidebarLayout,
+} from "@jcode/contracts";
+import { describe, expect, it } from "vitest";
+import { createSidebarLayoutRouter } from "./sidebarLayoutRouter";
+import { sidebarLayoutLegacySubjectsReady } from "./sidebarLayoutRouter";
+import {
+ createSidebarLayoutStore,
+ type SidebarLayoutCommand,
+ type SidebarLayoutDispatch,
+} from "./sidebarLayoutStore";
+
+type PendingDispatch = {
+ readonly command: SidebarLayoutCommand;
+ readonly resolve: (result: DispatchResult) => void;
+ readonly reject: (error: unknown) => void;
+};
+
+class ControllableTransport {
+ readonly commands: SidebarLayoutCommand[] = [];
+ readonly pending: PendingDispatch[] = [];
+
+ readonly dispatchCommand: SidebarLayoutDispatch = (command) => {
+ this.commands.push(command);
+ return new Promise((resolve, reject) => this.pending.push({ command, resolve, reject }));
+ };
+}
+
+const projectA = ProjectId.makeUnsafe("project-a");
+const projectB = ProjectId.makeUnsafe("project-b");
+const threadA = ThreadId.makeUnsafe("thread-a");
+const commandA = CommandId.makeUnsafe("command-a");
+const commandB = CommandId.makeUnsafe("command-b");
+
+const lifecycle = {
+ projects: [
+ { id: projectA, kind: "project", createdAt: "2026-01-01T00:00:00Z", deletedAt: null },
+ { id: projectB, kind: "project", createdAt: "2026-01-02T00:00:00Z", deletedAt: null },
+ ],
+ threads: [{ id: threadA, deletedAt: null }],
+} as const;
+
+function layout(revision: number, projectOrder = [projectA, projectB]): SidebarLayout {
+ return {
+ projectOrder,
+ pinnedThreadOrder: [threadA],
+ revision,
+ updatedAt: `2026-07-18T00:00:${String(revision).padStart(2, "0")}.000Z`,
+ };
+}
+
+function layoutEvent(revision: number, projectOrder = [projectA, projectB]) {
+ return {
+ kind: "sidebar-layout-updated",
+ sequence: revision,
+ sidebarLayout: layout(revision, projectOrder),
+ } satisfies OrchestrationShellStreamEvent;
+}
+
+async function flushDispatch(): Promise {
+ await Promise.resolve();
+ await Promise.resolve();
+}
+
+describe("sidebar layout router", () => {
+ it("defers transient empty streams but treats an authoritative empty query as ready", () => {
+ // Given: both observations contain a genuinely empty lifecycle.
+ const emptyLifecycle = { projects: [], threads: [] } as const;
+
+ // When/Then: stream emptiness is provisional, while the bootstrap query is authoritative.
+ expect(sidebarLayoutLegacySubjectsReady("stream", emptyLifecycle)).toBe(false);
+ expect(sidebarLayoutLegacySubjectsReady("query", emptyLifecycle)).toBe(true);
+ expect(sidebarLayoutLegacySubjectsReady("stream", lifecycle)).toBe(true);
+ expect(
+ sidebarLayoutLegacySubjectsReady("stream", { projects: lifecycle.projects, threads: [] }),
+ ).toBe(true);
+ expect(
+ sidebarLayoutLegacySubjectsReady("stream", { projects: [], threads: lifecycle.threads }),
+ ).toBe(true);
+ });
+
+ it("initializes only after a null snapshot and legacy candidates are ready", async () => {
+ // Given: a hydrated null snapshot whose candidate read has not completed.
+ const transport = new ControllableTransport();
+ const store = createSidebarLayoutStore({
+ dispatchCommand: transport.dispatchCommand,
+ createCommandId: () => commandA,
+ });
+ const router = createSidebarLayoutRouter({ store });
+ router.acceptSnapshot({ sidebarLayout: null, lifecycle });
+ await flushDispatch();
+ expect(transport.commands).toEqual([]);
+
+ // When: the same hydrated snapshot is delivered with ready candidates.
+ router.acceptSnapshot({
+ sidebarLayout: null,
+ lifecycle,
+ legacyCandidates: { projectOrder: [projectB], pinnedThreadOrder: [threadA] },
+ });
+ await flushDispatch();
+
+ // Then: exactly one initialize command uses those candidates.
+ expect(transport.commands).toEqual([
+ {
+ type: "sidebar-layout.initialize",
+ commandId: commandA,
+ projectOrder: [projectB],
+ pinnedThreadOrder: [threadA],
+ },
+ ]);
+ router.acceptSnapshot({
+ sidebarLayout: null,
+ lifecycle,
+ legacyCandidates: { projectOrder: [], pinnedThreadOrder: [] },
+ });
+ await flushDispatch();
+ expect(transport.commands).toHaveLength(1);
+ });
+
+ it("adopts the newest layout for event-before-snapshot and snapshot-before-event", () => {
+ // Given: an event arrives before its older snapshot.
+ const store = createSidebarLayoutStore({ dispatchCommand: async () => ({ sequence: 1 }) });
+ const router = createSidebarLayoutRouter({ store });
+ router.acceptShellEvent(layoutEvent(5, [projectB, projectA]));
+
+ // When: an older initialized snapshot and then a newer event arrive.
+ router.acceptSnapshot({
+ sidebarLayout: layout(4),
+ lifecycle,
+ legacyCandidates: null,
+ });
+ router.acceptShellEvent(layoutEvent(6));
+
+ // Then: confirmation is monotonic by layout revision only.
+ expect(store.getState().confirmedLayout).toEqual(layout(6));
+ expect(store.getState().pendingIntents).toEqual([]);
+ });
+
+ it("normalizes displayed layout when shell lifecycle events remove entities", () => {
+ // Given: canonical layout references two live projects and one pinned thread.
+ const store = createSidebarLayoutStore({ dispatchCommand: async () => ({ sequence: 1 }) });
+ const router = createSidebarLayoutRouter({ store });
+ router.acceptSnapshot({ sidebarLayout: layout(1), lifecycle, legacyCandidates: null });
+
+ // When: the shell removes one project and the pinned thread.
+ router.acceptShellEvent({ kind: "project-removed", sequence: 2, projectId: projectB });
+ router.acceptShellEvent({ kind: "thread-removed", sequence: 3, threadId: threadA });
+
+ // Then: lifecycle state immediately excludes both removed entities.
+ expect(store.getState().lifecycle).toEqual({
+ projects: [lifecycle.projects[0]],
+ threads: [],
+ });
+ });
+
+ it("does not initialize from a stale null snapshot after a live layout event", async () => {
+ // Given: another client initializes before this client's first snapshot.
+ const transport = new ControllableTransport();
+ const store = createSidebarLayoutStore({
+ dispatchCommand: transport.dispatchCommand,
+ createCommandId: () => commandA,
+ });
+ const router = createSidebarLayoutRouter({ store });
+ router.acceptShellEvent(layoutEvent(7, [projectB, projectA]));
+
+ // When: a stale null snapshot is delivered with cached-marker/no-candidate state.
+ router.acceptSnapshot({
+ sidebarLayout: null,
+ lifecycle,
+ legacyCandidates: { projectOrder: [], pinnedThreadOrder: [] },
+ });
+ await flushDispatch();
+
+ // Then: canonical state wins and no initializer is sent.
+ expect(store.getState().confirmedLayout).toEqual(layout(7, [projectB, projectA]));
+ expect(transport.commands).toEqual([]);
+ });
+
+ it("retries a lost initializer response with the same ID then adopts reconnect state", async () => {
+ // Given: initialize was sent but its response was lost.
+ const transport = new ControllableTransport();
+ const store = createSidebarLayoutStore({
+ dispatchCommand: transport.dispatchCommand,
+ createCommandId: () => commandA,
+ });
+ const router = createSidebarLayoutRouter({ store });
+ router.acceptSnapshot({
+ sidebarLayout: null,
+ lifecycle,
+ legacyCandidates: { projectOrder: [], pinnedThreadOrder: [] },
+ });
+ await flushDispatch();
+
+ // When: reconnect retries and the server snapshot reports a winning canonical layout.
+ expect(router.reconnect()).toBe(true);
+ await flushDispatch();
+ router.acceptSnapshot({
+ sidebarLayout: layout(9, [projectB, projectA]),
+ lifecycle,
+ legacyCandidates: null,
+ });
+
+ // Then: the same command ID was reused and no initialization remains pending.
+ expect(transport.commands.map((command) => command.commandId)).toEqual([commandA, commandA]);
+ expect(store.getState().confirmedLayout).toEqual(layout(9, [projectB, projectA]));
+ expect(store.getState().pendingIntents).toEqual([]);
+ expect(store.getState().inFlightCommandId).toBeNull();
+ });
+
+ it("does not initialize when a durable marker suppresses legacy candidates on reload", async () => {
+ // Given: candidate collection reports that this profile already completed migration.
+ const transport = new ControllableTransport();
+ const store = createSidebarLayoutStore({
+ dispatchCommand: transport.dispatchCommand,
+ createCommandId: () => commandA,
+ });
+ const router = createSidebarLayoutRouter({ store });
+
+ // When: an old null snapshot reaches the reloaded client.
+ router.acceptSnapshot({ sidebarLayout: null, lifecycle, legacyCandidates: null });
+ await flushDispatch();
+
+ // Then: stale browser values cannot cause another initialization attempt.
+ expect(transport.commands).toEqual([]);
+ expect(store.getState().pendingIntents).toEqual([]);
+ });
+
+ it("confirms legacy cleanup after another client initializes and retries interrupted cleanup", () => {
+ // Given: another client wins while this profile still contains legacy candidates.
+ const store = createSidebarLayoutStore({ dispatchCommand: async () => ({ sequence: 1 }) });
+ const cleanupAttempts: SidebarLayout[] = [];
+ const router = createSidebarLayoutRouter({
+ store,
+ confirmLegacyMigration: (confirmed) => {
+ cleanupAttempts.push(confirmed);
+ return cleanupAttempts.length > 1;
+ },
+ });
+
+ // When: the winning event is followed by a reconnect snapshot after cleanup was interrupted.
+ router.acceptShellEvent(layoutEvent(7, [projectB, projectA]));
+ router.acceptSnapshot({
+ sidebarLayout: layout(7, [projectB, projectA]),
+ lifecycle,
+ legacyCandidates: { projectOrder: [projectA], pinnedThreadOrder: [threadA] },
+ });
+
+ // Then: canonical server state is adopted, cleanup retries, and no initializer is submitted.
+ expect(store.getState().confirmedLayout).toEqual(layout(7, [projectB, projectA]));
+ expect(cleanupAttempts).toEqual([
+ layout(7, [projectB, projectA]),
+ layout(7, [projectB, projectA]),
+ ]);
+ expect(store.getState().pendingIntents).toEqual([]);
+ });
+
+ it("stops legacy cleanup after it succeeds", () => {
+ // Given: a cleanup callback that succeeds on its first initialized observation.
+ const store = createSidebarLayoutStore({ dispatchCommand: async () => ({ sequence: 1 }) });
+ const cleanupAttempts: SidebarLayout[] = [];
+ const router = createSidebarLayoutRouter({
+ store,
+ confirmLegacyMigration: (confirmed) => {
+ cleanupAttempts.push(confirmed);
+ return true;
+ },
+ });
+
+ // When: later initialized snapshots and events arrive.
+ router.acceptSnapshot({ sidebarLayout: layout(4), lifecycle, legacyCandidates: null });
+ router.acceptShellEvent(layoutEvent(5, [projectB, projectA]));
+
+ // Then: the durable migration cleanup is not repeated in this client session.
+ expect(cleanupAttempts).toEqual([layout(4)]);
+ });
+
+ it("retries interrupted cleanup from a later canonical-only snapshot without regressing layout", () => {
+ // Given: the first canonical event is adopted but legacy cleanup is interrupted.
+ const store = createSidebarLayoutStore({ dispatchCommand: async () => ({ sequence: 1 }) });
+ const cleanupAttempts: SidebarLayout[] = [];
+ const router = createSidebarLayoutRouter({
+ store,
+ confirmLegacyMigration: (confirmed) => {
+ cleanupAttempts.push(confirmed);
+ return cleanupAttempts.length > 1;
+ },
+ });
+ router.acceptShellEvent(layoutEvent(7, [projectB, projectA]));
+
+ // When: a fallback/bootstrap query later observes an older canonical layout that must not
+ // reapply lifecycle or candidates.
+ router.acceptConfirmedLayout(layout(6));
+
+ // Then: cleanup retries against the newest accepted layout and canonical revision is monotonic.
+ expect(store.getState().confirmedLayout).toEqual(layout(7, [projectB, projectA]));
+ expect(cleanupAttempts).toEqual([
+ layout(7, [projectB, projectA]),
+ layout(7, [projectB, projectA]),
+ ]);
+ expect(store.getState().lifecycle).toEqual({ projects: [], threads: [] });
+ expect(store.getState().pendingIntents).toEqual([]);
+ });
+
+ it("offers a typed retry after initialize rejection and adopts a winning canonical layout", async () => {
+ // Given: the first initialization attempt is rejected.
+ const transport = new ControllableTransport();
+ const commandIds = [commandA, commandB];
+ let commandIndex = 0;
+ let retryInitialization: (() => boolean) | null = null;
+ const rejectedErrors: unknown[] = [];
+ const store = createSidebarLayoutStore({
+ dispatchCommand: transport.dispatchCommand,
+ createCommandId: () => commandIds[commandIndex++] ?? commandB,
+ });
+ const router = createSidebarLayoutRouter({
+ store,
+ onInitializationRejected: (failure) => {
+ rejectedErrors.push(failure.error);
+ retryInitialization = failure.retry;
+ },
+ });
+ router.acceptSnapshot({
+ sidebarLayout: null,
+ lifecycle,
+ legacyCandidates: { projectOrder: [projectB], pinnedThreadOrder: [threadA] },
+ });
+ await flushDispatch();
+ const rejection = new Error("initialization rejected");
+ transport.pending[0]?.reject(rejection);
+ await flushDispatch();
+
+ // When: the targeted recovery action retries and another client wins before its response.
+ expect(retryInitialization).not.toBeNull();
+ expect((retryInitialization as (() => boolean) | null)?.()).toBe(true);
+ await flushDispatch();
+ router.acceptShellEvent(layoutEvent(9, [projectA, projectB]));
+
+ // Then: recovery is observable, uses a fresh command, and canonical state clears the retry.
+ expect(rejectedErrors).toEqual([rejection]);
+ expect(transport.commands.map((command) => command.commandId)).toEqual([commandA, commandB]);
+ expect(store.getState().confirmedLayout).toEqual(layout(9, [projectA, projectB]));
+ expect(store.getState().pendingIntents).toEqual([]);
+ expect(store.getState().inFlightCommandId).toBeNull();
+ expect((retryInitialization as (() => boolean) | null)?.()).toBe(false);
+ });
+});
diff --git a/apps/web/src/sidebarLayoutRouter.ts b/apps/web/src/sidebarLayoutRouter.ts
new file mode 100644
index 000000000..c3e6ba58b
--- /dev/null
+++ b/apps/web/src/sidebarLayoutRouter.ts
@@ -0,0 +1,163 @@
+import type { OrchestrationShellStreamEvent, SidebarLayout } from "@jcode/contracts";
+import type { StoreApi } from "zustand/vanilla";
+import type { SidebarLayoutLifecycle } from "./sidebarLayout.logic";
+import type { SidebarLayoutLegacyCandidates } from "./sidebarLayoutLegacyMigration";
+import type { SidebarLayoutStoreState } from "./sidebarLayoutStore";
+
+export type SidebarLayoutRouterSnapshot = {
+ readonly sidebarLayout: SidebarLayout | null;
+ readonly lifecycle: SidebarLayoutLifecycle;
+ readonly legacyCandidates?: SidebarLayoutLegacyCandidates | null;
+};
+
+export type SidebarLayoutRouterDependencies = {
+ readonly store: Pick, "getState">;
+ readonly confirmLegacyMigration?: (layout: SidebarLayout) => boolean;
+ readonly onInitializationRejected?: (failure: SidebarLayoutInitializationFailure) => void;
+};
+
+export type SidebarLayoutInitializationFailure = {
+ readonly error: unknown;
+ readonly retry: () => boolean;
+};
+
+export type SidebarLayoutSnapshotSource = "stream" | "query";
+
+export function sidebarLayoutLegacySubjectsReady(
+ source: SidebarLayoutSnapshotSource,
+ lifecycle: SidebarLayoutLifecycle,
+): boolean {
+ return source === "query" || lifecycle.projects.length > 0 || lifecycle.threads.length > 0;
+}
+
+function applyLifecycleShellEvent(
+ lifecycle: SidebarLayoutLifecycle,
+ event: Exclude,
+): SidebarLayoutLifecycle {
+ switch (event.kind) {
+ case "project-upserted":
+ return {
+ ...lifecycle,
+ projects: [
+ ...lifecycle.projects.filter((project) => project.id !== event.project.id),
+ {
+ id: event.project.id,
+ kind: event.project.kind,
+ createdAt: event.project.createdAt,
+ deletedAt: null,
+ },
+ ],
+ };
+ case "project-removed":
+ return {
+ ...lifecycle,
+ projects: lifecycle.projects.filter((project) => project.id !== event.projectId),
+ };
+ case "thread-upserted":
+ return {
+ ...lifecycle,
+ threads: [
+ ...lifecycle.threads.filter((thread) => thread.id !== event.thread.id),
+ { id: event.thread.id, deletedAt: null },
+ ],
+ };
+ case "thread-removed":
+ return {
+ ...lifecycle,
+ threads: lifecycle.threads.filter((thread) => thread.id !== event.threadId),
+ };
+ }
+}
+
+export function createSidebarLayoutRouter(dependencies: SidebarLayoutRouterDependencies) {
+ let initializationAttempted = false;
+ let initializationCandidates: SidebarLayoutLegacyCandidates | null = null;
+ let legacyCleanupComplete = false;
+
+ const confirmLegacyMigration = (): void => {
+ if (legacyCleanupComplete || dependencies.confirmLegacyMigration === undefined) {
+ return;
+ }
+ const confirmedLayout = dependencies.store.getState().confirmedLayout;
+ if (confirmedLayout !== null) {
+ legacyCleanupComplete = dependencies.confirmLegacyMigration(confirmedLayout);
+ }
+ };
+
+ const acceptConfirmedLayout = (layout: SidebarLayout | null): void => {
+ dependencies.store.getState().acceptConfirmedLayout(layout);
+ confirmLegacyMigration();
+ };
+
+ const retryInitialization = (): boolean => {
+ if (
+ initializationAttempted ||
+ initializationCandidates === null ||
+ dependencies.store.getState().confirmedLayout !== null
+ ) {
+ return false;
+ }
+ enqueueInitialization(initializationCandidates);
+ return true;
+ };
+
+ function enqueueInitialization(candidates: SidebarLayoutLegacyCandidates): void {
+ initializationAttempted = true;
+ initializationCandidates = candidates;
+ dependencies.store.getState().enqueue(
+ {
+ type: "sidebar-layout.initialize",
+ projectOrder: candidates.projectOrder,
+ pinnedThreadOrder: candidates.pinnedThreadOrder,
+ },
+ {
+ onRejected: (error) => {
+ initializationAttempted = false;
+ dependencies.onInitializationRejected?.({ error, retry: retryInitialization });
+ },
+ },
+ );
+ }
+
+ return {
+ acceptConfirmedLayout,
+ acceptSnapshot: (snapshot: SidebarLayoutRouterSnapshot): void => {
+ const state = dependencies.store.getState();
+ state.setLifecycle(snapshot.lifecycle);
+ acceptConfirmedLayout(snapshot.sidebarLayout);
+
+ if (
+ snapshot.sidebarLayout !== null ||
+ dependencies.store.getState().confirmedLayout !== null
+ ) {
+ initializationAttempted = true;
+ return;
+ }
+ if (
+ snapshot.legacyCandidates === undefined ||
+ snapshot.legacyCandidates === null ||
+ initializationAttempted
+ ) {
+ return;
+ }
+
+ enqueueInitialization(snapshot.legacyCandidates);
+ },
+ acceptShellEvent: (event: OrchestrationShellStreamEvent): void => {
+ switch (event.kind) {
+ case "sidebar-layout-updated":
+ acceptConfirmedLayout(event.sidebarLayout);
+ return;
+ case "project-upserted":
+ case "project-removed":
+ case "thread-upserted":
+ case "thread-removed":
+ dependencies.store
+ .getState()
+ .setLifecycle(applyLifecycleShellEvent(dependencies.store.getState().lifecycle, event));
+ return;
+ }
+ },
+ reconnect: (): boolean => dependencies.store.getState().retryInFlight(),
+ };
+}
diff --git a/apps/web/src/sidebarLayoutStore.test.ts b/apps/web/src/sidebarLayoutStore.test.ts
new file mode 100644
index 000000000..beb310e18
--- /dev/null
+++ b/apps/web/src/sidebarLayoutStore.test.ts
@@ -0,0 +1,503 @@
+import {
+ CommandId,
+ ProjectId,
+ ThreadId,
+ type DispatchResult,
+ type SidebarLayout,
+} from "@jcode/contracts";
+import { describe, expect, it } from "vitest";
+import {
+ createSidebarLayoutStore,
+ selectDisplayedPinnedThreadOrder,
+ selectDisplayedProjectOrder,
+ type SidebarLayoutCommand,
+ type SidebarLayoutDispatch,
+} from "./sidebarLayoutStore";
+
+type PendingDispatch = {
+ readonly command: SidebarLayoutCommand;
+ readonly resolve: (result: DispatchResult) => void;
+ readonly reject: (error: Error) => void;
+};
+
+class ControllableTransport {
+ readonly commands: SidebarLayoutCommand[] = [];
+ readonly pending: PendingDispatch[] = [];
+
+ readonly dispatchCommand: SidebarLayoutDispatch = (command) => {
+ this.commands.push(command);
+ return new Promise((resolve, reject) => {
+ this.pending.push({ command, resolve, reject });
+ });
+ };
+
+ resolveAttempt(index: number, sequence: number): void {
+ this.pending[index]?.resolve({ sequence });
+ }
+
+ rejectAttempt(index: number, message: string): void {
+ this.pending[index]?.reject(new Error(message));
+ }
+}
+
+const projectA = ProjectId.makeUnsafe("project-a");
+const projectB = ProjectId.makeUnsafe("project-b");
+const projectC = ProjectId.makeUnsafe("project-c");
+const threadA = ThreadId.makeUnsafe("thread-a");
+const threadB = ThreadId.makeUnsafe("thread-b");
+const commandA = CommandId.makeUnsafe("command-a");
+const commandB = CommandId.makeUnsafe("command-b");
+
+const layout = (
+ revision: number,
+ projectOrder: readonly ProjectId[] = [projectA, projectB],
+ pinnedThreadOrder: readonly ThreadId[] = [threadA],
+): SidebarLayout => ({
+ projectOrder,
+ pinnedThreadOrder,
+ revision,
+ updatedAt: `2026-07-18T00:00:${String(revision).padStart(2, "0")}.000Z`,
+});
+
+const lifecycle = {
+ projects: [
+ { id: projectA, kind: "project", createdAt: "2026-01-01T00:00:00Z", deletedAt: null },
+ { id: projectB, kind: "project", createdAt: "2026-01-02T00:00:00Z", deletedAt: null },
+ { id: projectC, kind: "project", createdAt: "2026-01-03T00:00:00Z", deletedAt: null },
+ ],
+ threads: [
+ { id: threadA, deletedAt: null },
+ { id: threadB, deletedAt: null },
+ ],
+} as const;
+
+async function flushDispatch(): Promise {
+ await Promise.resolve();
+ await Promise.resolve();
+}
+
+describe("sidebar layout store", () => {
+ it("returns a stable displayed selector value while state is unchanged", () => {
+ // Given: one immutable store state object.
+ const state = createSidebarLayoutStore({
+ dispatchCommand: async () => ({ sequence: 1 }),
+ }).getState();
+ // When: displayed state is selected twice.
+ const first = selectDisplayedProjectOrder(state);
+ // Then: React external-store consumers receive the same snapshot reference.
+ expect(selectDisplayedProjectOrder(state)).toBe(first);
+ });
+
+ it("queues a session-only optimistic intent", () => {
+ // Given: a new store with a dispatch transport.
+ const store = createSidebarLayoutStore({
+ dispatchCommand: async () => ({ sequence: 1 }),
+ });
+
+ // When: a layout intent is enqueued.
+ store.getState().enqueue({
+ type: "sidebar-layout.initialize",
+ projectOrder: [],
+ pinnedThreadOrder: [],
+ });
+
+ // Then: the intent is visible in session state.
+ expect(store.getState().pendingIntents).toHaveLength(1);
+ });
+
+ it("dispatches rapid intents sequentially with their exact generated command IDs", async () => {
+ // Given: a transport whose first response is controllable and deterministic command IDs.
+ const transport = new ControllableTransport();
+ const commandIds = [commandA, commandB];
+ const store = createSidebarLayoutStore({
+ dispatchCommand: transport.dispatchCommand,
+ createCommandId: () => commandIds.shift() ?? CommandId.makeUnsafe("unexpected-command"),
+ });
+
+ // When: two actions are enqueued before the first RPC completes.
+ store.getState().enqueue({
+ type: "sidebar-layout.project.move",
+ projectId: projectA,
+ beforeProjectId: null,
+ });
+ store.getState().enqueue({
+ type: "sidebar-layout.thread.unpin",
+ threadId: threadA,
+ });
+ await flushDispatch();
+
+ // Then: only the first exact command is dispatched.
+ expect(transport.commands).toEqual([
+ {
+ type: "sidebar-layout.project.move",
+ commandId: commandA,
+ projectId: projectA,
+ beforeProjectId: null,
+ },
+ ]);
+
+ transport.resolveAttempt(0, 7);
+ await flushDispatch();
+ expect(transport.commands[1]).toEqual({
+ type: "sidebar-layout.thread.unpin",
+ commandId: commandB,
+ threadId: threadA,
+ });
+ });
+
+ it("clears an event-before-RPC intent only after its accepted sequence is known", async () => {
+ // Given: a dispatched optimistic move whose canonical event arrives first.
+ const transport = new ControllableTransport();
+ const store = createSidebarLayoutStore({
+ dispatchCommand: transport.dispatchCommand,
+ createCommandId: () => commandA,
+ });
+ store.getState().setLifecycle(lifecycle);
+ store.getState().enqueue({
+ type: "sidebar-layout.project.move",
+ projectId: projectA,
+ beforeProjectId: null,
+ });
+ await flushDispatch();
+
+ // When: revision 7 is confirmed before the RPC receipt.
+ store.getState().acceptConfirmedLayout(layout(7, [projectB, projectA, projectC]));
+
+ // Then: the unacknowledged intent remains optimistic.
+ expect(store.getState().pendingIntents).toHaveLength(1);
+
+ transport.resolveAttempt(0, 7);
+ await flushDispatch();
+ expect(store.getState().pendingIntents).toEqual([]);
+ });
+
+ it("keeps an RPC-before-event intent until the confirmed layout reaches its sequence", async () => {
+ // Given: an optimistic pin accepted at sequence 9.
+ const transport = new ControllableTransport();
+ const store = createSidebarLayoutStore({
+ dispatchCommand: transport.dispatchCommand,
+ createCommandId: () => commandA,
+ });
+ store.getState().setLifecycle(lifecycle);
+ store.getState().enqueue({
+ type: "sidebar-layout.thread.pin",
+ threadId: threadA,
+ beforeThreadId: null,
+ });
+ await flushDispatch();
+ transport.resolveAttempt(0, 9);
+ await flushDispatch();
+
+ // When: an older layout revision arrives before revision 9.
+ store.getState().acceptConfirmedLayout(layout(8, [], []));
+ expect(store.getState().pendingIntents[0]?.acceptedSequence).toBe(9);
+ store.getState().acceptConfirmedLayout(layout(9, [], [threadA]));
+
+ // Then: the accepted intent is finally cleared.
+ expect(store.getState().pendingIntents).toEqual([]);
+ });
+
+ it("rebases displayed selectors over a newer remote layout", async () => {
+ // Given: a confirmed layout plus an optimistic local move-to-end.
+ const transport = new ControllableTransport();
+ const store = createSidebarLayoutStore({
+ dispatchCommand: transport.dispatchCommand,
+ createCommandId: () => commandA,
+ });
+ store.getState().setLifecycle(lifecycle);
+ store.getState().acceptConfirmedLayout(layout(1, [projectA, projectB, projectC]));
+ store.getState().enqueue({
+ type: "sidebar-layout.project.move",
+ projectId: projectA,
+ beforeProjectId: null,
+ });
+
+ // When: a remote command changes canonical order at revision 2.
+ store.getState().acceptConfirmedLayout(layout(2, [projectC, projectA, projectB], [threadA]));
+
+ // Then: selectors replay the pending local intent over the remote canonical order.
+ expect(selectDisplayedProjectOrder(store.getState())).toEqual([projectC, projectB, projectA]);
+ expect(selectDisplayedPinnedThreadOrder(store.getState())).toEqual([threadA]);
+ });
+
+ it("does not regress confirmed state on stale or equal conflicting revisions", () => {
+ // Given: revision 5 is confirmed.
+ const store = createSidebarLayoutStore({ dispatchCommand: async () => ({ sequence: 1 }) });
+ const confirmed = layout(5, [projectA, projectB]);
+ store.getState().acceptConfirmedLayout(confirmed);
+
+ // When: stale, equal-conflicting, and null reconnect values arrive.
+ store.getState().acceptConfirmedLayout(layout(4, [projectB, projectA]));
+ store.getState().acceptConfirmedLayout(layout(5, [projectB, projectA]));
+ store.getState().acceptConfirmedLayout(null);
+
+ // Then: the original canonical object remains authoritative.
+ expect(store.getState().confirmedLayout).toBe(confirmed);
+ });
+
+ it("starts each store session without persisted layout authority", () => {
+ // Given: one session with confirmed and pending layout state.
+ const dependencies = { dispatchCommand: async () => ({ sequence: 1 }) };
+ const firstSession = createSidebarLayoutStore(dependencies);
+ firstSession.getState().acceptConfirmedLayout(layout(1));
+ firstSession.getState().enqueue({
+ type: "sidebar-layout.thread.unpin",
+ threadId: threadA,
+ });
+
+ // When: a new session store is constructed.
+ const nextSession = createSidebarLayoutStore(dependencies);
+
+ // Then: neither confirmed nor optimistic authority is restored.
+ expect(nextSession.getState().confirmedLayout).toBeNull();
+ expect(nextSession.getState().pendingIntents).toEqual([]);
+ expect("snapshotSequence" in nextSession.getState()).toBe(false);
+ });
+
+ it("retries a lost response with the same command ID and ignores the late attempt", async () => {
+ // Given: the first command response is lost while a second intent waits.
+ const transport = new ControllableTransport();
+ const ids = [commandA, commandB];
+ const store = createSidebarLayoutStore({
+ dispatchCommand: transport.dispatchCommand,
+ createCommandId: () => ids.shift() ?? CommandId.makeUnsafe("unexpected-command"),
+ });
+ store.getState().enqueue({
+ type: "sidebar-layout.project.move",
+ projectId: projectA,
+ beforeProjectId: null,
+ });
+ store.getState().enqueue({
+ type: "sidebar-layout.project.move",
+ projectId: projectB,
+ beforeProjectId: projectA,
+ });
+ await flushDispatch();
+
+ // When: the unresolved dispatch is retried and its receipt arrives.
+ expect(store.getState().retryInFlight()).toBe(true);
+ await flushDispatch();
+ expect(transport.commands.map((command) => command.commandId)).toEqual([commandA, commandA]);
+ transport.resolveAttempt(1, 10);
+ await flushDispatch();
+
+ // Then: the queue continues and a late response cannot replace the recovered receipt.
+ expect(transport.commands[2]?.commandId).toBe(commandB);
+ transport.resolveAttempt(0, 99);
+ await flushDispatch();
+ expect(store.getState().pendingIntents[0]?.acceptedSequence).toBe(10);
+ });
+
+ it("adopts an initialized canonical layout when the initialize receipt was lost", async () => {
+ // Given: an initialize request remains in flight without a receipt.
+ const transport = new ControllableTransport();
+ const store = createSidebarLayoutStore({
+ dispatchCommand: transport.dispatchCommand,
+ createCommandId: () => commandA,
+ });
+ store.getState().enqueue({
+ type: "sidebar-layout.initialize",
+ projectOrder: [projectB, projectA],
+ pinnedThreadOrder: [threadA],
+ });
+ await flushDispatch();
+
+ // When: a reconnect snapshot proves the server has initialized canonically.
+ store.getState().acceptConfirmedLayout(layout(11, [projectA, projectB]));
+
+ // Then: the canonical layout is adopted without leaving a hung initializer.
+ expect(store.getState().pendingIntents).toEqual([]);
+ expect(store.getState().inFlightCommandId).toBeNull();
+ });
+
+ it("clears the rejection observer when adopting a lost initializer", async () => {
+ // Given: an initializer with a rejection observer remains in flight without a receipt.
+ const transport = new ControllableTransport();
+ const rejectedInitializers: unknown[] = [];
+ const store = createSidebarLayoutStore({
+ dispatchCommand: transport.dispatchCommand,
+ createCommandId: () => commandA,
+ });
+ store.getState().enqueue(
+ {
+ type: "sidebar-layout.initialize",
+ projectOrder: [projectB, projectA],
+ pinnedThreadOrder: [threadA],
+ },
+ { onRejected: (error) => rejectedInitializers.push(error) },
+ );
+ await flushDispatch();
+ store.getState().acceptConfirmedLayout(layout(11, [projectA, projectB]));
+
+ // When: the same command ID is reused for a later command without an observer and rejected.
+ store.getState().enqueue({ type: "sidebar-layout.thread.unpin", threadId: threadA });
+ await flushDispatch();
+ transport.rejectAttempt(1, "later_rejection");
+ await flushDispatch();
+
+ // Then: the removed initializer's observer cannot leak into the later command lifecycle.
+ expect(rejectedInitializers).toEqual([]);
+ });
+
+ it("rolls back a rejected intent and continues the sequential queue", async () => {
+ // Given: two optimistic moves over a confirmed canonical layout.
+ const transport = new ControllableTransport();
+ const ids = [commandA, commandB];
+ const store = createSidebarLayoutStore({
+ dispatchCommand: transport.dispatchCommand,
+ createCommandId: () => ids.shift() ?? CommandId.makeUnsafe("unexpected-command"),
+ });
+ store.getState().setLifecycle(lifecycle);
+ store.getState().acceptConfirmedLayout(layout(1, [projectA, projectB, projectC]));
+ store.getState().enqueue({
+ type: "sidebar-layout.project.move",
+ projectId: projectA,
+ beforeProjectId: null,
+ });
+ store.getState().enqueue({
+ type: "sidebar-layout.project.move",
+ projectId: projectB,
+ beforeProjectId: null,
+ });
+ await flushDispatch();
+
+ // When: the first RPC is explicitly rejected.
+ transport.rejectAttempt(0, "stale_state");
+ await flushDispatch();
+
+ // Then: its optimistic effect is gone and the second exact command is dispatched.
+ expect(store.getState().pendingIntents.map((pending) => pending.commandId)).toEqual([commandB]);
+ expect(selectDisplayedProjectOrder(store.getState())).toEqual([projectA, projectC, projectB]);
+ expect(transport.commands[1]?.commandId).toBe(commandB);
+ });
+
+ it("notifies a rejected project move after canonical rollback and continues without compensation", async () => {
+ // Given: one project move with a rejection observer and a second queued move.
+ const transport = new ControllableTransport();
+ const ids = [commandA, commandB];
+ const rejectedDisplayedOrders: Array = [];
+ const rejectedConfirmedOrders: Array = [];
+ const store = createSidebarLayoutStore({
+ dispatchCommand: transport.dispatchCommand,
+ createCommandId: () => ids.shift() ?? CommandId.makeUnsafe("unexpected-command"),
+ });
+ store.getState().setLifecycle(lifecycle);
+ store.getState().acceptConfirmedLayout(layout(1, [projectA, projectB, projectC]));
+ store.getState().enqueue(
+ {
+ type: "sidebar-layout.project.move",
+ projectId: projectA,
+ beforeProjectId: null,
+ },
+ {
+ onRejected: () => {
+ rejectedDisplayedOrders.push(selectDisplayedProjectOrder(store.getState()));
+ rejectedConfirmedOrders.push(store.getState().confirmedLayout?.projectOrder ?? []);
+ },
+ },
+ );
+ store.getState().enqueue({
+ type: "sidebar-layout.project.move",
+ projectId: projectB,
+ beforeProjectId: null,
+ });
+ await flushDispatch();
+
+ // When: the first project move is rejected.
+ transport.rejectAttempt(0, "stale_state");
+ await flushDispatch();
+
+ // Then: its observer sees canonical state with only the queued move replayed, which proceeds once.
+ expect(rejectedConfirmedOrders).toEqual([[projectA, projectB, projectC]]);
+ expect(rejectedDisplayedOrders).toEqual([[projectA, projectC, projectB]]);
+ expect(store.getState().pendingIntents.map((pending) => pending.commandId)).toEqual([commandB]);
+ expect(transport.commands.map((command) => command.commandId)).toEqual([commandA, commandB]);
+ expect(
+ transport.commands.every((command) => command.type === "sidebar-layout.project.move"),
+ ).toBe(true);
+ expect(selectDisplayedProjectOrder(store.getState())).toEqual([projectA, projectC, projectB]);
+ });
+
+ it("isolates a throwing rejection observer and continues the sequential queue", async () => {
+ // Given: a rejected first command whose caller observer throws and a second queued command.
+ const transport = new ControllableTransport();
+ const ids = [commandA, commandB];
+ const store = createSidebarLayoutStore({
+ dispatchCommand: transport.dispatchCommand,
+ createCommandId: () => ids.shift() ?? CommandId.makeUnsafe("unexpected-command"),
+ });
+ store.getState().enqueue(
+ { type: "sidebar-layout.thread.pin", threadId: threadA, beforeThreadId: null },
+ {
+ onRejected: () => {
+ throw new Error("observer_exploded");
+ },
+ },
+ );
+ store.getState().enqueue({ type: "sidebar-layout.thread.unpin", threadId: threadA });
+ await flushDispatch();
+
+ // When: the first RPC rejects and invokes the throwing observer.
+ transport.rejectAttempt(0, "stale_state");
+ await flushDispatch();
+
+ // Then: the observer is contained, no rejected promise escapes, and dispatch continues.
+ expect(transport.commands[1]?.commandId).toBe(commandB);
+ });
+
+ it("dispatches exactly one unpin while a stale shell layout arrives in flight", async () => {
+ // Given: canonical membership contains the thread and the unpin response is held.
+ const transport = new ControllableTransport();
+ const store = createSidebarLayoutStore({
+ dispatchCommand: transport.dispatchCommand,
+ createCommandId: () => commandA,
+ });
+ store.getState().setLifecycle(lifecycle);
+ store.getState().acceptConfirmedLayout(layout(1, [projectA, projectB], [threadA]));
+
+ // When: unpin is optimistic and an unrelated shell update still carries the old pin.
+ store.getState().enqueue({ type: "sidebar-layout.thread.unpin", threadId: threadA });
+ await flushDispatch();
+ store.getState().acceptConfirmedLayout(layout(2, [projectB, projectA], [threadA, threadB]));
+ await flushDispatch();
+
+ // Then: replay keeps the thread unpinned and no mirror command is generated.
+ expect(selectDisplayedPinnedThreadOrder(store.getState())).toEqual([threadB]);
+ expect(transport.commands).toHaveLength(1);
+ expect(transport.commands[0]?.type).toBe("sidebar-layout.thread.unpin");
+ expect(
+ transport.commands.filter((command) => command.type === "sidebar-layout.thread.pin"),
+ ).toEqual([]);
+ });
+
+ it("rolls back a rejected pin intent before notifying its caller", async () => {
+ // Given: a canonical unpinned thread and a caller-owned rejection observer.
+ const transport = new ControllableTransport();
+ const rejectedDisplayedOrders: Array = [];
+ const store = createSidebarLayoutStore({
+ dispatchCommand: transport.dispatchCommand,
+ createCommandId: () => commandA,
+ });
+ store.getState().setLifecycle(lifecycle);
+ store.getState().acceptConfirmedLayout(layout(1, [projectA, projectB], []));
+
+ // When: the optimistic pin is rejected.
+ store.getState().enqueue(
+ { type: "sidebar-layout.thread.pin", threadId: threadA, beforeThreadId: null },
+ {
+ onRejected: () => {
+ rejectedDisplayedOrders.push(selectDisplayedPinnedThreadOrder(store.getState()));
+ },
+ },
+ );
+ await flushDispatch();
+ transport.rejectAttempt(0, "thread_not_found");
+ await flushDispatch();
+
+ // Then: the callback sees canonical rollback and no compensation is dispatched.
+ expect(rejectedDisplayedOrders).toEqual([[]]);
+ expect(transport.commands.map((command) => command.type)).toEqual([
+ "sidebar-layout.thread.pin",
+ ]);
+ });
+});
diff --git a/apps/web/src/sidebarLayoutStore.ts b/apps/web/src/sidebarLayoutStore.ts
new file mode 100644
index 000000000..f22c99c6b
--- /dev/null
+++ b/apps/web/src/sidebarLayoutStore.ts
@@ -0,0 +1,265 @@
+import type {
+ CommandId,
+ DispatchableClientOrchestrationCommand,
+ DispatchResult,
+ SidebarLayout,
+} from "@jcode/contracts";
+import { createStore } from "zustand/vanilla";
+import { newCommandId } from "./lib/utils";
+import { ensureNativeApi } from "./nativeApi";
+import type {
+ DisplayedSidebarLayout,
+ PendingSidebarLayoutIntent,
+ SidebarLayoutLifecycle,
+ SidebarLayoutIntent,
+} from "./sidebarLayout.logic";
+import {
+ acceptConfirmedSidebarLayout,
+ deriveDisplayedSidebarLayout,
+ reconcilePendingSidebarLayoutIntents,
+} from "./sidebarLayout.logic";
+
+export type SidebarLayoutCommand = Extract<
+ DispatchableClientOrchestrationCommand,
+ { readonly type: SidebarLayoutIntent["type"] }
+>;
+
+export type SidebarLayoutDispatch = (command: SidebarLayoutCommand) => Promise;
+
+export type SidebarLayoutStoreDependencies = {
+ readonly dispatchCommand: SidebarLayoutDispatch;
+ readonly createCommandId?: () => PendingSidebarLayoutIntent["commandId"];
+};
+
+export type SidebarLayoutEnqueueOptions = {
+ readonly onRejected?: (error: unknown) => void;
+};
+
+export type SidebarLayoutStoreState = {
+ readonly confirmedLayout: SidebarLayout | null;
+ readonly pendingIntents: readonly PendingSidebarLayoutIntent[];
+ readonly lifecycle: SidebarLayoutLifecycle;
+ readonly inFlightCommandId: CommandId | null;
+ readonly enqueue: (
+ intent: SidebarLayoutIntent,
+ options?: SidebarLayoutEnqueueOptions,
+ ) => CommandId;
+ readonly acceptConfirmedLayout: (layout: SidebarLayout | null) => void;
+ readonly setLifecycle: (lifecycle: SidebarLayoutLifecycle) => void;
+ readonly retryInFlight: () => boolean;
+};
+
+const displayedLayoutByState = new WeakMap();
+
+function sidebarLayoutLifecycleEqual(
+ left: SidebarLayoutLifecycle,
+ right: SidebarLayoutLifecycle,
+): boolean {
+ return (
+ left.projects.length === right.projects.length &&
+ left.projects.every((project, index) => {
+ const candidate = right.projects[index];
+ return (
+ candidate !== undefined &&
+ project.id === candidate.id &&
+ project.kind === candidate.kind &&
+ project.createdAt === candidate.createdAt &&
+ project.deletedAt === candidate.deletedAt
+ );
+ }) &&
+ left.threads.length === right.threads.length &&
+ left.threads.every((thread, index) => {
+ const candidate = right.threads[index];
+ return (
+ candidate !== undefined &&
+ thread.id === candidate.id &&
+ thread.deletedAt === candidate.deletedAt
+ );
+ })
+ );
+}
+
+export function selectDisplayedSidebarLayout(
+ state: SidebarLayoutStoreState,
+): DisplayedSidebarLayout {
+ const cached = displayedLayoutByState.get(state);
+ if (cached !== undefined) {
+ return cached;
+ }
+ const displayed = deriveDisplayedSidebarLayout(
+ state.confirmedLayout,
+ state.pendingIntents,
+ state.lifecycle,
+ );
+ displayedLayoutByState.set(state, displayed);
+ return displayed;
+}
+
+export function selectDisplayedProjectOrder(
+ state: SidebarLayoutStoreState,
+): DisplayedSidebarLayout["projectOrder"] {
+ return selectDisplayedSidebarLayout(state).projectOrder;
+}
+
+export function selectDisplayedPinnedThreadOrder(
+ state: SidebarLayoutStoreState,
+): DisplayedSidebarLayout["pinnedThreadOrder"] {
+ return selectDisplayedSidebarLayout(state).pinnedThreadOrder;
+}
+
+export function createSidebarLayoutStore(dependencies: SidebarLayoutStoreDependencies) {
+ return createStore()((set, get) => {
+ let activeAttempt = 0;
+ const rejectionHandlers = new Map void>();
+
+ const dispatchAttempt = (pendingIntent: PendingSidebarLayoutIntent): void => {
+ const attempt = ++activeAttempt;
+ const command: SidebarLayoutCommand = {
+ ...pendingIntent.intent,
+ commandId: pendingIntent.commandId,
+ };
+ void Promise.resolve()
+ .then(() => dependencies.dispatchCommand(command))
+ .then(
+ (result) => {
+ if (attempt !== activeAttempt || get().inFlightCommandId !== pendingIntent.commandId) {
+ return;
+ }
+ set((state) => {
+ const accepted = state.pendingIntents.map((pending) =>
+ pending.commandId === pendingIntent.commandId
+ ? { ...pending, acceptedSequence: result.sequence }
+ : pending,
+ );
+ return {
+ inFlightCommandId: null,
+ pendingIntents: reconcilePendingSidebarLayoutIntents(
+ accepted,
+ state.confirmedLayout?.revision ?? null,
+ ),
+ };
+ });
+ rejectionHandlers.delete(pendingIntent.commandId);
+ dispatchNext();
+ },
+ (error: unknown) => {
+ if (attempt !== activeAttempt || get().inFlightCommandId !== pendingIntent.commandId) {
+ return;
+ }
+ set((state) => ({
+ inFlightCommandId: null,
+ pendingIntents: state.pendingIntents.filter(
+ (pending) => pending.commandId !== pendingIntent.commandId,
+ ),
+ }));
+ const onRejected = rejectionHandlers.get(pendingIntent.commandId);
+ rejectionHandlers.delete(pendingIntent.commandId);
+ try {
+ onRejected?.(error);
+ } catch {
+ return;
+ } finally {
+ dispatchNext();
+ }
+ },
+ );
+ };
+
+ const dispatchNext = (): void => {
+ if (get().inFlightCommandId !== null) {
+ return;
+ }
+ const next = get().pendingIntents.find((pending) => pending.acceptedSequence === undefined);
+ if (next === undefined) {
+ return;
+ }
+ set({ inFlightCommandId: next.commandId });
+ dispatchAttempt(next);
+ };
+
+ return {
+ confirmedLayout: null,
+ pendingIntents: [],
+ lifecycle: { projects: [], threads: [] },
+ inFlightCommandId: null,
+ enqueue: (intent, options) => {
+ const commandId = (dependencies.createCommandId ?? newCommandId)();
+ if (options?.onRejected !== undefined) {
+ rejectionHandlers.set(commandId, options.onRejected);
+ }
+ set((state) => ({
+ pendingIntents: [...state.pendingIntents, { commandId, intent }],
+ }));
+ dispatchNext();
+ return commandId;
+ },
+ acceptConfirmedLayout: (incoming) => {
+ if (incoming === null) {
+ return;
+ }
+ const current = get();
+ const lostInitializer = current.pendingIntents.find(
+ (pending) =>
+ pending.commandId === current.inFlightCommandId &&
+ pending.intent.type === "sidebar-layout.initialize" &&
+ pending.acceptedSequence === undefined,
+ );
+ if (lostInitializer !== undefined) {
+ activeAttempt += 1;
+ rejectionHandlers.delete(lostInitializer.commandId);
+ }
+ set((state) => {
+ const confirmedLayout = acceptConfirmedSidebarLayout(state.confirmedLayout, incoming);
+ const pendingIntents =
+ lostInitializer === undefined
+ ? state.pendingIntents
+ : state.pendingIntents.filter(
+ (pending) => pending.commandId !== lostInitializer.commandId,
+ );
+ const reconciledPendingIntents = reconcilePendingSidebarLayoutIntents(
+ pendingIntents,
+ confirmedLayout.revision,
+ );
+ if (
+ confirmedLayout === state.confirmedLayout &&
+ reconciledPendingIntents.length === state.pendingIntents.length &&
+ reconciledPendingIntents.every(
+ (pending, index) => pending === state.pendingIntents[index],
+ ) &&
+ lostInitializer === undefined
+ ) {
+ return state;
+ }
+ return {
+ confirmedLayout,
+ pendingIntents: reconciledPendingIntents,
+ ...(lostInitializer === undefined ? {} : { inFlightCommandId: null }),
+ };
+ });
+ if (lostInitializer !== undefined) {
+ dispatchNext();
+ }
+ },
+ setLifecycle: (lifecycle) =>
+ set((state) =>
+ sidebarLayoutLifecycleEqual(state.lifecycle, lifecycle) ? state : { lifecycle },
+ ),
+ retryInFlight: () => {
+ const commandId = get().inFlightCommandId;
+ if (commandId === null) {
+ return false;
+ }
+ const pending = get().pendingIntents.find((item) => item.commandId === commandId);
+ if (pending === undefined) {
+ return false;
+ }
+ dispatchAttempt(pending);
+ return true;
+ },
+ };
+ });
+}
+
+export const sidebarLayoutStore = createSidebarLayoutStore({
+ dispatchCommand: (command) => ensureNativeApi().orchestration.dispatchCommand(command),
+});
diff --git a/apps/web/src/storageKeyMigration.test.ts b/apps/web/src/storageKeyMigration.test.ts
index dcfa2258f..736831e36 100644
--- a/apps/web/src/storageKeyMigration.test.ts
+++ b/apps/web/src/storageKeyMigration.test.ts
@@ -98,10 +98,68 @@ describe("storageKeyMigration", () => {
await importMigrationFresh();
expect(globalThis.localStorage.getItem("jcode:composer-drafts:v1")).toBe("drafts");
- expect(globalThis.localStorage.getItem("jcode:pinned-threads:v1")).toBe("pinned");
+ expect(globalThis.localStorage.getItem("jcode:pinned-threads:v1")).toBeNull();
expect(globalThis.localStorage.getItem("jcode:last-editor")).toBe("vscode");
});
+ it("never copies a legacy pinned-thread payload into current storage", async () => {
+ // Given
+ const sourceKey = "dpcode:pinned-threads:v1";
+ const destinationKey = "jcode:pinned-threads:v1";
+ const legacyPayload = JSON.stringify({ state: { pinnedThreadIds: ["thread-2"] }, version: 0 });
+ globalThis.localStorage.setItem(sourceKey, legacyPayload);
+
+ // When
+ await importMigrationFresh();
+
+ // Then
+ expect(globalThis.localStorage.getItem(sourceKey)).toBe(legacyPayload);
+ expect(globalThis.localStorage.getItem(destinationKey)).toBeNull();
+ });
+
+ it("does not resurrect legacy pin or project-order authority after migration is marked", async () => {
+ // Given
+ globalThis.localStorage.setItem("jcode:sidebar-layout-migrated:v1", "1");
+ globalThis.localStorage.setItem("dpcode:pinned-threads:v1", "legacy pins");
+ globalThis.localStorage.setItem(
+ "t3code:renderer-state:v8",
+ JSON.stringify({ projectOrderCwds: ["/stale/project"] }),
+ );
+
+ // When
+ await importMigrationFresh();
+
+ // Then
+ expect(globalThis.localStorage.getItem("jcode:pinned-threads:v1")).toBeNull();
+ expect(
+ JSON.parse(globalThis.localStorage.getItem("jcode:renderer-state:v8") ?? "null"),
+ ).toEqual({});
+ });
+
+ it("migrates marked legacy renderer presentation without restoring project order", async () => {
+ // Given: a marked old profile only has a T3Code renderer payload.
+ globalThis.localStorage.setItem("jcode:sidebar-layout-migrated:v1", "1");
+ globalThis.localStorage.setItem(
+ "t3code:renderer-state:v8",
+ JSON.stringify({
+ expandedProjectCwds: ["/repo/a"],
+ projectNamesByCwd: { "/repo/a": "Local A" },
+ projectOrderCwds: ["/repo/a"],
+ }),
+ );
+
+ // When: namespace bootstrap runs.
+ await importMigrationFresh();
+
+ // Then: presentation migrates to JCode while ordering authority stays retired.
+ expect(
+ JSON.parse(globalThis.localStorage.getItem("jcode:renderer-state:v8") ?? "null"),
+ ).toEqual({
+ expandedProjectCwds: ["/repo/a"],
+ projectNamesByCwd: { "/repo/a": "Local A" },
+ });
+ });
+
it("swallows storage errors so the app can still boot", async () => {
const failingStorage = {
getItem: () => {
diff --git a/apps/web/src/storageKeyMigration.ts b/apps/web/src/storageKeyMigration.ts
index c27310634..65e793ac1 100644
--- a/apps/web/src/storageKeyMigration.ts
+++ b/apps/web/src/storageKeyMigration.ts
@@ -3,6 +3,11 @@
// Layer: Web bootstrap utility
// Exports: migrateJCodeLocalStorageKeys
+import {
+ removeSidebarLayoutLegacyProjectOrder,
+ SIDEBAR_LAYOUT_MIGRATION_MARKER_KEY,
+} from "./sidebarLayoutLegacyMigration";
+
// DPCode/T3Code keys are compatibility inputs only. Leave legacy keys intact so
// users can downgrade during the rebrand window without losing browser state.
const STORAGE_KEY_MIGRATIONS = [
@@ -17,7 +22,6 @@ const STORAGE_KEY_MIGRATIONS = [
[["dpcode:terminal-state:v1", "t3code:terminal-state:v1"], "jcode:terminal-state:v1"],
[["dpcode:latest-project:v1", "t3code:latest-project:v1"], "jcode:latest-project:v1"],
[["dpcode:app-settings:v1", "t3code:app-settings:v1"], "jcode:app-settings:v1"],
- [["dpcode:pinned-threads:v1", "t3code:pinned-threads:v1"], "jcode:pinned-threads:v1"],
[["dpcode:browser-state:v1", "t3code:browser-state:v1"], "jcode:browser-state:v1"],
[["dpcode:workspace-pages:v2", "t3code:workspace-pages:v2"], "jcode:workspace-pages:v2"],
[["dpcode:theme", "t3code:theme"], "jcode:theme"],
@@ -42,6 +46,7 @@ export function migrateJCodeLocalStorageKeys(): void {
}
try {
+ const sidebarLayoutMigrated = storage.getItem(SIDEBAR_LAYOUT_MIGRATION_MARKER_KEY) !== null;
for (const [legacyKeys, nextKey] of STORAGE_KEY_MIGRATIONS) {
if (storage.getItem(nextKey) !== null) {
continue;
@@ -50,7 +55,13 @@ export function migrateJCodeLocalStorageKeys(): void {
.map((legacyKey) => storage.getItem(legacyKey))
.find((value): value is string => value !== null);
if (legacyValue !== undefined) {
- storage.setItem(nextKey, legacyValue);
+ const nextValue =
+ sidebarLayoutMigrated && nextKey === "jcode:renderer-state:v8"
+ ? removeSidebarLayoutLegacyProjectOrder(legacyValue)
+ : legacyValue;
+ if (nextValue !== null) {
+ storage.setItem(nextKey, nextValue);
+ }
}
}
} catch {
diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts
index a7ae8a9db..7f86d2a35 100644
--- a/apps/web/src/store.test.ts
+++ b/apps/web/src/store.test.ts
@@ -21,7 +21,6 @@ import {
collapseProjectsExcept,
markThreadUnread,
renameProjectLocally,
- reorderProjects,
setThreadWorkspace,
setAllProjectsExpanded,
syncServerReadModel,
@@ -172,6 +171,7 @@ function makeReadModelThread(overrides: Partial {
},
{
snapshotSequence: 1,
+ sidebarLayout: null,
updatedAt: "2026-02-27T00:00:00.000Z",
projects: [
makeReadModelProject({
@@ -1518,44 +1519,6 @@ describe("store pure functions", () => {
expect(next.threads[0]?.latestTurn?.turnId).toBe(TurnId.makeUnsafe("turn-1"));
});
- it("reorderProjects moves a project to a target index", () => {
- const project1 = ProjectId.makeUnsafe("project-1");
- const project2 = ProjectId.makeUnsafe("project-2");
- const project3 = ProjectId.makeUnsafe("project-3");
- const state: AppState = {
- projects: [
- makeProject({
- id: project1,
- name: "Project 1",
- remoteName: "Project 1",
- folderName: "project-1",
- cwd: "/tmp/project-1",
- }),
- makeProject({
- id: project2,
- name: "Project 2",
- remoteName: "Project 2",
- folderName: "project-2",
- cwd: "/tmp/project-2",
- }),
- makeProject({
- id: project3,
- name: "Project 3",
- remoteName: "Project 3",
- folderName: "project-3",
- cwd: "/tmp/project-3",
- }),
- ],
- threads: [],
- sidebarThreadSummaryById: {},
- threadsHydrated: true,
- };
-
- const next = reorderProjects(state, project1, project3);
-
- expect(next.projects.map((project) => project.id)).toEqual([project2, project3, project1]);
- });
-
it("expands every project when toggled on", () => {
const project1 = ProjectId.makeUnsafe("project-1");
const project2 = ProjectId.makeUnsafe("project-2");
@@ -2726,7 +2689,7 @@ describe("store read model sync", () => {
expect(next.sidebarThreadSummaryById["thread-1"]?.archivedAt).toBeNull();
});
- it("preserves the current project order when syncing incoming read model updates", () => {
+ it("maps incoming projects without treating the previous local array order as authority", () => {
const project1 = ProjectId.makeUnsafe("project-1");
const project2 = ProjectId.makeUnsafe("project-2");
const project3 = ProjectId.makeUnsafe("project-3");
@@ -2753,6 +2716,7 @@ describe("store read model sync", () => {
};
const readModel: OrchestrationReadModel = {
snapshotSequence: 2,
+ sidebarLayout: null,
updatedAt: "2026-02-27T00:00:00.000Z",
projects: [
makeReadModelProject({
@@ -2776,7 +2740,7 @@ describe("store read model sync", () => {
const next = syncServerReadModel(initialState, readModel);
- expect(next.projects.map((project) => project.id)).toEqual([project2, project1, project3]);
+ expect(next.projects.map((project) => project.id)).toEqual([project1, project2, project3]);
});
it("preserves expanded project state when a project briefly disappears from the snapshot", () => {
@@ -2806,6 +2770,7 @@ describe("store read model sync", () => {
const snapshotWithoutProject2: OrchestrationReadModel = {
snapshotSequence: 2,
+ sidebarLayout: null,
updatedAt: "2026-02-27T00:00:00.000Z",
projects: [
makeReadModelProject({
@@ -2818,6 +2783,7 @@ describe("store read model sync", () => {
};
const snapshotWithProject2Restored: OrchestrationReadModel = {
snapshotSequence: 3,
+ sidebarLayout: null,
updatedAt: "2026-02-27T00:01:00.000Z",
projects: [
makeReadModelProject({
@@ -2980,9 +2946,169 @@ describe("store read model sync", () => {
}
});
+ it("does not create browser-owned project order while persisting presentation state", async () => {
+ // Given: a fresh profile with a hydrated project list.
+ const storage = new Map();
+ const fakeWindow = {
+ localStorage: {
+ getItem: (key: string) => storage.get(key) ?? null,
+ setItem: (key: string, value: string) => storage.set(key, value),
+ removeItem: (key: string) => storage.delete(key),
+ clear: () => storage.clear(),
+ },
+ addEventListener: vi.fn(),
+ };
+ vi.stubGlobal("window", fakeWindow);
+ try {
+ vi.resetModules();
+ const freshStore = await import("./store");
+ freshStore.useStore.setState((state) => ({
+ ...state,
+ projects: [makeProject({ cwd: "/tmp/project", expanded: true })],
+ }));
+
+ // When: device-local presentation state is flushed.
+ freshStore.persistAppStateNow();
+
+ // Then: expansion is retained without deriving an order from the project array.
+ expect(JSON.parse(storage.get("jcode:renderer-state:v8") ?? "{}")).toEqual({
+ expandedProjectCwds: ["/tmp/project"],
+ projectNamesByCwd: {},
+ });
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it("preserves an unconfirmed legacy order without reading or updating it as authority", async () => {
+ // Given: migration candidates exist before the server has confirmed initialization.
+ const storage = new Map();
+ storage.set(
+ "jcode:renderer-state:v8",
+ JSON.stringify({
+ expandedProjectCwds: ["/legacy"],
+ projectOrderCwds: ["/legacy/b", "/legacy/a"],
+ projectNamesByCwd: { "/legacy/a": "Legacy A" },
+ }),
+ );
+ const fakeWindow = {
+ localStorage: {
+ getItem: (key: string) => storage.get(key) ?? null,
+ setItem: (key: string, value: string) => storage.set(key, value),
+ removeItem: (key: string) => storage.delete(key),
+ clear: () => storage.clear(),
+ },
+ addEventListener: vi.fn(),
+ };
+ vi.stubGlobal("window", fakeWindow);
+ try {
+ vi.resetModules();
+ const freshStore = await import("./store");
+ freshStore.useStore.setState((state) => ({
+ ...state,
+ projects: [makeProject({ cwd: "/tmp/current", expanded: true })],
+ }));
+
+ // When: presentation changes are persisted before migration confirmation.
+ freshStore.persistAppStateNow();
+
+ // Then: the candidate field is byte-for-value preserved, not replaced by current UI order.
+ expect(JSON.parse(storage.get("jcode:renderer-state:v8") ?? "{}")).toMatchObject({
+ projectOrderCwds: ["/legacy/b", "/legacy/a"],
+ });
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it("preserves a mixed legacy order and presentation fields before project hydration", async () => {
+ // Given: an old flat renderer payload contains valid candidates mixed with malformed entries.
+ const storage = new Map();
+ storage.set(
+ "jcode:renderer-state:v8",
+ JSON.stringify({
+ expandedProjectCwds: ["/legacy/a"],
+ projectOrderCwds: ["/legacy/b", 42, "/legacy/a", null],
+ projectNamesByCwd: { "/legacy/a": "Legacy A" },
+ }),
+ );
+ const fakeWindow = {
+ localStorage: {
+ getItem: (key: string) => storage.get(key) ?? null,
+ setItem: (key: string, value: string) => storage.set(key, value),
+ removeItem: (key: string) => storage.delete(key),
+ clear: () => storage.clear(),
+ },
+ addEventListener: vi.fn(),
+ };
+ vi.stubGlobal("window", fakeWindow);
+ try {
+ vi.resetModules();
+ const freshStore = await import("./store");
+
+ // When: ordinary presentation persistence runs before the first project snapshot.
+ freshStore.persistAppStateNow();
+
+ // Then: migration input is unchanged and device-local presentation survives hydration wait.
+ expect(JSON.parse(storage.get("jcode:renderer-state:v8") ?? "{}")).toEqual({
+ expandedProjectCwds: ["/legacy/a"],
+ projectNamesByCwd: { "/legacy/a": "Legacy A" },
+ projectOrderCwds: ["/legacy/b", 42, "/legacy/a", null],
+ });
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it("preserves Zustand-envelope migration input and presentation fields before hydration", async () => {
+ // Given: a supported persisted envelope contains legacy authority and presentation state.
+ const storage = new Map();
+ storage.set(
+ "jcode:renderer-state:v8",
+ JSON.stringify({
+ state: {
+ expandedProjectCwds: ["/legacy/a"],
+ projectOrderCwds: ["/legacy/b", false, "/legacy/a"],
+ projectNamesByCwd: { "/legacy/a": "Legacy A" },
+ },
+ version: 8,
+ }),
+ );
+ const fakeWindow = {
+ localStorage: {
+ getItem: (key: string) => storage.get(key) ?? null,
+ setItem: (key: string, value: string) => storage.set(key, value),
+ removeItem: (key: string) => storage.delete(key),
+ clear: () => storage.clear(),
+ },
+ addEventListener: vi.fn(),
+ };
+ vi.stubGlobal("window", fakeWindow);
+ try {
+ vi.resetModules();
+ const freshStore = await import("./store");
+
+ // When: persistence runs before the server project snapshot arrives.
+ freshStore.persistAppStateNow();
+
+ // Then: the envelope and candidate field remain migration-only while presentation survives.
+ expect(JSON.parse(storage.get("jcode:renderer-state:v8") ?? "{}")).toEqual({
+ state: {
+ expandedProjectCwds: ["/legacy/a"],
+ projectNamesByCwd: { "/legacy/a": "Legacy A" },
+ projectOrderCwds: ["/legacy/b", false, "/legacy/a"],
+ },
+ version: 8,
+ });
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+
it("reuses normalized thread objects when the incoming snapshot is unchanged", () => {
const readModel = {
snapshotSequence: 1,
+ sidebarLayout: null,
updatedAt: "2026-02-28T00:00:00.000Z",
projects: [
makeReadModelProject({
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts
index ee7d7051f..fc447919d 100644
--- a/apps/web/src/store.ts
+++ b/apps/web/src/store.ts
@@ -78,13 +78,6 @@ type ThreadUserInputResponseRequestedEvent = Extract<
const PERSISTED_STATE_KEY = "jcode:renderer-state:v8";
const LEGACY_PERSISTED_STATE_KEYS = [
- "dpcode:renderer-state:v8",
- "t3code:renderer-state:v8",
- "t3code:renderer-state:v7",
- "t3code:renderer-state:v6",
- "t3code:renderer-state:v5",
- "t3code:renderer-state:v4",
- "t3code:renderer-state:v3",
"codething:renderer-state:v4",
"codething:renderer-state:v3",
"codething:renderer-state:v2",
@@ -139,7 +132,6 @@ const initialState: AppState = {
turnDiffSummaryByThreadId: {},
};
const persistedExpandedProjectCwds = new Set();
-const persistedProjectOrderCwds: string[] = [];
const persistedProjectNamesByCwd = new Map();
function projectCwdKey(cwd: string): string {
@@ -151,7 +143,9 @@ function basenameOfPath(value: string): string | null {
return segments.at(-1) ?? null;
}
-function rememberProjectUiState(projects: ReadonlyArray>): void {
+function rememberProjectExpansionState(
+ projects: ReadonlyArray>,
+): void {
for (const project of projects) {
const cwdKey = projectCwdKey(project.cwd);
if (project.expanded) {
@@ -159,10 +153,69 @@ function rememberProjectUiState(projects: ReadonlyArray;
+ readonly state: Record;
+ readonly envelope: boolean;
+};
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function parseRendererStorage(raw: string | null): ParsedRendererStorage | null {
+ if (raw === null) {
+ return null;
+ }
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ if (!isRecord(parsed)) {
+ return null;
+ }
+ const nestedState = parsed["state"];
+ if (isRecord(nestedState)) {
+ return { root: parsed, state: nestedState, envelope: true };
}
+ return { root: parsed, state: parsed, envelope: false };
+ } catch (error) {
+ if (error instanceof SyntaxError) {
+ return null;
+ }
+ throw error;
+ }
+}
+
+function storedStringItems(value: unknown): readonly string[] {
+ return Array.isArray(value)
+ ? value.filter((candidate): candidate is string => typeof candidate === "string")
+ : [];
+}
+
+function serializeRendererPresentation(
+ existing: ParsedRendererStorage | null,
+ presentation: {
+ readonly expandedProjectCwds: readonly string[];
+ readonly projectNamesByCwd: Readonly>;
+ },
+): string {
+ if (existing === null) {
+ return JSON.stringify(presentation);
}
+
+ const { projectOrderCwds, ...deviceLocalState } = existing.state;
+ const nextState = {
+ ...deviceLocalState,
+ ...presentation,
+ ...(Array.isArray(projectOrderCwds) ? { projectOrderCwds } : {}),
+ };
+ if (!existing.envelope) {
+ return JSON.stringify(nextState);
+ }
+ const { state: _state, ...envelope } = existing.root;
+ return JSON.stringify({ ...envelope, state: nextState });
}
function rememberProjectLocalNames(
@@ -186,26 +239,19 @@ function readPersistedState(): AppState {
try {
const raw = window.localStorage.getItem(PERSISTED_STATE_KEY);
if (!raw) return initialState;
- const parsed = JSON.parse(raw) as {
- expandedProjectCwds?: string[];
- projectOrderCwds?: string[];
- projectNamesByCwd?: Record;
- };
+ const parsed = parseRendererStorage(raw);
+ if (parsed === null) return initialState;
persistedExpandedProjectCwds.clear();
- persistedProjectOrderCwds.length = 0;
persistedProjectNamesByCwd.clear();
- for (const cwd of parsed.expandedProjectCwds ?? []) {
- if (typeof cwd === "string" && cwd.length > 0) {
+ for (const cwd of storedStringItems(parsed.state["expandedProjectCwds"])) {
+ if (cwd.length > 0) {
persistedExpandedProjectCwds.add(projectCwdKey(cwd));
}
}
- for (const cwd of parsed.projectOrderCwds ?? []) {
- const cwdKey = typeof cwd === "string" ? projectCwdKey(cwd) : "";
- if (cwdKey.length > 0 && !persistedProjectOrderCwds.includes(cwdKey)) {
- persistedProjectOrderCwds.push(cwdKey);
- }
- }
- for (const [cwd, name] of Object.entries(parsed.projectNamesByCwd ?? {})) {
+ const storedProjectNames = parsed.state["projectNamesByCwd"];
+ for (const [cwd, name] of Object.entries(
+ isRecord(storedProjectNames) ? storedProjectNames : {},
+ )) {
if (typeof cwd !== "string" || cwd.length === 0) continue;
if (typeof name !== "string") continue;
const trimmedName = name.trim();
@@ -223,15 +269,17 @@ let legacyKeysCleanedUp = false;
function persistState(state: AppState): void {
if (typeof window === "undefined") return;
try {
- rememberProjectUiState(state.projects);
+ rememberProjectExpansionState(state.projects);
rememberProjectLocalNames(state.projects);
+ const existing = parseRendererStorage(window.localStorage.getItem(PERSISTED_STATE_KEY));
+ const expandedProjectCwds =
+ state.threadsHydrated || state.projects.length > 0
+ ? state.projects.filter((project) => project.expanded).map((project) => project.cwd)
+ : [...persistedExpandedProjectCwds];
window.localStorage.setItem(
PERSISTED_STATE_KEY,
- JSON.stringify({
- expandedProjectCwds: state.projects
- .filter((project) => project.expanded)
- .map((project) => project.cwd),
- projectOrderCwds: state.projects.map((project) => project.cwd),
+ serializeRendererPresentation(existing, {
+ expandedProjectCwds,
projectNamesByCwd: Object.fromEntries(persistedProjectNamesByCwd),
}),
);
@@ -1827,39 +1875,11 @@ function mapProjectsFromReadModel(
const previousByCwd = new Map(
previous.map((project) => [projectCwdKey(project.cwd), project] as const),
);
- const previousOrderById = new Map(previous.map((project, index) => [project.id, index] as const));
- const previousOrderByCwd = new Map(
- previous.map((project, index) => [projectCwdKey(project.cwd), index] as const),
- );
- const persistedOrderByCwd = new Map(
- persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const),
- );
- const usePersistedOrder = previous.length === 0;
-
- const mappedProjects = incoming
- .map((project) => {
- const existing =
- previousById.get(project.id) ?? previousByCwd.get(projectCwdKey(project.workspaceRoot));
- return normalizeProjectFromReadModel(project, existing);
- })
- .map((project, incomingIndex) => {
- const previousIndex =
- previousOrderById.get(project.id) ?? previousOrderByCwd.get(projectCwdKey(project.cwd));
- const persistedIndex = usePersistedOrder
- ? persistedOrderByCwd.get(projectCwdKey(project.cwd))
- : undefined;
- const orderIndex =
- previousIndex ??
- persistedIndex ??
- (usePersistedOrder ? persistedProjectOrderCwds.length : previous.length) + incomingIndex;
- return { project, incomingIndex, orderIndex };
- })
- .toSorted((a, b) => {
- const byOrder = a.orderIndex - b.orderIndex;
- if (byOrder !== 0) return byOrder;
- return a.incomingIndex - b.incomingIndex;
- })
- .map((entry) => entry.project);
+ const mappedProjects = incoming.map((project) => {
+ const existing =
+ previousById.get(project.id) ?? previousByCwd.get(projectCwdKey(project.workspaceRoot));
+ return normalizeProjectFromReadModel(project, existing);
+ });
return arraysShallowEqual(previous, mappedProjects) ? previous : mappedProjects;
}
@@ -1872,39 +1892,11 @@ function mapProjectsFromShellSnapshot(
const previousByCwd = new Map(
previous.map((project) => [projectCwdKey(project.cwd), project] as const),
);
- const previousOrderById = new Map(previous.map((project, index) => [project.id, index] as const));
- const previousOrderByCwd = new Map(
- previous.map((project, index) => [projectCwdKey(project.cwd), index] as const),
- );
- const persistedOrderByCwd = new Map(
- persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const),
- );
- const usePersistedOrder = previous.length === 0;
-
- const mappedProjects = incoming
- .map((project) => {
- const existing =
- previousById.get(project.id) ?? previousByCwd.get(projectCwdKey(project.workspaceRoot));
- return normalizeProjectFromShell(project, existing);
- })
- .map((project, incomingIndex) => {
- const previousIndex =
- previousOrderById.get(project.id) ?? previousOrderByCwd.get(projectCwdKey(project.cwd));
- const persistedIndex = usePersistedOrder
- ? persistedOrderByCwd.get(projectCwdKey(project.cwd))
- : undefined;
- const orderIndex =
- previousIndex ??
- persistedIndex ??
- (usePersistedOrder ? persistedProjectOrderCwds.length : previous.length) + incomingIndex;
- return { project, incomingIndex, orderIndex };
- })
- .toSorted((a, b) => {
- const byOrder = a.orderIndex - b.orderIndex;
- if (byOrder !== 0) return byOrder;
- return a.incomingIndex - b.incomingIndex;
- })
- .map((entry) => entry.project);
+ const mappedProjects = incoming.map((project) => {
+ const existing =
+ previousById.get(project.id) ?? previousByCwd.get(projectCwdKey(project.workspaceRoot));
+ return normalizeProjectFromShell(project, existing);
+ });
return arraysShallowEqual(previous, mappedProjects) ? previous : mappedProjects;
}
@@ -3622,7 +3614,7 @@ export function syncServerShellSnapshot(
state: AppState,
snapshot: OrchestrationShellSnapshot,
): AppState {
- rememberProjectUiState(state.projects);
+ rememberProjectExpansionState(state.projects);
rememberProjectLocalNames(state.projects);
const projects = mapProjectsFromShellSnapshot(snapshot.projects, state.projects);
const nextThreadIds = new Set(snapshot.threads.map((thread) => thread.id));
@@ -3733,11 +3725,13 @@ export function applyShellEvent(state: AppState, event: OrchestrationShellStream
}
case "thread-removed":
return removeThreadState(state, event.threadId);
+ case "sidebar-layout-updated":
+ return state;
}
}
export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState {
- rememberProjectUiState(state.projects);
+ rememberProjectExpansionState(state.projects);
rememberProjectLocalNames(state.projects);
const projects = mapProjectsFromReadModel(
readModel.projects.filter((project) => project.deletedAt === null),
@@ -3897,22 +3891,6 @@ export function collapseProjectsExcept(
return changed ? { ...state, projects } : state;
}
-export function reorderProjects(
- state: AppState,
- draggedProjectId: Project["id"],
- targetProjectId: Project["id"],
-): AppState {
- if (draggedProjectId === targetProjectId) return state;
- const draggedIndex = state.projects.findIndex((project) => project.id === draggedProjectId);
- const targetIndex = state.projects.findIndex((project) => project.id === targetProjectId);
- if (draggedIndex < 0 || targetIndex < 0) return state;
- const projects = [...state.projects];
- const [draggedProject] = projects.splice(draggedIndex, 1);
- if (!draggedProject) return state;
- projects.splice(targetIndex, 0, draggedProject);
- return { ...state, projects };
-}
-
export function renameProjectLocally(
state: AppState,
projectId: Project["id"],
@@ -4026,7 +4004,6 @@ interface AppStore extends AppState {
setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void;
setAllProjectsExpanded: (expanded: boolean) => void;
collapseProjectsExcept: (activeProjectId: Project["id"] | null) => void;
- reorderProjects: (draggedProjectId: Project["id"], targetProjectId: Project["id"]) => void;
renameProjectLocally: (projectId: Project["id"], name: string | null) => void;
setError: (threadId: ThreadId, error: string | null) => void;
setThreadWorkspace: (threadId: ThreadId, patch: ThreadWorkspacePatch) => void;
@@ -4057,8 +4034,6 @@ export const useStore = create((set) => ({
setAllProjectsExpanded: (expanded) => set((state) => setAllProjectsExpanded(state, expanded)),
collapseProjectsExcept: (activeProjectId) =>
set((state) => collapseProjectsExcept(state, activeProjectId)),
- reorderProjects: (draggedProjectId, targetProjectId) =>
- set((state) => reorderProjects(state, draggedProjectId, targetProjectId)),
renameProjectLocally: (projectId, name) => {
set((state) => renameProjectLocally(state, projectId, name));
persistAppStateNow();
@@ -4070,7 +4045,7 @@ export const useStore = create((set) => ({
// Persist state changes with debouncing to avoid localStorage thrashing
useStore.subscribe((state) => {
- rememberProjectUiState(state.projects);
+ rememberProjectExpansionState(state.projects);
rememberProjectLocalNames(state.projects);
debouncedPersistState.maybeExecute(state);
});
diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts
index 9063b6bce..7c967bd62 100644
--- a/apps/web/src/vite-env.d.ts
+++ b/apps/web/src/vite-env.d.ts
@@ -1,6 +1,21 @@
///
-import type { NativeApi, DesktopBridge } from "@jcode/contracts";
+import type { DesktopBridge, NativeApi, ProjectId, ThreadId } from "@jcode/contracts";
+
+declare module "vitest/browser" {
+ interface BrowserCommands {
+ dragSidebarProject: (sourceId: ProjectId, targetId: ProjectId) => Promise;
+ dragPinnedThread: (sourceId: ThreadId, targetId: ThreadId) => Promise;
+ dragPinnedThreadOutOfBounds: (sourceId: ThreadId) => Promise;
+ keyboardMovePinnedThread: (
+ sourceId: ThreadId,
+ direction: "ArrowUp" | "ArrowDown",
+ ) => Promise;
+ selectProjectSortOption: (name: string) => Promise;
+ clickProjectThreadPin: (threadId: ThreadId) => Promise;
+ clickPinnedThreadUnpin: (threadId: ThreadId) => Promise;
+ }
+}
interface ImportMetaEnv {
readonly APP_VERSION: string;
diff --git a/apps/web/vitest.browser.config.ts b/apps/web/vitest.browser.config.ts
index 2d12665ef..0b0206357 100644
--- a/apps/web/vitest.browser.config.ts
+++ b/apps/web/vitest.browser.config.ts
@@ -1,5 +1,5 @@
import { fileURLToPath } from "node:url";
-import { playwright } from "@vitest/browser-playwright";
+import { defineBrowserCommand, playwright } from "@vitest/browser-playwright";
import { defineConfig, mergeConfig } from "vitest/config";
import viteConfig from "./vite.config";
@@ -35,6 +35,206 @@ export default mergeConfig(
},
fileParallelism: localTestProfile ? false : undefined,
provider: playwright(),
+ commands: {
+ dragSidebarProject: defineBrowserCommand(async ({ frame, page }, sourceId, targetId) => {
+ const testerFrame = await frame();
+ const source = testerFrame.locator(`button[data-sidebar-project-id="${sourceId}"]`);
+ const target = testerFrame.locator(`button[data-sidebar-project-id="${targetId}"]`);
+ const [sourceBounds, targetBounds] = await Promise.all([
+ source.boundingBox(),
+ target.boundingBox(),
+ ]);
+ if (sourceBounds === null || targetBounds === null) {
+ throw new Error("Missing visible sidebar project drag activator");
+ }
+ const sourcePoint = {
+ x: sourceBounds.x + sourceBounds.width / 2,
+ y: sourceBounds.y + sourceBounds.height / 2,
+ };
+ const targetPoint = {
+ x: targetBounds.x + targetBounds.width / 2,
+ y: targetBounds.y + targetBounds.height / 2,
+ };
+ await page.mouse.move(sourcePoint.x, sourcePoint.y);
+ await page.mouse.down();
+ try {
+ await page.mouse.move(sourcePoint.x, sourcePoint.y + 8, { steps: 2 });
+ await page.mouse.move(targetPoint.x, targetPoint.y, { steps: 8 });
+ } finally {
+ await page.mouse.up();
+ }
+ }),
+ dragPinnedThread: defineBrowserCommand(async ({ frame, page }, sourceId, targetId) => {
+ const testerFrame = await frame();
+ const source = testerFrame.locator(
+ `button[data-pinned-thread-drag-handle="${sourceId}"]`,
+ );
+ const target = testerFrame.locator(`[data-pinned-thread-id="${targetId}"]`);
+ const [sourceBounds, targetBounds] = await Promise.all([
+ source.boundingBox(),
+ target.boundingBox(),
+ ]);
+ if (sourceBounds === null || targetBounds === null) {
+ throw new Error("Missing visible pinned-thread drag activator");
+ }
+ await page.mouse.move(
+ sourceBounds.x + sourceBounds.width / 2,
+ sourceBounds.y + sourceBounds.height / 2,
+ );
+ await page.mouse.down();
+ try {
+ await page.mouse.move(sourceBounds.x, sourceBounds.y + 8, { steps: 2 });
+ await page.mouse.move(
+ targetBounds.x + targetBounds.width / 2,
+ targetBounds.y + targetBounds.height / 2,
+ { steps: 8 },
+ );
+ } finally {
+ await page.mouse.up();
+ }
+ }),
+ dragPinnedThreadOutOfBounds: defineBrowserCommand(async ({ frame, page }, sourceId) => {
+ const testerFrame = await frame();
+ const sourceHandle = testerFrame.locator(
+ `button[data-pinned-thread-drag-handle="${sourceId}"]`,
+ );
+ const sourceRow = testerFrame.locator(`[data-pinned-thread-id="${sourceId}"]`);
+ const list = testerFrame.locator("[data-pinned-thread-list]");
+ const [sourceBounds, listBounds] = await Promise.all([
+ sourceHandle.boundingBox(),
+ list.boundingBox(),
+ ]);
+ if (sourceBounds === null || listBounds === null) {
+ throw new Error("Missing pinned-thread containment bounds");
+ }
+ const sourcePoint = {
+ x: sourceBounds.x + sourceBounds.width / 2,
+ y: sourceBounds.y + sourceBounds.height / 2,
+ };
+ await page.mouse.move(sourcePoint.x, sourcePoint.y);
+ await page.mouse.down();
+ try {
+ await page.mouse.move(sourceBounds.x, sourceBounds.y + 8, { steps: 2 });
+ await testerFrame
+ .locator(
+ `button[data-pinned-thread-drag-handle="${sourceId}"][aria-pressed="true"]`,
+ )
+ .waitFor({ state: "attached", timeout: 5_000 });
+ await page.mouse.move(listBounds.x + listBounds.width + 160, sourceBounds.y + 8, {
+ steps: 8,
+ });
+ await testerFrame.evaluate(
+ () =>
+ new Promise((resolve) => {
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
+ }),
+ );
+ const activeBounds = await sourceRow.boundingBox();
+ if (activeBounds === null) {
+ throw new Error("Missing active pinned-thread bounds");
+ }
+ return (
+ activeBounds.x >= listBounds.x - 0.5 &&
+ activeBounds.x + activeBounds.width <= listBounds.x + listBounds.width + 0.5
+ );
+ } finally {
+ await page.mouse.up();
+ }
+ }),
+ keyboardMovePinnedThread: defineBrowserCommand(async ({ frame }, sourceId, direction) => {
+ const testerFrame = await frame();
+ const source = testerFrame.locator(
+ `button[data-pinned-thread-drag-handle="${sourceId}"]`,
+ );
+ await source.focus();
+ await source.press("Space");
+ await testerFrame
+ .locator(`button[data-pinned-thread-drag-handle="${sourceId}"][aria-pressed="true"]`)
+ .waitFor({ state: "attached", timeout: 5_000 });
+ let movedOverSibling = false;
+ for (let attempt = 0; attempt < 4; attempt += 1) {
+ await source.press(String(direction));
+ await testerFrame.evaluate(
+ () =>
+ new Promise((resolve) => {
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
+ }),
+ );
+ const announcements = await testerFrame
+ .locator('[aria-live="assertive"]')
+ .allTextContents();
+ movedOverSibling = announcements.some(
+ (announcement) =>
+ announcement.includes(
+ `Draggable item ${sourceId} was moved over droppable area`,
+ ) && !announcement.includes(`droppable area ${sourceId}.`),
+ );
+ if (movedOverSibling) {
+ break;
+ }
+ }
+ if (!movedOverSibling) {
+ throw new Error("Keyboard drag did not announce a sibling target");
+ }
+ await source.press("Space");
+ await testerFrame
+ .locator(`button[data-pinned-thread-drag-handle="${sourceId}"][aria-pressed="false"]`)
+ .waitFor({ state: "attached", timeout: 5_000 });
+ }),
+ selectProjectSortOption: defineBrowserCommand(async ({ frame, page }, name) => {
+ const testerFrame = await frame();
+ const triggerCandidates = testerFrame.getByRole("button", {
+ name: "Sort projects",
+ exact: true,
+ });
+ const triggerCount = await triggerCandidates.count();
+ let visibleTriggerIndex = -1;
+ for (let index = 0; index < triggerCount; index += 1) {
+ const candidate = triggerCandidates.nth(index);
+ if (await candidate.isVisible()) {
+ visibleTriggerIndex = index;
+ break;
+ }
+ }
+ if (visibleTriggerIndex < 0) {
+ throw new Error("Missing visible project sort trigger");
+ }
+ const visibleTrigger = triggerCandidates.nth(visibleTriggerIndex);
+ if ((await visibleTrigger.getAttribute("aria-expanded")) !== "true") {
+ await testerFrame
+ .locator('[role="menu"]:visible')
+ .waitFor({ state: "hidden", timeout: 5_000 });
+ await visibleTrigger.click({ timeout: 5_000 });
+ }
+ const openMenu = testerFrame.locator('[role="menu"]:visible').last();
+ await openMenu.waitFor({ state: "visible", timeout: 5_000 });
+ const candidate = openMenu.getByRole("menuitemradio", { name, exact: true }).first();
+ await candidate.waitFor({ state: "visible", timeout: 5_000 });
+ const bounds = await candidate.boundingBox();
+ if (bounds === null) {
+ throw new Error(`Missing bounds for visible menu radio item: ${name}`);
+ }
+ await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
+ await page.mouse.down();
+ await page.mouse.up();
+ await page.keyboard.press("Escape");
+ await openMenu.waitFor({ state: "hidden", timeout: 5_000 });
+ }),
+ clickProjectThreadPin: defineBrowserCommand(async ({ frame }, threadId) => {
+ const testerFrame = await frame();
+ await testerFrame
+ .locator(`[data-sidebar-thread-id="${threadId}"]`)
+ .getByRole("button", { name: "Pin thread", exact: true })
+ .click();
+ }),
+ clickPinnedThreadUnpin: defineBrowserCommand(async ({ frame }, threadId) => {
+ const testerFrame = await frame();
+ await testerFrame
+ .locator(`[data-pinned-thread-id="${threadId}"]`)
+ .getByRole("button", { name: "Unpin thread", exact: true })
+ .click();
+ }),
+ },
instances: [{ browser: "chromium" }],
headless: true,
},
diff --git a/docs/superpowers/specs/2026-07-17-server-owned-sidebar-layout-design.md b/docs/superpowers/specs/2026-07-17-server-owned-sidebar-layout-design.md
new file mode 100644
index 000000000..841cef162
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-17-server-owned-sidebar-layout-design.md
@@ -0,0 +1,295 @@
+# Server-Owned Sidebar Layout Design
+
+## Summary
+
+JCode will make the server the sole authority for manual project order and pinned-thread membership and order. Every browser or desktop client connected to the same JCode server will render the same canonical sidebar layout.
+
+The layout will be modeled as a dedicated event-sourced aggregate. Clients will send semantic move, pin, and unpin intents instead of replacing complete arrays. The server will apply each intent to its latest state, publish the resulting canonical layout, and include that layout in shell snapshots and updates.
+
+Device-local storage will retain presentation state such as expanded sections, but it will no longer participate in project or pin ordering after a one-time migration.
+
+## Goals
+
+- Synchronize manual project order across every client connected to one JCode server.
+- Synchronize pinned-thread membership and order across those clients.
+- Eliminate the current local/server reconciliation race that can undo an unpin.
+- Preserve responsive drag, pin, and unpin interactions through optimistic rendering.
+- Resolve concurrent client operations deterministically without stale whole-list replacement.
+- Preserve server-side retention protection for pinned threads.
+- Migrate existing local project and pin order once without allowing legacy data to overwrite later server changes.
+
+## Non-Goals
+
+- Synchronizing layout between independent JCode server installations.
+- Moving expanded/collapsed sections, transient selection, hover state, or open panels to the server.
+- Changing automatic project or thread sorting modes.
+- Adding accounts, cloud storage, or Tailnet-specific persistence.
+- Persisting arbitrary pending client mutations across browser restarts.
+
+## Current Failure
+
+Pinned membership currently has two authorities: `projection_threads.is_pinned` on the server and a persisted browser list. During unpin, the client waits for the server command before removing the browser pin. The shell stream can publish `isPinned: false` during that interval, causing the legacy migration effect to interpret the mismatch as an unmigrated local pin and immediately send `isPinned: true`.
+
+The affected thread produced two accepted events 24 milliseconds apart: an unpin followed by a pin. The server correctly applied both commands; the client reconciliation policy created the second command.
+
+Project order has a similar ownership problem. It is persisted by workspace root in browser local storage, so each client can maintain a different order even though all clients use the same JCode server.
+
+## Ownership Model
+
+### Server-owned state
+
+One singleton `SidebarLayout` aggregate per JCode server owns:
+
+- `projectOrder`: the explicit relative order of project IDs;
+- `pinnedThreadOrder`: the ordered set of pinned thread IDs;
+- aggregate revision and update metadata.
+
+Membership in `pinnedThreadOrder` is the authoritative definition of whether a thread is pinned. The existing `projection_threads.is_pinned` value remains as a denormalized projection for retention and compatibility queries. It must only be written as a consequence of sidebar-layout events and must never drive layout commands.
+
+The layout contains project IDs rather than workspace roots. Workspace roots are used only to map legacy browser state during migration.
+
+### Device-local state
+
+The following remain local because they describe a view rather than shared layout:
+
+- project and section expansion;
+- selected rows and range-selection anchors;
+- transient drag and hover state;
+- open panels, drawers, and routes;
+- unconfirmed optimistic intents for the current session.
+
+## Domain Contracts
+
+### Read model
+
+The server exposes a canonical layout in the shell snapshot and shell update stream:
+
+```ts
+interface SidebarLayout {
+ readonly projectOrder: readonly ProjectId[];
+ readonly pinnedThreadOrder: readonly ThreadId[];
+ readonly revision: number;
+ readonly updatedAt: string;
+}
+```
+
+Full and shell snapshots expose `sidebarLayout: SidebarLayout | null`. `null` is the only
+uninitialized state. Once initialized, every accepted layout event publishes a non-null layout
+whose `revision` is that event's global orchestration sequence; `snapshotSequence` is only a
+snapshot fence and is never a layout acknowledgement.
+
+The shell query normalizes the stored layout against live projections:
+
+- duplicate IDs are removed;
+- deleted IDs are omitted;
+- projects not yet explicitly ordered are appended by creation time and ID;
+- pinned IDs that no longer resolve to live threads are omitted.
+
+Normalization is deterministic and read-only. The next accepted layout command persists the normalized result as part of its canonical event.
+
+### Commands
+
+Clients express intent with narrow commands:
+
+- `sidebar-layout.initialize`
+ - the first accepted command initializes the layout;
+ - carries legacy project and pinned-thread order candidates.
+- `sidebar-layout.project.move`
+ - carries a project ID and an optional `beforeProjectId` anchor;
+ - a missing anchor means append.
+- `sidebar-layout.thread.pin`
+ - carries a thread ID and an optional `beforeThreadId` anchor;
+ - an already-pinned thread is repositioned rather than duplicated.
+- `sidebar-layout.thread.unpin`
+ - removes the thread from pinned membership and order atomically.
+- `sidebar-layout.pinned-thread.move`
+ - repositions an already-pinned thread.
+
+All commands carry the existing idempotent command ID. Clients do not send full replacement arrays and do not use a client-authored revision as a last-write-wins token.
+
+Every accepted command returns the existing dispatch receipt sequence. A client retains its
+optimistic intent until a canonical layout with `revision >= acceptedSequence` is observed. This
+handles RPC-before-event, event-before-RPC, retry receipt recovery, and reconnect snapshots without
+using the unrelated shell `snapshotSequence`.
+
+### Command semantics
+
+The orchestration engine serializes commands on the singleton layout stream and evaluates each command against the latest aggregate state.
+
+- If the subject project or thread no longer exists, the command returns a typed not-found result.
+- If a move anchor disappeared concurrently, the subject appends to the relevant list.
+- Moving an item before itself is a no-op.
+- Pin and unpin are idempotent.
+- Concurrent moves of different items are both applied in server acceptance order.
+- Concurrent moves of the same item resolve to the last accepted intent.
+- Initialization after the layout already exists is an accepted canonical no-op: it preserves the
+ existing ordered lists and emits them at a later global event sequence so the dispatch can be
+ acknowledged normally.
+
+Events contain the resulting canonical ordered lists. This keeps projection rebuilds deterministic and makes each accepted layout revision self-contained while commands remain semantic and concurrency-safe.
+
+## Persistence And Projection
+
+A migration adds a singleton `projection_sidebar_layout` table containing:
+
+- layout key;
+- project order JSON;
+- pinned-thread order JSON;
+- revision;
+- initialized timestamp;
+- updated timestamp.
+
+The event store remains authoritative. The projection table is rebuildable from layout events.
+
+When a layout event changes pinned membership, the projection pipeline updates `projection_sidebar_layout` and the affected `projection_threads.is_pinned` values in the same projection transaction. Retention therefore continues using an indexed scalar without becoming a second writer or authority.
+
+Project and thread creation do not need to rewrite the layout. Newly created projects append deterministically until the next explicit project move stores the normalized list. Deleted projects and threads disappear through snapshot normalization; a later layout command compacts the stored arrays.
+
+## Client State And Reconciliation
+
+The web client stores:
+
+- the last confirmed server layout;
+- a session-only queue of pending semantic intents.
+
+The displayed layout is derived by replaying pending intents over the confirmed server layout. It is not persisted as an independent membership or ordering source.
+
+For each interaction:
+
+1. Add the semantic intent to the pending queue and render it immediately.
+2. Dispatch the command with a unique command ID.
+3. Apply shell layout events from any client as the new confirmed layout.
+4. Record the dispatch result's global event sequence as the intent's `acceptedSequence`.
+5. Remove a pending intent after a canonical layout revision reaches that accepted sequence.
+6. Replay any remaining pending intents over the new confirmed layout.
+
+This model naturally handles another client changing the layout while a local command is in flight. There is no imperative rollback copy: on rejection, the client removes the rejected intent and the displayed layout derives again from the last confirmed server state. A targeted toast explains the failure.
+
+Commands from one client are dispatched sequentially so rapid local drags preserve user intent. Server-side stream serialization remains the cross-client ordering authority.
+
+The manual project order is rendered only when the existing sidebar project sort mode is `manual`. Other sort modes remain derived views. Switching back to manual restores the canonical server order.
+
+## Legacy Migration
+
+Migration is explicit, atomic, and one-time.
+
+1. The server snapshot reports that no sidebar layout has been initialized.
+2. A client reads the legacy local project order and pinned-thread list.
+3. The client maps project workspace roots to project IDs from the hydrated server snapshot.
+4. The client sends `sidebar-layout.initialize` with valid candidate IDs.
+5. The server filters deleted or unknown IDs, removes duplicates, appends missing live projects deterministically, merges valid client pin candidates with existing server-pinned threads, and initializes the aggregate atomically. Existing server pins are preserved even when the winning client has no legacy pin storage.
+6. If two clients race to initialize, only the first command changes the ordered lists. The later command is accepted as a canonical no-op event at a newer sequence, and the losing client adopts the already-initialized layout.
+7. After observing initialized server state, each client removes ordering and pin authority from its legacy local storage. Local expansion and other presentation values remain.
+
+If a client has no valid legacy order, it initializes from the deterministic server default. Legacy values are never consulted again after server initialization, so an old browser profile cannot resurrect stale pins or project order.
+
+The final web upgrade algorithm distinguishes three candidate states. `undefined` means the hydrated
+snapshot has not made candidate collection ready, a candidate object (including empty arrays) means
+the client may make its one initialization attempt, and `null` means a durable migration marker or
+unavailable storage forbids initialization from that profile. Candidate collection occurs at most
+once per mounted EventRouter. An empty, readable profile therefore still initializes the server
+default, while a marked old profile never submits another initialize command even if stale keys
+later reappear.
+
+A fully empty pushed shell snapshot is provisional because desktop startup can publish it before the
+projection query is hydrated. It keeps candidates `undefined`. Any pushed snapshot containing a
+project or thread is ready, including valid project-only and thread-only lifecycles. An authoritative
+`getShellSnapshot` query is ready even when both collections are genuinely empty, so an empty server
+still initializes deterministically without treating elapsed time as proof of readiness.
+
+Confirmation is driven only by an observed non-null canonical layout, whether it arrives in a
+snapshot, a non-applied fallback/bootstrap query, or another client's shell event. Canonical-only
+fallback observations pass through the layout router so they retry interrupted cleanup without
+reapplying stale lifecycle data or migration candidates; layout revision monotonicity still prevents
+regression. The client writes
+`jcode:sidebar-layout-migrated:v1` before retiring authority fields, removes only
+`projectOrderCwds` from renderer-state objects, removes the three current/DPCode/T3Code pin keys,
+and leaves expansion, local project names, and unrelated device-local state intact. If field cleanup
+is interrupted, the marker immediately prevents stale replay and later canonical observations retry
+the remaining cleanup. Bootstrap never copies legacy pin storage into a current pin store. Until
+confirmation, ordinary presentation persistence preserves an existing legacy order field unchanged;
+it never derives or updates that field from the rendered project array. This preservation supports
+both flat renderer objects and Zustand-style `{ state, version }` envelopes, keeps mixed legacy
+arrays byte-for-value equivalent until the migration parser filters them, and retains expansion and
+local project names even when persistence runs before project hydration.
+
+The namespace bootstrap marker retires ordering authority only; it does not suppress presentation
+migration. If a marked profile has only a DPCode/T3Code renderer payload, bootstrap strips
+`projectOrderCwds` and copies the remaining expansion, local-name, and unrelated presentation fields
+to the current JCode renderer key.
+
+## Authorization And Transport
+
+Layout reads follow existing shell snapshot authorization. Layout mutations require an owner session because they change shared server state for every connected client. The feature does not add Tailnet-specific trust; it remains behind JCode's Server Auth Boundary.
+
+The web bundle and server contracts ship together. The old client-only pin migration effect and persisted pin membership store are removed from active reconciliation. Any temporary compatibility decoding must be read-only and must not reintroduce a second writer.
+
+## Failure Handling
+
+- Transport failure: remove the rejected pending intent, render confirmed server state, and show a specific retryable toast.
+- Initialization rejection: clear the one-attempt latch and show a targeted Retry action. Retrying
+ reuses the collected candidate snapshot in a fresh command only while canonical layout remains
+ uninitialized; if another client wins first, the retry becomes a no-op and canonical state wins.
+- Domain not found: remove the intent and refresh from the next shell snapshot; do not retry automatically.
+- Lost connection after server acceptance: command ID idempotency makes an explicit retry safe, while the shell snapshot eventually confirms the canonical result.
+- Projection restart or rebuild: replay layout events and reconstruct both the layout row and denormalized pinned flags.
+- Corrupt persisted JSON: fail startup or projection decoding loudly using the existing typed persistence error path; do not silently fall back to browser data.
+- Concurrent client changes: apply accepted server revisions in stream order and rebase local pending intents by replay.
+
+## Testing Strategy
+
+### Contracts and domain
+
+- Decode valid layout snapshots and every command variant.
+- Reject duplicate/invalid boundary data before it enters the domain.
+- Cover initialize-once behavior.
+- Cover project move, pin, unpin, and pinned move behavior.
+- Cover missing subjects, missing anchors, self-moves, and idempotent retries.
+- Apply interleaved intents from two clients and assert deterministic server order.
+
+### Persistence and projections
+
+- Migrate a database and round-trip the singleton layout row.
+- Project layout events into ordered JSON and denormalized `is_pinned` flags.
+- Rebuild projections from events and obtain identical layout and pin flags.
+- Verify thread retention protects exactly the IDs in canonical pinned membership.
+- Verify deleted IDs and newly created projects normalize deterministically.
+
+### Web client
+
+- Replay pending intents over confirmed layout without mutating confirmed state.
+- Reconcile a local pending move with a remote server revision.
+- Remove a failed intent and derive the original confirmed layout.
+- Prove an unpin cannot emit a compensating pin while its RPC is in flight.
+- Prove automatic project sorting ignores manual order without overwriting it.
+- Prove legacy initialization runs only while the server is uninitialized.
+
+### Browser acceptance
+
+Use two isolated authenticated browser contexts against one loopback development server:
+
+1. Reorder projects in client A and observe the same order in client B.
+2. Pin and reorder threads in client B and observe the same order in client A.
+3. Unpin the formerly failing thread and verify it remains unpinned after stream reconciliation and reload.
+4. Perform overlapping moves from both clients and verify both converge to the server's canonical order.
+5. Simulate a rejected command and verify optimistic state rolls back with a targeted error.
+6. Restart the server and verify both orders persist.
+
+## Rollout Sequence
+
+1. Add contracts, aggregate behavior, projection migration, and server snapshot support behind focused tests.
+2. Add the web client's confirmed-layout plus pending-intent model.
+3. Add atomic legacy initialization and remove continuous local reconciliation.
+4. Cut project drag-and-drop and pin controls over to layout commands.
+5. Verify projection rebuilds, retention, two-client convergence, reloads, and restart persistence.
+6. Remove obsolete local ordering and pin-membership code once migration coverage proves it is no longer authoritative.
+
+## Acceptance Criteria
+
+- All clients connected to one JCode server converge on the same manual project order and pinned-thread order.
+- Pin membership has exactly one authority: the server sidebar layout.
+- Unpin cannot be undone by local migration or stream timing.
+- Concurrent client operations are applied as semantic intents to the latest server state.
+- Legacy browser data initializes the server at most once and cannot later overwrite it.
+- Pinned-thread retention behavior remains correct after projection rebuild and server restart.
+- Device-local expansion and navigation state remain independent between clients.
diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts
index bbf2dd72a..56a070dc4 100644
--- a/packages/contracts/src/baseSchemas.ts
+++ b/packages/contracts/src/baseSchemas.ts
@@ -17,6 +17,12 @@ const makeEntityId = (brand: Brand) =>
export const ThreadId = makeEntityId("ThreadId");
export type ThreadId = typeof ThreadId.Type;
+export const SidebarLayoutId = Schema.Literal("sidebar-layout").pipe(
+ Schema.brand("SidebarLayoutId"),
+);
+export type SidebarLayoutId = typeof SidebarLayoutId.Type;
+export const SIDEBAR_LAYOUT_ID: SidebarLayoutId =
+ Schema.decodeSync(SidebarLayoutId)("sidebar-layout");
export const ProjectId = makeEntityId("ProjectId");
export type ProjectId = typeof ProjectId.Type;
export const EnvironmentId = makeEntityId("EnvironmentId");
diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts
index 213361898..d0c76922b 100644
--- a/packages/contracts/src/orchestration.test.ts
+++ b/packages/contracts/src/orchestration.test.ts
@@ -14,6 +14,8 @@ import {
OrchestrationLatestTurn,
OrchestrationMessage,
OrchestrationReadModel,
+ OrchestrationShellSnapshot,
+ OrchestrationShellStreamItem,
ProviderKind,
ProjectIconMetadata,
ProjectCreatedPayload,
@@ -50,6 +52,214 @@ const decodeModelSelection = Schema.decodeUnknownEffect(ModelSelection);
const decodeClientOrchestrationCommand = Schema.decodeUnknownEffect(ClientOrchestrationCommand);
const decodeOrchestrationCommand = Schema.decodeUnknownEffect(OrchestrationCommand);
const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent);
+const decodeOrchestrationReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel);
+const decodeOrchestrationShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot);
+const decodeOrchestrationShellStreamItem = Schema.decodeUnknownEffect(OrchestrationShellStreamItem);
+
+it.effect("decodes an uninitialized sidebar layout in full and shell snapshots", () =>
+ Effect.gen(function* () {
+ const wire = {
+ snapshotSequence: 0,
+ sidebarLayout: null,
+ projects: [],
+ threads: [],
+ updatedAt: "2026-07-18T00:00:00.000Z",
+ } as const;
+
+ const fullSnapshot = yield* decodeOrchestrationReadModel(wire);
+ const shellSnapshot = yield* decodeOrchestrationShellSnapshot(wire);
+
+ assert.strictEqual(fullSnapshot.sidebarLayout, null);
+ assert.strictEqual(shellSnapshot.sidebarLayout, null);
+ }),
+);
+
+it.effect("decodes an initialized sidebar layout in full and shell snapshots", () =>
+ Effect.gen(function* () {
+ const wire = {
+ snapshotSequence: 12,
+ sidebarLayout: {
+ projectOrder: ["project-2", "project-1"],
+ pinnedThreadOrder: ["thread-2", "thread-1"],
+ revision: 11,
+ updatedAt: "2026-07-18T00:00:00.000Z",
+ },
+ projects: [],
+ threads: [],
+ updatedAt: "2026-07-18T00:00:00.000Z",
+ } as const;
+
+ const fullSnapshot = yield* decodeOrchestrationReadModel(wire);
+ const shellSnapshot = yield* decodeOrchestrationShellSnapshot(wire);
+
+ assert.deepStrictEqual(fullSnapshot.sidebarLayout, wire.sidebarLayout);
+ assert.deepStrictEqual(shellSnapshot.sidebarLayout, wire.sidebarLayout);
+ }),
+);
+
+it.effect("decodes all sidebar layout intent commands", () =>
+ Effect.gen(function* () {
+ const commands = [
+ {
+ type: "sidebar-layout.initialize",
+ commandId: "command-layout-initialize",
+ projectOrder: ["project-2", "project-1"],
+ pinnedThreadOrder: ["thread-2", "thread-1"],
+ },
+ {
+ type: "sidebar-layout.project.move",
+ commandId: "command-layout-project-move",
+ projectId: "project-2",
+ beforeProjectId: "project-1",
+ },
+ {
+ type: "sidebar-layout.thread.pin",
+ commandId: "command-layout-thread-pin",
+ threadId: "thread-2",
+ beforeThreadId: "thread-1",
+ },
+ {
+ type: "sidebar-layout.thread.unpin",
+ commandId: "command-layout-thread-unpin",
+ threadId: "thread-2",
+ },
+ {
+ type: "sidebar-layout.pinned-thread.move",
+ commandId: "command-layout-thread-move",
+ threadId: "thread-2",
+ beforeThreadId: null,
+ },
+ ] as const;
+
+ for (const command of commands) {
+ const parsed = yield* decodeClientOrchestrationCommand(command);
+
+ assert.strictEqual(parsed.type, command.type);
+ assert.strictEqual(parsed.commandId, command.commandId);
+ }
+ }),
+);
+
+it.effect("decodes sidebar layout domain and shell events", () =>
+ Effect.gen(function* () {
+ const sidebarLayout = {
+ projectOrder: ["project-1"],
+ pinnedThreadOrder: ["thread-1"],
+ revision: 17,
+ updatedAt: "2026-07-18T00:00:00.000Z",
+ } as const;
+ const domainEvent = yield* decodeOrchestrationEvent({
+ sequence: 17,
+ eventId: "event-layout-updated",
+ aggregateKind: "sidebar-layout",
+ aggregateId: "sidebar-layout",
+ type: "sidebar-layout.updated",
+ payload: {
+ projectOrder: sidebarLayout.projectOrder,
+ pinnedThreadOrder: sidebarLayout.pinnedThreadOrder,
+ updatedAt: sidebarLayout.updatedAt,
+ },
+ occurredAt: sidebarLayout.updatedAt,
+ commandId: "command-layout-project-move",
+ causationEventId: null,
+ correlationId: "command-layout-project-move",
+ metadata: {},
+ });
+ const shellItem = yield* decodeOrchestrationShellStreamItem({
+ kind: "sidebar-layout-updated",
+ sequence: 17,
+ sidebarLayout,
+ });
+
+ assert.strictEqual(domainEvent.type, "sidebar-layout.updated");
+ assert.strictEqual(domainEvent.aggregateId, "sidebar-layout");
+ assert.ok(shellItem.kind === "sidebar-layout-updated");
+ assert.deepStrictEqual(shellItem.sidebarLayout, sidebarLayout);
+ }),
+);
+
+it.effect("strips historical pin metadata from new client commands", () =>
+ Effect.gen(function* () {
+ const metaCommand = yield* decodeClientOrchestrationCommand({
+ type: "thread.meta.update",
+ commandId: "command-new-meta-pin",
+ threadId: "thread-1",
+ isPinned: true,
+ });
+
+ assert.strictEqual("isPinned" in metaCommand, false);
+ }),
+);
+
+it.effect("strips historical pin metadata from new thread creation", () =>
+ Effect.gen(function* () {
+ const createCommand = yield* decodeClientOrchestrationCommand({
+ type: "thread.create",
+ commandId: "command-new-thread-pin",
+ threadId: "thread-1",
+ projectId: "project-1",
+ title: "Thread 1",
+ modelSelection: { provider: "codex", model: "gpt-5.5" },
+ runtimeMode: "full-access",
+ branch: null,
+ worktreePath: null,
+ isPinned: true,
+ createdAt: "2026-07-18T00:00:00.000Z",
+ });
+
+ assert.strictEqual("isPinned" in createCommand, false);
+ }),
+);
+
+it.effect("rejects duplicate sidebar layout initialization candidates", () =>
+ Effect.gen(function* () {
+ const result = yield* Effect.exit(
+ decodeClientOrchestrationCommand({
+ type: "sidebar-layout.initialize",
+ commandId: "command-layout-duplicates",
+ projectOrder: ["project-1", "project-1"],
+ pinnedThreadOrder: ["thread-1", "thread-1"],
+ }),
+ );
+
+ assert.strictEqual(result._tag, "Failure");
+ }),
+);
+
+it.effect("rejects malformed sidebar layout anchors and aggregate IDs", () =>
+ Effect.gen(function* () {
+ const anchorResult = yield* Effect.exit(
+ decodeClientOrchestrationCommand({
+ type: "sidebar-layout.project.move",
+ commandId: "command-layout-invalid-anchor",
+ projectId: "project-1",
+ beforeProjectId: " ",
+ }),
+ );
+ const aggregateResult = yield* Effect.exit(
+ decodeOrchestrationEvent({
+ sequence: 18,
+ eventId: "event-layout-invalid-id",
+ aggregateKind: "sidebar-layout",
+ aggregateId: "not-the-sidebar-layout",
+ type: "sidebar-layout.updated",
+ payload: {
+ projectOrder: [],
+ pinnedThreadOrder: [],
+ updatedAt: "2026-07-18T00:00:00.000Z",
+ },
+ occurredAt: "2026-07-18T00:00:00.000Z",
+ commandId: null,
+ causationEventId: null,
+ correlationId: null,
+ metadata: {},
+ }),
+ );
+
+ assert.strictEqual(anchorResult._tag, "Failure");
+ assert.strictEqual(aggregateResult._tag, "Failure");
+ }),
+);
it.effect("accepts OpenClaw as a provider kind", () =>
Effect.gen(function* () {
@@ -210,6 +420,7 @@ it.effect("preserves thread activity payloads through the RPC JSON codec", () =>
const codec = Schema.toCodecJson(OrchestrationReadModel);
const readModel = {
snapshotSequence: 1,
+ sidebarLayout: null,
updatedAt: "2026-01-01T00:00:00.000Z",
projects: [],
threads: [
@@ -667,6 +878,33 @@ it.effect("decodes thread.meta-updated payloads with explicit provider", () =>
}),
);
+it.effect("preserves historical thread pin metadata events", () =>
+ Effect.gen(function* () {
+ const wire = {
+ sequence: 42,
+ eventId: "event-historical-pin",
+ aggregateKind: "thread",
+ aggregateId: "thread-1",
+ type: "thread.meta-updated",
+ occurredAt: "2026-01-01T00:00:00.000Z",
+ commandId: "command-historical-pin",
+ causationEventId: null,
+ correlationId: "command-historical-pin",
+ metadata: {},
+ payload: {
+ threadId: "thread-1",
+ isPinned: true,
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ },
+ } as const;
+
+ const parsed = yield* decodeOrchestrationEvent(wire);
+
+ assert.deepStrictEqual(parsed.payload, wire.payload);
+ assert.strictEqual(parsed.payload.isPinned, true);
+ }),
+);
+
it.effect("accepts provider-scoped model options in thread.turn.start", () =>
Effect.gen(function* () {
const parsed = yield* decodeThreadTurnStartCommand({
diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts
index cbd1b9426..ffd46d44d 100644
--- a/packages/contracts/src/orchestration.ts
+++ b/packages/contracts/src/orchestration.ts
@@ -26,6 +26,7 @@ import {
PositiveInt,
ProjectId,
ProviderItemId,
+ SidebarLayoutId,
ThreadId,
TrimmedNonEmptyString,
TurnId,
@@ -637,8 +638,27 @@ export const OrchestrationThreadShell = Schema.Struct({
});
export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type;
+const hasUniqueItems = - (items: readonly Item[]): boolean =>
+ new Set(items).size === items.length;
+
+const SidebarProjectOrder = Schema.Array(ProjectId).check(
+ Schema.makeFilter(hasUniqueItems, { identifier: "SidebarProjectOrder" }),
+);
+const SidebarPinnedThreadOrder = Schema.Array(ThreadId).check(
+ Schema.makeFilter(hasUniqueItems, { identifier: "SidebarPinnedThreadOrder" }),
+);
+
+export const SidebarLayout = Schema.Struct({
+ projectOrder: SidebarProjectOrder,
+ pinnedThreadOrder: SidebarPinnedThreadOrder,
+ revision: NonNegativeInt,
+ updatedAt: IsoDateTime,
+});
+export type SidebarLayout = typeof SidebarLayout.Type;
+
export const OrchestrationReadModel = Schema.Struct({
snapshotSequence: NonNegativeInt,
+ sidebarLayout: Schema.NullOr(SidebarLayout),
projects: Schema.Array(OrchestrationProject),
threads: Schema.Array(OrchestrationThread),
updatedAt: IsoDateTime,
@@ -647,6 +667,7 @@ export type OrchestrationReadModel = typeof OrchestrationReadModel.Type;
export const OrchestrationShellSnapshot = Schema.Struct({
snapshotSequence: NonNegativeInt,
+ sidebarLayout: Schema.NullOr(SidebarLayout),
projects: Schema.Array(OrchestrationProjectShell),
threads: Schema.Array(OrchestrationThreadShell),
updatedAt: IsoDateTime,
@@ -674,6 +695,11 @@ export const OrchestrationShellStreamEvent = Schema.Union([
sequence: NonNegativeInt,
threadId: ThreadId,
}),
+ Schema.Struct({
+ kind: Schema.Literal("sidebar-layout-updated"),
+ sequence: NonNegativeInt,
+ sidebarLayout: SidebarLayout,
+ }),
]);
export type OrchestrationShellStreamEvent = typeof OrchestrationShellStreamEvent.Type;
@@ -739,7 +765,6 @@ const ThreadCreateCommand = Schema.Struct({
createBranchFlowCompleted: Schema.optional(Schema.Boolean).pipe(
Schema.withDecodingDefault(() => false),
),
- isPinned: Schema.optional(Schema.Boolean).pipe(Schema.withDecodingDefault(() => false)),
parentThreadId: Schema.optional(Schema.NullOr(ThreadId)).pipe(
Schema.withDecodingDefault(() => null),
),
@@ -850,7 +875,6 @@ const ThreadMetaUpdateCommand = Schema.Struct({
associatedWorktreeBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
associatedWorktreeRef: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
createBranchFlowCompleted: Schema.optional(Schema.Boolean),
- isPinned: Schema.optional(Schema.Boolean),
parentThreadId: Schema.optional(Schema.NullOr(ThreadId)),
subagentAgentId: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
subagentNickname: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
@@ -1064,6 +1088,46 @@ const ThreadGoalClearCommand = Schema.Struct({
createdAt: IsoDateTime,
});
+const SidebarLayoutInitializeCommand = Schema.Struct({
+ type: Schema.Literal("sidebar-layout.initialize"),
+ commandId: CommandId,
+ projectOrder: SidebarProjectOrder,
+ pinnedThreadOrder: SidebarPinnedThreadOrder,
+});
+
+const SidebarLayoutProjectMoveCommand = Schema.Struct({
+ type: Schema.Literal("sidebar-layout.project.move"),
+ commandId: CommandId,
+ projectId: ProjectId,
+ beforeProjectId: Schema.optional(Schema.NullOr(ProjectId)).pipe(
+ Schema.withDecodingDefault(() => null),
+ ),
+});
+
+const SidebarLayoutThreadPinCommand = Schema.Struct({
+ type: Schema.Literal("sidebar-layout.thread.pin"),
+ commandId: CommandId,
+ threadId: ThreadId,
+ beforeThreadId: Schema.optional(Schema.NullOr(ThreadId)).pipe(
+ Schema.withDecodingDefault(() => null),
+ ),
+});
+
+const SidebarLayoutThreadUnpinCommand = Schema.Struct({
+ type: Schema.Literal("sidebar-layout.thread.unpin"),
+ commandId: CommandId,
+ threadId: ThreadId,
+});
+
+const SidebarLayoutPinnedThreadMoveCommand = Schema.Struct({
+ type: Schema.Literal("sidebar-layout.pinned-thread.move"),
+ commandId: CommandId,
+ threadId: ThreadId,
+ beforeThreadId: Schema.optional(Schema.NullOr(ThreadId)).pipe(
+ Schema.withDecodingDefault(() => null),
+ ),
+});
+
const DispatchableClientOrchestrationCommand = Schema.Union([
ProjectCreateCommand,
ProjectMetaUpdateCommand,
@@ -1090,6 +1154,11 @@ const DispatchableClientOrchestrationCommand = Schema.Union([
ThreadGoalResumeCommand,
ThreadGoalCompleteCommand,
ThreadGoalClearCommand,
+ SidebarLayoutInitializeCommand,
+ SidebarLayoutProjectMoveCommand,
+ SidebarLayoutThreadPinCommand,
+ SidebarLayoutThreadUnpinCommand,
+ SidebarLayoutPinnedThreadMoveCommand,
]);
export type DispatchableClientOrchestrationCommand =
typeof DispatchableClientOrchestrationCommand.Type;
@@ -1120,6 +1189,11 @@ export const ClientOrchestrationCommand = Schema.Union([
ThreadGoalResumeCommand,
ThreadGoalCompleteCommand,
ThreadGoalClearCommand,
+ SidebarLayoutInitializeCommand,
+ SidebarLayoutProjectMoveCommand,
+ SidebarLayoutThreadPinCommand,
+ SidebarLayoutThreadUnpinCommand,
+ SidebarLayoutPinnedThreadMoveCommand,
]);
export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type;
@@ -1253,10 +1327,11 @@ export const OrchestrationEventType = Schema.Literals([
"thread.goal-resumed",
"thread.goal-completed",
"thread.goal-cleared",
+ "sidebar-layout.updated",
]);
export type OrchestrationEventType = typeof OrchestrationEventType.Type;
-export const OrchestrationAggregateKind = Schema.Literals(["project", "thread"]);
+export const OrchestrationAggregateKind = Schema.Literals(["project", "thread", "sidebar-layout"]);
export type OrchestrationAggregateKind = typeof OrchestrationAggregateKind.Type;
export const OrchestrationActorKind = Schema.Literals(["client", "server", "provider"]);
@@ -1545,6 +1620,13 @@ export const ThreadGoalClearedPayload = Schema.Struct({
updatedAt: IsoDateTime,
});
+export const SidebarLayoutUpdatedPayload = Schema.Struct({
+ projectOrder: SidebarProjectOrder,
+ pinnedThreadOrder: SidebarPinnedThreadOrder,
+ updatedAt: IsoDateTime,
+});
+export type SidebarLayoutUpdatedPayload = typeof SidebarLayoutUpdatedPayload.Type;
+
export const OrchestrationEventMetadata = Schema.Struct({
providerTurnId: Schema.optional(TrimmedNonEmptyString),
providerItemId: Schema.optional(ProviderItemId),
@@ -1558,7 +1640,7 @@ const EventBaseFields = {
sequence: NonNegativeInt,
eventId: EventId,
aggregateKind: OrchestrationAggregateKind,
- aggregateId: Schema.Union([ProjectId, ThreadId]),
+ aggregateId: Schema.Union([ProjectId, ThreadId, SidebarLayoutId]),
occurredAt: IsoDateTime,
commandId: Schema.NullOr(CommandId),
causationEventId: Schema.NullOr(EventId),
@@ -1722,6 +1804,13 @@ export const OrchestrationEvent = Schema.Union([
type: Schema.Literal("thread.goal-cleared"),
payload: ThreadGoalClearedPayload,
}),
+ Schema.Struct({
+ ...EventBaseFields,
+ aggregateKind: Schema.Literal("sidebar-layout"),
+ aggregateId: SidebarLayoutId,
+ type: Schema.Literal("sidebar-layout.updated"),
+ payload: SidebarLayoutUpdatedPayload,
+ }),
]);
export type OrchestrationEvent = typeof OrchestrationEvent.Type;