From 69f8bb20d20cbfb2479e1cc6770a6beb7e6a95e7 Mon Sep 17 00:00:00 2001 From: Aaron Stevens Date: Tue, 10 Nov 2026 10:05:03 -0800 Subject: [PATCH 1/4] feat(solo): drag to reorder workspaces Solo workspaces can now be reordered by dragging their heading, or with Move up and Move down in the workspace actions menu. The order saves to the existing Solo groups record, so it syncs across devices. Chats stays last. The Team sidebar's section drag moves into a shared useSidebarGroup hook so both sidebars use the same drop line, drag preview and click guard. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Claude --- docs/buzz-help.md | 3 + src/bundled/channels/SoloWorkspaces.test.tsx | 38 +++++++ src/bundled/channels/SoloWorkspaces.tsx | 97 ++++++++++++++-- .../channel-navigation/ChannelSidebarDnd.tsx | 107 +++++++++++------- src/features/relay/solo-preferences.test.ts | 34 ++++++ src/features/relay/solo-preferences.ts | 14 +++ 6 files changed, 238 insertions(+), 55 deletions(-) diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 31ec587782..5e17de7956 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -396,6 +396,9 @@ revokes all of them, and a pairing unused for 30 days expires. Folders are optional unless **Work in a new worktree** is on. In that mode, add at least one project folder; the form shows a message beside Folders until you add one or turn worktree mode off. + Drag a workspace's heading up or down to reorder it, or use **Move up** and + **Move down** in its actions menu. The order syncs to your other devices; + **Chats** stays last. - **Activity** (the bell at the top of the Messages sidebar) collects DMs, ordinary responses in Solo chats, mentions of you, and replies in threads you started, replied in, or follow. Solo chats appear under All, not DMs; their nested threads stay separate. Agent-only coordination stays out. The tabs (All, Threads, Mentions, DMs, Reactions) narrow it by diff --git a/src/bundled/channels/SoloWorkspaces.test.tsx b/src/bundled/channels/SoloWorkspaces.test.tsx index dadaf1531d..adb1a61d02 100644 --- a/src/bundled/channels/SoloWorkspaces.test.tsx +++ b/src/bundled/channels/SoloWorkspaces.test.tsx @@ -85,6 +85,15 @@ function fixture(projectName = "Work") { }; return saved; }); + const reorder = vi.fn(async (ids: readonly string[]) => { + saved = { + ...saved, + sections: [...saved.sections] + .sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id)) + .map((section, order) => ({ ...section, order })), + }; + return saved; + }); const store = () => createSidebarPreferencesStore({ read: async () => saved, @@ -92,6 +101,7 @@ function fixture(projectName = "Work") { write, writeStar: async () => [], removeSection: remove, + writeOrder: reorder, // Groups-only chats have no Messages sort coordinate. groupsOnly: true, }); @@ -139,6 +149,7 @@ function fixture(projectName = "Work") { view, write, remove, + reorder, onNew, saved: () => saved, }; @@ -255,6 +266,33 @@ it("keeps chats after project deletion and allows retry after a rejected deletio owner.dispose(); }); +it("reorders workspaces from their menus and keeps Chats last", async () => { + const h = fixture(); + const owner = h.store(); + const user = userEvent.setup(); + await owner.queries.ensure(); + await owner.queries.createAndAssign("other", { id: "home", name: "Home" }); + render(h.view(owner.queries)); + const headings = () => + screen + .getAllByRole("button", { name: /^(Work|Home|Chats)$/ }) + .map((button) => button.textContent); + expect(headings()).toEqual(["Work", "Home", "Chats"]); + await user.click(screen.getByRole("button", { name: "Actions for Work" })); + expect( + await screen.findByRole("menuitem", { name: "Move up" }), + ).toHaveAttribute("aria-disabled", "true"); + await user.click(screen.getByRole("menuitem", { name: "Move down" })); + await waitFor(() => expect(headings()).toEqual(["Home", "Work", "Chats"])); + expect(h.reorder).toHaveBeenCalledWith(["home", "work"], expect.anything()); + expect(h.saved().sections.map(({ id }) => id)).toEqual(["home", "work"]); + await user.click(screen.getByRole("button", { name: "Actions for Work" })); + expect( + await screen.findByRole("menuitem", { name: "Move down" }), + ).toHaveAttribute("aria-disabled", "true"); + owner.dispose(); +}); + it("starts an unfiled chat from the Chats heading plus without collapsing it", async () => { const h = fixture(); const owner = h.store(); diff --git a/src/bundled/channels/SoloWorkspaces.tsx b/src/bundled/channels/SoloWorkspaces.tsx index 802b328fdc..66460e9c32 100644 --- a/src/bundled/channels/SoloWorkspaces.tsx +++ b/src/bundled/channels/SoloWorkspaces.tsx @@ -14,15 +14,17 @@ import { import type { ChannelSummary } from "../../features/relay/contracts"; import type { ChannelLifecycleAction } from "../../features/relay/channel-lifecycle-protocol"; import { Collapsible } from "@base-ui/react/collapsible"; -import { useDroppable } from "@dnd-kit/core"; import { ChannelSidebarDnd, DraggableChannel, + useSidebarGroup, } from "../../features/channel-navigation/ChannelSidebarDnd"; import { WorkspaceSettings } from "../../features/solo/WorkspaceSettings"; import { + createContext, memo, useCallback, + useContext, useEffect, useLayoutEffect, useMemo, @@ -263,6 +265,13 @@ export function SoloWorkspaces({ const renamingProject = projects.find((project) => project.rows.some((item) => item.id === renaming), )?.id; + const groupIds = groups.map((group) => group.id); + const orderWritable = preferences.sectionOrderWritable; + const moveProject = (id: string, to: number) => { + const next = groupIds.filter((key) => key !== id); + next.splice(to, 0, id); + void preferences.reorderSections(id, next).catch(() => {}); + }; const move = (id: string, projectId?: string) => { if (prepareSoloMove(session, id)) return; const saving = preferences.assign(id, projectId); @@ -470,9 +479,23 @@ export function SoloWorkspaces({ move(id, key.slice(DROP.length) || undefined)} - // Solo projects keep their saved order; only chats drag. - onReorder={() => {}} - overlay={(id) => { + onReorder={(key, over) => + moveProject( + key.slice(DROP.length), + groupIds.indexOf(over.slice(DROP.length)), + ) + } + overlay={(id, group) => { + if (group) { + const project = groups.find((group) => DROP + group.id === id); + return ( + project && ( +
+ {project.name} +
+ ) + ); + } const item = chats.find((item) => item.id === id); return ( item && ( @@ -486,6 +509,8 @@ export function SoloWorkspaces({ {visible.map((project) => ( setSettingsFor(project)}> Workspace settings + + moveProject(project.id, groupIds.indexOf(project.id) - 1) + } + > + Move up + + + moveProject(project.id, groupIds.indexOf(project.id) + 1) + } + > + Move down + } > - - + )} @@ -960,18 +1001,50 @@ function ContributedSoloRow({ } /** A drop reports only its destination, so it saves exactly like Move to. */ +const ProjectDragHandle = + createContext["dragHandle"]>(undefined); + function DropProject({ projectKey, + groupIndex, + reorderable, + children, ...props -}: ComponentProps & { projectKey: string }) { - const { setNodeRef, isOver, active } = useDroppable({ id: projectKey }); +}: ComponentProps & { + projectKey: string; + groupIndex: number; + reorderable: boolean; +}) { + const { dropRef, dropTarget, dropEdge, dragHandle, dragging } = + useSidebarGroup({ + sectionKey: projectKey, + disabled: false, + reorderable, + groupIndex, + }); return ( + + {children} + + + ); +} + +/** A workspace heading drags its workspace while it can move. */ +function ProjectTrigger(props: ComponentProps) { + const dragHandle = useContext(ProjectDragHandle); + return ( + ); } diff --git a/src/features/channel-navigation/ChannelSidebarDnd.tsx b/src/features/channel-navigation/ChannelSidebarDnd.tsx index 1ccd529454..24abcaf3b9 100644 --- a/src/features/channel-navigation/ChannelSidebarDnd.tsx +++ b/src/features/channel-navigation/ChannelSidebarDnd.tsx @@ -18,6 +18,7 @@ import { useState, useRef, type ComponentProps, + type PointerEvent, type ReactNode, } from "react"; import { createPortal } from "react-dom"; @@ -231,23 +232,26 @@ export function DraggableChannel({ ); } -/** A reorderable saved group drags by its header, never its rows or actions. */ -export function DroppableSidebarSection({ +/** + * A reorderable saved group drags by its header, never its rows or actions. + * Owns the group's drop target, header drag and the click a drag must not + * turn into a disclosure toggle. + */ +export function useSidebarGroup({ + sectionKey, disabled, reorderable, groupIndex, groupDomain = "channels", - createSectionTarget, - ...section -}: ComponentProps & { +}: { + sectionKey: string; disabled: boolean; reorderable: boolean; groupIndex: number; groupDomain?: string; - createSectionTarget?: boolean; }) { const { setNodeRef, isOver, active } = useDroppable({ - id: section.sectionKey, + id: sectionKey, data: { group: reorderable, groupIndex, groupDomain }, disabled, // dnd-kit queues a re-measure of only the droppable that resized and clears @@ -258,7 +262,7 @@ export function DroppableSidebarSection({ resizeObserverConfig: { updateMeasurementsFor: [] }, }); const drag = useDraggable({ - id: section.sectionKey, + id: sectionKey, data: { group: true, groupIndex, groupDomain }, disabled: !reorderable, }); @@ -286,48 +290,65 @@ export function DroppableSidebarSection({ }, [reorderable]); useDndMonitor({ onDragStart: ({ active }) => { - if (active.id === section.sectionKey) suppressClick.current = true; + if (active.id === sectionKey) suppressClick.current = true; }, }); const group = !!active?.data.current?.group; const from = active?.data.current?.groupIndex; + return { + dropRef: setNodeRef, + dropTarget: + !group && isOver && active?.data.current?.sectionKey !== sectionKey, + dropEdge: + group && isOver && active?.id !== sectionKey + ? from !== undefined && from < groupIndex + ? ("after" as const) + : ("before" as const) + : undefined, + dragHandle: reorderable + ? { + ref: (node: HTMLElement | null) => { + handle.current = node; + drag.setNodeRef(node); + }, + listeners: { + ...drag.listeners, + onPointerDown: (event: PointerEvent) => { + suppressClick.current = false; + drag.listeners?.onPointerDown?.(event); + }, + }, + } + : undefined, + dragging: drag.isDragging, + }; +} + +export function DroppableSidebarSection({ + disabled, + reorderable, + groupIndex, + groupDomain = "channels", + createSectionTarget, + ...section +}: ComponentProps & { + disabled: boolean; + reorderable: boolean; + groupIndex: number; + groupDomain?: string; + createSectionTarget?: boolean; +}) { + const group = useSidebarGroup({ + sectionKey: section.sectionKey, + disabled, + reorderable, + groupIndex, + groupDomain, + }); return ( <> {createSectionTarget && } - { - handle.current = node; - drag.setNodeRef(node); - }, - listeners: { - ...drag.listeners, - onPointerDown: (event) => { - suppressClick.current = false; - drag.listeners?.onPointerDown?.(event); - }, - }, - } - : undefined - } - dragging={drag.isDragging} - /> + ); } diff --git a/src/features/relay/solo-preferences.test.ts b/src/features/relay/solo-preferences.test.ts index 6a3bd035da..e535ea8620 100644 --- a/src/features/relay/solo-preferences.test.ts +++ b/src/features/relay/solo-preferences.test.ts @@ -354,6 +354,40 @@ it("confirms native-shaped reordered assignment maps without losing group order" } }); +it("saves a workspace reorder as the Solo groups order, keeping unplaced workspaces after", async () => { + const f = fixture(); + try { + f.install({ + ...groups, + groups: [ + { id: "work", name: "Solo work", defaultTemplateId: "template" }, + { id: "home", name: "Home", defaultTemplateId: "" }, + { id: "late", name: "Added elsewhere", defaultTemplateId: "" }, + ], + }); + const solo = f.session.soloPreferences; + await solo.ensure(); + expect(solo.sectionOrderWritable).toBe(true); + await solo.reorderSections("work", ["work"]); + expect(f.publish).not.toHaveBeenCalled(); + await solo.reorderSections("home", ["home", "work"]); + expect(solo.snapshot().data?.sections.map(({ id }) => id)).toEqual([ + "home", + "work", + "late", + ]); + expect(f.publish).toHaveBeenCalledOnce(); + const saved = parseKitRecord( + JSON.parse(f.publish.mock.calls[0]?.[0].content ?? "{}"), + community, + ).value as Groups; + expect(saved.groups.map(({ id }) => id)).toEqual(["home", "work", "late"]); + expect(saved.assignments).toEqual(groups.assignments); + } finally { + f.dispose(); + } +}); + it("checks the exact project capacity before saving and reclaims a removed filing", async () => { const f = fixture(); const value: Groups = { diff --git a/src/features/relay/solo-preferences.ts b/src/features/relay/solo-preferences.ts index b1fd59f03d..2ae6c71078 100644 --- a/src/features/relay/solo-preferences.ts +++ b/src/features/relay/solo-preferences.ts @@ -188,6 +188,20 @@ export function createSoloPreferences(kit: ChannelKit, lifetime: AbortSignal) { return saved; }), groupsOnly: true, + writeOrder: (sectionIds, signal) => + serialize(async () => { + const { value, entry, active } = await current(signal); + // Workspaces missing from the intent (added elsewhere) keep their + // relative order after the ones it places. + const rank = (id: string) => + sectionIds.includes(id) ? sectionIds.indexOf(id) : sectionIds.length; + const groups = [...value.groups].sort( + (a, b) => rank(a.id) - rank(b.id), + ); + return groups.every((group, index) => group === value.groups[index]) + ? project(value) + : save({ ...value, groups }, entry, active); + }), createSection: (section, signal) => serialize(async () => { const { value, entry, active } = await current(signal); From ac443d60f0c08a119008fb22275b130ee1fb08f1 Mon Sep 17 00:00:00 2001 From: Aaron Stevens Date: Tue, 10 Nov 2026 10:05:10 -0800 Subject: [PATCH 2/4] test(solo): drag a workspace heading in the browser A real pointer drag shows the drop line, saves the new Solo groups order, leaves the dragged workspace expanded, and survives a reload. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Claude --- tests/browser/solo-chats.spec.mjs | 64 +++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/browser/solo-chats.spec.mjs b/tests/browser/solo-chats.spec.mjs index ace0a08ccc..3151176665 100644 --- a/tests/browser/solo-chats.spec.mjs +++ b/tests/browser/solo-chats.spec.mjs @@ -1395,3 +1395,67 @@ test.describe("Solo send focus", () => { await expect(composer).toHaveCSS("outline-style", "none"); }); }); + +test("dragging a workspace heading reorders workspaces and reloads in order", async ({ + page, + app, +}) => { + await page.goto(app.origin); + await newChat(page); + const sidebar = page.getByRole("navigation", { name: "Solo conversations" }); + const heading = (name) => sidebar.getByRole("button", { name, exact: true }); + // Workspace headings in sidebar order; their menu triggers carry no text. + const order = async () => + ( + await sidebar + .locator('[data-solo-menu-id^="project-"] button[aria-expanded]') + .allTextContents() + ).filter(Boolean); + const published = async (count) => { + await expect + .poll(() => app.report.sidebarPublications?.length ?? 0) + .toBe(count); + // Each replaceable preference write needs a later second. + await page.clock.runFor(1_100); + return app.report.sidebarPublications + .at(-1) + .blob.value.groups.map(({ name }) => name); + }; + async function file(chat, name) { + await heading(chat).click({ button: "right" }); + await page.getByRole("menuitem", { name: "Move to", exact: true }).hover(); + await page.getByRole("menuitem", { name: "New workspace" }).click(); + const dialog = page.getByRole("dialog"); + await expect(page.locator('[role="menu"]')).toHaveCount(0); + const field = dialog.getByRole("textbox", { name: "Workspace name" }); + await expect(field).toBeFocused(); + await field.fill(name); + await dialog.getByRole("button", { name: "Create and move" }).click(); + await expect(dialog).toHaveCount(0); + } + await page.clock.install(); + await file("Alpha", "Work"); + await published(1); + await file("Beta", "Home"); + expect(await published(2)).toEqual(["Work", "Home"]); + await expect.poll(order).toEqual(["Work", "Home", "Chats"]); + + const from = await heading("Home").boundingBox(); + const to = await heading("Work").boundingBox(); + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); + await page.mouse.down(); + await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { + steps: 8, + }); + await expect(sidebar.locator("[data-section-dragging]")).toBeVisible(); + await expect(sidebar.locator('[data-drop-edge="before"]')).toHaveCount(1); + await page.mouse.up(); + await expect(sidebar.locator("[data-section-dragging]")).toHaveCount(0); + // Releasing the drag does not also toggle the dragged workspace. + await expect(heading("Home")).toHaveAttribute("aria-expanded", "true"); + await expect.poll(order).toEqual(["Home", "Work", "Chats"]); + expect(await published(3)).toEqual(["Home", "Work"]); + + await page.reload(); + await expect.poll(order).toEqual(["Home", "Work", "Chats"]); +}); From 175fb7d46816fba88560b7a285d4c218880e1796 Mon Sep 17 00:00:00 2001 From: Aaron Stevens Date: Tue, 10 Nov 2026 10:20:08 -0800 Subject: [PATCH 3/4] fix(solo): report failed workspace moves and wait out the save limit A failed workspace move now shows a notice with Retry and Dismiss, and focus stays on the workspace. Back-to-back moves wait for the next second instead of failing, since a replaceable record refuses a second save in the same second. Move up and Move down hide when order cannot be saved, matching Team sections. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Claude --- src/bundled/channels/SoloWorkspaces.test.tsx | 29 ++++++- src/bundled/channels/SoloWorkspaces.tsx | 80 +++++++++++++++----- src/features/relay/solo-preferences.test.ts | 27 +++++++ src/features/relay/solo-preferences.ts | 28 ++++++- tests/browser/solo-chats.spec.mjs | 14 ++++ 5 files changed, 158 insertions(+), 20 deletions(-) diff --git a/src/bundled/channels/SoloWorkspaces.test.tsx b/src/bundled/channels/SoloWorkspaces.test.tsx index adb1a61d02..d913045914 100644 --- a/src/bundled/channels/SoloWorkspaces.test.tsx +++ b/src/bundled/channels/SoloWorkspaces.test.tsx @@ -89,7 +89,11 @@ function fixture(projectName = "Work") { saved = { ...saved, sections: [...saved.sections] - .sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id)) + .sort( + (a, b) => + (ids.indexOf(a.id) + 1 || Infinity) - + (ids.indexOf(b.id) + 1 || Infinity), + ) .map((section, order) => ({ ...section, order })), }; return saved; @@ -293,6 +297,29 @@ it("reorders workspaces from their menus and keeps Chats last", async () => { owner.dispose(); }); +it("reports a failed workspace move with Retry, keeping focus on the workspace", async () => { + const h = fixture(); + const owner = h.store(); + const user = userEvent.setup(); + await owner.queries.ensure(); + await owner.queries.createAndAssign("other", { id: "home", name: "Home" }); + render(h.view(owner.queries)); + h.reorder.mockRejectedValueOnce(new Error("Relay unavailable")); + await user.click(screen.getByRole("button", { name: "Actions for Home" })); + await user.click(await screen.findByRole("menuitem", { name: "Move up" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Couldn’t save the workspace order. Relay unavailable", + ); + expect(h.saved().sections.map(({ id }) => id)).toEqual(["work", "home"]); + await user.click(screen.getByRole("button", { name: "Retry move" })); + await waitFor(() => + expect(h.saved().sections.map(({ id }) => id)).toEqual(["home", "work"]), + ); + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.getByRole("button", { name: "Home" })).toHaveFocus(); + owner.dispose(); +}); + it("starts an unfiled chat from the Chats heading plus without collapsing it", async () => { const h = fixture(); const owner = h.store(); diff --git a/src/bundled/channels/SoloWorkspaces.tsx b/src/bundled/channels/SoloWorkspaces.tsx index 66460e9c32..b549ef79bc 100644 --- a/src/bundled/channels/SoloWorkspaces.tsx +++ b/src/bundled/channels/SoloWorkspaces.tsx @@ -267,6 +267,13 @@ export function SoloWorkspaces({ )?.id; const groupIds = groups.map((group) => group.id); const orderWritable = preferences.sectionOrderWritable; + // The order notice unmounts on Retry or Dismiss; its workspace keeps focus. + const focusProject = (id: string) => + document + .querySelector( + `[data-solo-menu-id="project-${CSS.escape(id)}"] button`, + ) + ?.focus({ preventScroll: true }); const moveProject = (id: string, to: number) => { const next = groupIds.filter((key) => key !== id); next.splice(to, 0, id); @@ -487,7 +494,7 @@ export function SoloWorkspaces({ } overlay={(id, group) => { if (group) { - const project = groups.find((group) => DROP + group.id === id); + const project = groups.find(({ id: key }) => DROP + key === id); return ( project && (
@@ -537,22 +544,32 @@ export function SoloWorkspaces({ setSettingsFor(project)}> Workspace settings - - moveProject(project.id, groupIds.indexOf(project.id) - 1) - } - > - Move up - - - moveProject(project.id, groupIds.indexOf(project.id) + 1) - } - > - Move down - + {orderWritable && ( + <> + + moveProject( + project.id, + groupIds.indexOf(project.id) - 1, + ) + } + > + Move up + + + moveProject( + project.id, + groupIds.indexOf(project.id) + 1, + ) + } + > + Move down + + + )}
))} + {snapshot.orderError && ( +
+

Couldn’t save the workspace order. {snapshot.orderError.error}

+ + +
+ )} {settingsFor && ( ["dragHandle"]>(undefined); +/** A drop reports only its destination, so it saves exactly like Move to. */ function DropProject({ projectKey, groupIndex, diff --git a/src/features/relay/solo-preferences.test.ts b/src/features/relay/solo-preferences.test.ts index e535ea8620..be4a5b9a17 100644 --- a/src/features/relay/solo-preferences.test.ts +++ b/src/features/relay/solo-preferences.test.ts @@ -388,6 +388,33 @@ it("saves a workspace reorder as the Solo groups order, keeping unplaced workspa } }); +it("waits out the same-second save limit for back-to-back workspace moves", async () => { + const f = fixture(); + // Real time: the relay refuses a replaceable save within the same second. + vi.mocked(Date.now).mockRestore(); + try { + f.install({ + ...groups, + groups: [ + { id: "work", name: "Solo work", defaultTemplateId: "template" }, + { id: "home", name: "Home", defaultTemplateId: "" }, + ], + }); + const solo = f.session.soloPreferences; + await solo.ensure(); + await solo.reorderSections("home", ["home", "work"]); + await solo.reorderSections("work", ["work", "home"]); + expect(solo.snapshot().orderError).toBeUndefined(); + expect(solo.snapshot().data?.sections.map(({ id }) => id)).toEqual([ + "work", + "home", + ]); + expect(f.publish).toHaveBeenCalledTimes(2); + } finally { + f.dispose(); + } +}); + it("checks the exact project capacity before saving and reclaims a removed filing", async () => { const f = fixture(); const value: Groups = { diff --git a/src/features/relay/solo-preferences.ts b/src/features/relay/solo-preferences.ts index 2ae6c71078..30dda561a5 100644 --- a/src/features/relay/solo-preferences.ts +++ b/src/features/relay/solo-preferences.ts @@ -56,6 +56,8 @@ function admit(value: Groups, entry: KitEntry | undefined) { /** Reuse recipe revision checks/outbox and preference lifecycle; never read Messages groups. */ export function createSoloPreferences(kit: ChannelKit, lifetime: AbortSignal) { let mutations = Promise.resolve(); + /** When this store last saved; replaceable records need a later second. */ + let lastSave = 0; let generation = new AbortController(); const serialize = (operation: () => Promise): Promise => { const admitted = generation.signal; @@ -94,7 +96,11 @@ export function createSoloPreferences(kit: ChannelKit, lifetime: AbortSignal) { active: AbortSignal, ) { admit(value, entry); - await kit.save(value, entry?.eventId, false, active); + try { + await kit.save(value, entry?.eventId, false, active); + } finally { + lastSave = Date.now(); + } active.throwIfAborted(); const confirmed = soloGroups(kit.snapshot().entries); if ( @@ -190,6 +196,26 @@ export function createSoloPreferences(kit: ChannelKit, lifetime: AbortSignal) { groupsOnly: true, writeOrder: (sectionIds, signal) => serialize(async () => { + // Moves come in quick runs, and a replaceable record refuses a second + // save within the same second. Wait it out instead of failing. + const wait = (Math.floor(lastSave / 1000) + 1) * 1000 - Date.now(); + if (wait > 0) + await new Promise((resolve, reject) => { + const abort = AbortSignal.any([ + lifetime, + generation.signal, + signal, + ]); + const timer = setTimeout(resolve, wait); + abort.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(abort.reason); + }, + { once: true }, + ); + }); const { value, entry, active } = await current(signal); // Workspaces missing from the intent (added elsewhere) keep their // relative order after the ones it places. diff --git a/tests/browser/solo-chats.spec.mjs b/tests/browser/solo-chats.spec.mjs index 3151176665..7ff11d9e4a 100644 --- a/tests/browser/solo-chats.spec.mjs +++ b/tests/browser/solo-chats.spec.mjs @@ -1458,4 +1458,18 @@ test("dragging a workspace heading reorders workspaces and reloads in order", as await page.reload(); await expect.poll(order).toEqual(["Home", "Work", "Chats"]); + + // Chats is not a workspace drop target, so it stays last. + const work = await heading("Work").boundingBox(); + const chats = await heading("Chats").boundingBox(); + await page.mouse.move(work.x + work.width / 2, work.y + work.height / 2); + await page.mouse.down(); + await page.mouse.move(chats.x + chats.width / 2, chats.y + chats.height / 2, { + steps: 8, + }); + await expect(sidebar.locator("[data-section-dragging]")).toBeVisible(); + await expect(sidebar.locator("[data-drop-edge]")).toHaveCount(0); + await page.mouse.up(); + await expect.poll(order).toEqual(["Home", "Work", "Chats"]); + expect(app.report.sidebarPublications).toHaveLength(3); }); From 7243063e011750e3a79138b75a3e47f84fe4d67c Mon Sep 17 00:00:00 2001 From: Aaron Stevens Date: Tue, 10 Nov 2026 10:28:07 -0800 Subject: [PATCH 4/4] fix(solo): wait out the save limit before every Solo write A chat move or workspace delete right after a reorder hit the same same-second save refusal, so the wait now runs before every fresh write read instead of only before reorders. A disabled Retry move says why. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Claude --- src/bundled/channels/SoloWorkspaces.test.tsx | 4 +++ src/bundled/channels/SoloWorkspaces.tsx | 3 ++ src/features/relay/solo-preferences.ts | 37 +++++++++----------- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/bundled/channels/SoloWorkspaces.test.tsx b/src/bundled/channels/SoloWorkspaces.test.tsx index d913045914..e338b90e76 100644 --- a/src/bundled/channels/SoloWorkspaces.test.tsx +++ b/src/bundled/channels/SoloWorkspaces.test.tsx @@ -288,6 +288,10 @@ it("reorders workspaces from their menus and keeps Chats last", async () => { ).toHaveAttribute("aria-disabled", "true"); await user.click(screen.getByRole("menuitem", { name: "Move down" })); await waitFor(() => expect(headings()).toEqual(["Home", "Work", "Chats"])); + // The moved workspace keeps focus after its section relocates. + await waitFor(() => + expect(screen.getByRole("button", { name: "Work" })).toHaveFocus(), + ); expect(h.reorder).toHaveBeenCalledWith(["home", "work"], expect.anything()); expect(h.saved().sections.map(({ id }) => id)).toEqual(["home", "work"]); await user.click(screen.getByRole("button", { name: "Actions for Work" })); diff --git a/src/bundled/channels/SoloWorkspaces.tsx b/src/bundled/channels/SoloWorkspaces.tsx index b549ef79bc..4a7f1e93dd 100644 --- a/src/bundled/channels/SoloWorkspaces.tsx +++ b/src/bundled/channels/SoloWorkspaces.tsx @@ -835,6 +835,9 @@ export function SoloWorkspaces({ {snapshot.orderError && (

Couldn’t save the workspace order. {snapshot.orderError.error}

+ {!orderWritable && ( +

Refresh saved workspaces before retrying this move.

+ )}