Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions src/bundled/channels/SoloWorkspaces.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,27 @@ 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,
available: true,
write,
writeStar: async () => [],
removeSection: remove,
writeOrder: reorder,
// Groups-only chats have no Messages sort coordinate.
groupsOnly: true,
});
Expand Down Expand Up @@ -139,6 +153,7 @@ function fixture(projectName = "Work") {
view,
write,
remove,
reorder,
onNew,
saved: () => saved,
};
Expand Down Expand Up @@ -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();
Expand Down
144 changes: 132 additions & 12 deletions src/bundled/channels/SoloWorkspaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<HTMLElement>(
`[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);
Expand Down Expand Up @@ -470,9 +486,23 @@ export function SoloWorkspaces({
<ChannelSidebarDnd
writable={preferences.writable}
onMove={(id, key) => 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 && (
<div className={channelStyles.channelDragOverlay}>
<span>{project.name}</span>
</div>
)
);
}
const item = chats.find((item) => item.id === id);
return (
item && (
Expand All @@ -486,6 +516,8 @@ export function SoloWorkspaces({
{visible.map((project) => (
<DropProject
projectKey={DROP + project.id}
groupIndex={groupIds.indexOf(project.id)}
reorderable={orderWritable && !!project.id}
key={project.id}
className={`${styles.project} ${channelStyles.channelSection}`}
data-project={project.id ? "" : undefined}
Expand All @@ -512,6 +544,32 @@ export function SoloWorkspaces({
<MenuItem onClick={() => setSettingsFor(project)}>
Workspace settings
</MenuItem>
{orderWritable && (
<>
<MenuItem
disabled={groupIds[0] === project.id}
onClick={() =>
moveProject(
project.id,
groupIds.indexOf(project.id) - 1,
)
}
>
Move up
</MenuItem>
<MenuItem
disabled={groupIds.at(-1) === project.id}
onClick={() =>
moveProject(
project.id,
groupIds.indexOf(project.id) + 1,
)
}
>
Move down
</MenuItem>
</>
)}
<MenuItem
tone="danger"
data-delete-solo-project=""
Expand Down Expand Up @@ -559,7 +617,7 @@ export function SoloWorkspaces({
</>
}
>
<Collapsible.Trigger
<ProjectTrigger
type="button"
className={styles.projectHeading}
aria-expanded={
Expand All @@ -578,7 +636,7 @@ export function SoloWorkspaces({
>
<CaretDownIcon size={15} strokeWidth={2.5} />
</span>
</Collapsible.Trigger>
</ProjectTrigger>
</NavigationSectionHeading>
)}
</ChatActionsMenu>
Expand Down Expand Up @@ -774,6 +832,36 @@ export function SoloWorkspaces({
</Button>
</div>
))}
{snapshot.orderError && (
<div role="alert" className={styles.listMessage}>
<p>Couldn’t save the workspace order. {snapshot.orderError.error}</p>
{!orderWritable && (
<p>Refresh saved workspaces before retrying this move.</p>
)}
<Button
disabled={!orderWritable}
onClick={() => {
const failed = snapshot.orderError;
if (!failed) return;
void preferences
.reorderSections(failed.sectionId, failed.sectionIds)
.catch(() => {});
focusProject(failed.sectionId);
}}
>
Retry move
</Button>
<Button
onClick={() => {
const failed = snapshot.orderError;
preferences.dismissOrderError();
if (failed) focusProject(failed.sectionId);
}}
>
Dismiss
</Button>
</div>
)}
{settingsFor && (
<WorkspaceSettings
key={settingsFor.id}
Expand Down Expand Up @@ -959,19 +1047,51 @@ function ContributedSoloRow({
);
}

const ProjectDragHandle =
createContext<ReturnType<typeof useSidebarGroup>["dragHandle"]>(undefined);

/** A drop reports only its destination, so it saves exactly like Move to. */
function DropProject({
projectKey,
groupIndex,
reorderable,
children,
...props
}: ComponentProps<typeof Collapsible.Root> & { projectKey: string }) {
const { setNodeRef, isOver, active } = useDroppable({ id: projectKey });
}: ComponentProps<typeof Collapsible.Root> & {
projectKey: string;
groupIndex: number;
reorderable: boolean;
}) {
const { dropRef, dropTarget, dropEdge, dragHandle, dragging } =
useSidebarGroup({
sectionKey: projectKey,
disabled: false,
reorderable,
groupIndex,
});
return (
<Collapsible.Root
{...props}
ref={setNodeRef}
data-drop-target={
(isOver && active?.data.current?.sectionKey !== projectKey) || undefined
}
ref={dropRef}
data-drop-target={dropTarget || undefined}
data-drop-edge={dropEdge}
data-section-dragging={dragging || undefined}
>
<ProjectDragHandle.Provider value={dragHandle}>
{children}
</ProjectDragHandle.Provider>
</Collapsible.Root>
);
}

/** A workspace heading drags its workspace while it can move. */
function ProjectTrigger(props: ComponentProps<typeof Collapsible.Trigger>) {
const dragHandle = useContext(ProjectDragHandle);
return (
<Collapsible.Trigger
ref={dragHandle?.ref}
{...dragHandle?.listeners}
{...props}
/>
);
}
Expand Down
Loading