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..e338b90e76 100644 --- a/src/bundled/channels/SoloWorkspaces.test.tsx +++ b/src/bundled/channels/SoloWorkspaces.test.tsx @@ -85,6 +85,19 @@ 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) + 1 || Infinity) - + (ids.indexOf(b.id) + 1 || Infinity), + ) + .map((section, order) => ({ ...section, order })), + }; + return saved; + }); const store = () => createSidebarPreferencesStore({ read: async () => saved, @@ -92,6 +105,7 @@ function fixture(projectName = "Work") { write, writeStar: async () => [], removeSection: remove, + writeOrder: reorder, // Groups-only chats have no Messages sort coordinate. groupsOnly: true, }); @@ -139,6 +153,7 @@ function fixture(projectName = "Work") { view, write, remove, + reorder, onNew, saved: () => saved, }; @@ -255,6 +270,60 @@ 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"])); + // 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" })); + expect( + await screen.findByRole("menuitem", { name: "Move down" }), + ).toHaveAttribute("aria-disabled", "true"); + 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 802b328fdc..4a7f1e93dd 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,20 @@ 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; + // 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); + 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 +486,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(({ id: key }) => DROP + key === id); + return ( + project && ( +
+ {project.name} +
+ ) + ); + } const item = chats.find((item) => item.id === id); return ( item && ( @@ -486,6 +516,8 @@ export function SoloWorkspaces({ {visible.map((project) => ( setSettingsFor(project)}> Workspace settings + {orderWritable && ( + <> + + moveProject( + project.id, + groupIds.indexOf(project.id) - 1, + ) + } + > + Move up + + + moveProject( + project.id, + groupIds.indexOf(project.id) + 1, + ) + } + > + Move down + + + )} } > - - + )} @@ -774,6 +832,36 @@ export function SoloWorkspaces({ ))} + {snapshot.orderError && ( +
+

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

+ {!orderWritable && ( +

Refresh saved workspaces before retrying this move.

+ )} + + +
+ )} {settingsFor && ( ["dragHandle"]>(undefined); + /** A drop reports only its destination, so it saves exactly like Move to. */ 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..be4a5b9a17 100644 --- a/src/features/relay/solo-preferences.test.ts +++ b/src/features/relay/solo-preferences.test.ts @@ -354,6 +354,67 @@ 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("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 b1fd59f03d..0fedd9310b 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; @@ -73,6 +75,23 @@ export function createSoloPreferences(kit: ChannelKit, lifetime: AbortSignal) { async function current(signal = lifetime, fresh = true) { const active = AbortSignal.any([lifetime, generation.signal, signal]); active.throwIfAborted(); + // Fresh reads precede writes. Moves come in quick runs, and the record + // refuses a second save within the same second: wait it out, not fail. + const wait = fresh + ? (Math.floor(lastSave / 1000) + 1) * 1000 - Date.now() + : 0; + if (wait > 0) + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, wait); + active.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(active.reason); + }, + { once: true }, + ); + }); if (fresh || kit.snapshot().status !== "ready") await kit.refresh(); active.throwIfAborted(); const state = kit.snapshot(); @@ -94,7 +113,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 ( @@ -188,6 +211,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); diff --git a/tests/browser/solo-chats.spec.mjs b/tests/browser/solo-chats.spec.mjs index ace0a08ccc..7ff11d9e4a 100644 --- a/tests/browser/solo-chats.spec.mjs +++ b/tests/browser/solo-chats.spec.mjs @@ -1395,3 +1395,81 @@ 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"]); + + // 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); +});