Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 03b7bf9a92 fix(app): keep delayed navigation behind user scroll intent 2026-11-15 18:56:43 +01:00
Mohamed Boudra a01098ed31 fix(app): preserve submit scroll through existing momentum
A prompt sent during an active gesture requested the bottom, but the older gesture cancelled that request when momentum ended away. Honor the still-sticky pending request on release while allowing a newer gesture to take ownership.
2026-11-15 18:45:57 +01:00
4421f0473a Archive non-Git workspaces from the command center (#9777)
* fix(app): show archive workspace in command center

* test(app): cover archive workspace command center entry

* fix(app): expose archive for non-git workspaces

* fix(app): archive non-Git workspaces from the command center

A plain directory workspace had no Archive workspace entry in the command
center, because the entry came only from the Git action list, which is
empty outside Git. The command center now lists archive for any workspace
that is not Git and runs the workspace's own archive controller, the same
one the Git action uses, so it works whether or not the workspace's
sidebar row is rendered.

Git workspaces are unchanged: archive stays a Git action that shows on
the default list only when it is the promoted primary action.

Co-authored-by: Andreas Heiberg <andreas@heiberg.io>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Andreas Heiberg <andreas@heiberg.io>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 15:59:25 +00:00
paseo-bot[bot] 2a432b1e0a test(app): wait for the slash search itself before erasing it (#9790)
The arrow-choice test waited for /tdd to leave the list as proof that
the search had become "h". The option list is a FlatList that mounts
only the rows near its scroll position: right after the arrows wrap to
/exit at the top, /tdd at the bottom is unmounted while the search is
still "". The wait then passed early, Backspace landed before the
cursor reached the search, the /exit choice stood, and Enter archived
the agent.

The test now waits for a popover that shows /help with neither /tdd
nor /exit, in one query. With the search at "" the list always mounts
the bottom row or the chosen one; only the search "h" leaves both out.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-15 15:51:53 +00:00
Mohamed Boudra 1c175b36b3 Keep a dropped Usage card in its new slot on web (#9780)
On web and desktop, dropping a Usage card made it jump above its new slot
and slide back down for 200 ms.

The web list cleared its drag state on drop and rendered the owner's data
again. Usage saves the order through app settings, so the new order
arrived in a later render. The render in between had the old order with
dnd-kit's transforms cleared, which started a transition back to the old
slot. The new order then moved the row's slot under that running
transition.

The list now holds the dropped key order until the owner's data changes
order, and renders the owner's current rows in that order. The drop and
the new order commit in one render, so dnd-kit skips the transition. A
drag that starts during the hold picks up the rows as displayed. Sidebar
lists already reorder in the drop's render and behave as before. The
native list has followed the same rule since #9748.
2026-11-15 21:51:37 +07:00
Junjie ZHANG aa9984b899 fix(app): show the keyboard focus ring on icon buttons (#9745)
The shared icon button chrome set outlineWidth: 0, which overrides the
:focus-visible ring from public/index.html. Header and toolbar icon
buttons (sidebar toggle, workspace actions, Explorer toggle, new tab,
pane menu, diff toolbar) showed no indication of keyboard focus.

Drop the reset and draw the ring 2px inside the frame: the header title
row clips overflow, so an outset ring loses its top and bottom edges.
Pointer focus stays unringed through the existing *:focus rule.
2026-11-15 13:53:12 +00:00
18 changed files with 552 additions and 53 deletions
+4 -2
View File
@@ -156,8 +156,10 @@ keeps following.
Following resumes only for a gesture that ends within 1 px of the end, either when the finger lifts
or when momentum stops. A release past the end counts: iOS bounces back to the end, and lines that
land during the bounce must keep scrolling in. A release anywhere short of the end stays detached
with the scroll-to-bottom pill, so the rows the user let go of never move.
land during the bounce must keep scrolling in. A release short of the end stays detached with the scroll-to-bottom pill unless you submitted
another prompt during that gesture. That submit requests the end after the gesture finishes;
a new drag after submitting takes ownership again. Rewind and tail-fetch completions do not
override a scroll away because they can arrive after the action that requested them.
### Draw distance
@@ -0,0 +1,105 @@
import { existsSync } from "node:fs";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openCommandCenter } from "../support/helpers/command-center";
import { openWorkspaceFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { getServerId } from "../support/helpers/server-id";
import { expectWorkspaceAbsentFromSidebar } from "../support/helpers/sidebar";
async function isWorkspaceListed(seeded: SeededWorkspace): Promise<boolean> {
const result = await seeded.client.fetchWorkspaces();
return result.entries.some((entry) => entry.id === seeded.workspaceId);
}
async function expectWorkspaceArchivedOnDaemon(seeded: SeededWorkspace): Promise<void> {
await expect.poll(() => isWorkspaceListed(seeded), { timeout: 30_000 }).toBe(false);
}
test.describe("Command center archive for a non-Git workspace", () => {
test("archives a plain directory workspace from the command center", async ({ page }) => {
const title = "Command Center Directory Archive";
const seeded = await seedWorkspace({ repoPrefix: "cc-archive-dir-", title, git: false });
try {
await gotoAppShell(page);
await openWorkspaceFromCommandCenter(page, seeded, title);
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("archive");
const archive = panel.getByRole("button", { name: /^Archive(?: workspace)?(?:\s|$)/ });
await expect(archive).toBeVisible({ timeout: 30_000 });
await archive.click();
await expectWorkspaceAbsentFromSidebar(page, seeded.workspaceId);
expect(existsSync(seeded.repoPath)).toBe(true);
} finally {
await seeded.cleanup();
}
});
test("archives a plain directory workspace from the command center with the sidebar closed", async ({
page,
}) => {
const title = "Command Center Directory Archive Closed Sidebar";
const seeded = await seedWorkspace({ repoPrefix: "cc-archive-dir-closed-", title, git: false });
try {
await gotoAppShell(page);
await openWorkspaceFromCommandCenter(page, seeded, title);
await page.getByTestId("menu-button").click();
await expect(page.getByTestId("sidebar-search")).toBeHidden({ timeout: 10_000 });
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+k`);
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
await panel.getByTestId("command-center-input").fill("archive");
const archive = panel.getByRole("button", { name: /^Archive(?: workspace)?(?:\s|$)/ });
await expect(archive).toBeVisible({ timeout: 30_000 });
await archive.click();
await expectWorkspaceArchivedOnDaemon(seeded);
} finally {
await seeded.cleanup();
}
});
test("archives a plain directory workspace from the command center with its project collapsed", async ({
page,
}) => {
const title = "Command Center Directory Archive Collapsed Project";
const seeded = await seedWorkspace({
repoPrefix: "cc-archive-dir-collapsed-",
title,
git: false,
});
try {
await gotoAppShell(page);
await openWorkspaceFromCommandCenter(page, seeded, title);
const workspaceRow = page.getByTestId(
`sidebar-workspace-row-${getServerId()}:${seeded.workspaceId}`,
);
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
await page
.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(seeded.projectKey)}`)
.first()
.click();
await expect(workspaceRow).toHaveCount(0, { timeout: 10_000 });
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("archive");
const archive = panel.getByRole("button", { name: /^Archive(?: workspace)?(?:\s|$)/ });
await expect(archive).toBeVisible({ timeout: 30_000 });
await archive.click();
await expectWorkspaceArchivedOnDaemon(seeded);
} finally {
await seeded.cleanup();
}
});
});
@@ -788,11 +788,20 @@ test.describe("Composer autocomplete", () => {
await input.press("ArrowDown");
await input.press("h");
// On web the search reads the cursor after paint, so the input's value runs ahead of
// the list. Wait on the list itself: /tdd is hidden only while the search is "h", and
// back only once the search is "" again.
await expect(popover.getByText("/tdd", { exact: true })).toHaveCount(0);
// the list. Wait on the list itself. The list mounts only the rows near its scroll
// position, so one missing row proves nothing: right after the arrows wrap to the top,
// /tdd at the bottom is unmounted while the search is still "". With the search at "",
// the list always mounts the bottom row (/tdd) or the chosen one (/exit); only the
// search "h" leaves both out. Check them in one query so no frame falls between them.
await expect(
popover.filter({
has: page.getByText("/help", { exact: true }),
hasNot: page.getByText(/^\/(tdd|exit)$/),
}),
).toBeVisible();
await input.press("Backspace");
await expect(input).toHaveValue("/");
// /tdd is back only once the search is "" again.
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible();
await input.press("Enter");
@@ -0,0 +1,56 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
const HEADER_ICON_BUTTONS = ["workspace-header-menu-trigger", "workspace-explorer-toggle"];
function visible(page: Page, testId: string): Locator {
return page.getByTestId(testId).filter({ visible: true }).first();
}
function matchesFocusVisible(target: Locator): Promise<boolean> {
return target.evaluate((el) => el.matches(":focus-visible"));
}
async function expectKeyboardFocusRing(page: Page, testId: string): Promise<void> {
const button = visible(page, testId);
// Chromium matches :focus-visible on a programmatic focus that follows keyboard input.
await page.keyboard.press("Shift");
await button.focus();
await expect(button).toBeFocused();
expect(await matchesFocusVisible(button)).toBe(true);
await expect(button).toHaveCSS("outline-style", "solid");
await expect(button).toHaveCSS("outline-width", "2px");
// Header rows clip overflow; an outset ring would lose its top and bottom edges.
await expect(button).toHaveCSS("outline-offset", "-2px");
}
async function expectNoPointerFocusRing(page: Page, testId: string): Promise<void> {
const button = visible(page, testId);
await button.blur();
// Toggle twice so the layout ends where it started.
await button.click();
await button.click();
await expect(button).toBeFocused();
expect(await matchesFocusVisible(button)).toBe(false);
await expect(button).toHaveCSS("outline-style", "none");
}
test.describe("Icon button focus ring", () => {
test("header icon buttons show the focus ring for keyboard focus only", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
const workspace = await seedWorkspace({ repoPrefix: "icon-button-focus-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
for (const testId of HEADER_ICON_BUTTONS) {
await expectKeyboardFocusRing(page, testId);
}
await expectNoPointerFocusRing(page, "workspace-explorer-toggle");
} finally {
await workspace.cleanup();
}
});
});
@@ -37,6 +37,7 @@ import {
pickUsageHost,
usageModalHost,
dragUsageCardAbove,
recordUsageCardPositionsAfterRelease,
expectUsageCardOrder,
expectUsageOptionsFitContent,
setUsageCardOn,
@@ -254,9 +255,15 @@ test.describe("usage modal", () => {
await expect(work.getByText("work", { exact: true })).toBeVisible();
await expectPinnedUsage(page, ["31% Weekly", "20% Weekly"]);
const stopRecording = await recordUsageCardPositionsAfterRelease(page);
await dragUsageCardAbove(page, "beta:home", "alpha:a");
await expectUsageCardOrder(page, ["beta:home", "alpha:a", "beta:work"]);
await expectPinnedUsage(page, ["20% Weekly", "31% Weekly"]);
// The cards are in their new slots from the first frame after the drop.
const framesAfterDrop = await stopRecording();
const settled = framesAfterDrop.at(-1);
expect(settled).toBeDefined();
expect(framesAfterDrop.filter((frame) => frame !== settled)).toEqual([]);
await page.reload();
await expectPinnedUsage(page, ["20% Weekly", "31% Weekly"]);
@@ -187,6 +187,44 @@ export async function dragUsageCardAbove(
await page.mouse.up();
}
/**
* Starts recording where the cards are painted on every frame after the next mouse release.
* Call the returned function to stop and read the frames, each as "id@top" for every card.
*/
export async function recordUsageCardPositionsAfterRelease(
page: Page,
): Promise<() => Promise<string[]>> {
const recording = await usageModal(page).evaluateHandle((modal) => {
const state = { released: false, stopped: false, frames: [] as string[] };
const markReleased = () => {
state.released = true;
};
window.addEventListener("mouseup", markReleased, { capture: true, once: true });
const recordFrame = () => {
if (state.stopped) return;
if (state.released) {
const cards = Array.from(modal.querySelectorAll('[data-testid^="usage-report-"]'));
state.frames.push(
cards
.map((card) => {
const id = card.getAttribute("data-testid")?.slice("usage-report-".length);
return `${id}@${Math.round(card.getBoundingClientRect().top)}`;
})
.join(" "),
);
}
requestAnimationFrame(recordFrame);
};
requestAnimationFrame(recordFrame);
return state;
});
return () =>
recording.evaluate((state) => {
state.stopped = true;
return state.frames;
});
}
/** Each pinned window as it reads: its percent and short label, "31% 5h". */
export async function expectPinnedUsage(page: Page, windows: string[]): Promise<void> {
const pinned = usageItem(page).getByTestId("sidebar-usage-pinned-window");
@@ -394,6 +394,56 @@ describe("bottom anchor controller driver", () => {
expect(harness.scrollAttempts).toHaveLength(0);
});
it("honors a submit made during a gesture when that gesture ends away", () => {
const harness = createDriverHarness({ isNearBottom: false });
harness.driver.beginUserScroll();
harness.driver.requestLocalAnchor({ agentId: "agent-1", reason: "message-sent" });
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toEqual([]);
harness.driver.endUserScroll({ isAtEnd: false });
harness.scheduler.flushAll();
expect(harness.context.measurementState.offsetY).toBe(
harness.context.measurementState.contentHeight -
harness.context.measurementState.viewportHeight,
);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
});
});
it.each(["rewind", "jump-to-bottom"] as const)(
"lets scrolling away supersede a late %s request",
(reason) => {
const harness = createDriverHarness({ isNearBottom: false });
harness.driver.beginUserScroll();
harness.driver.requestLocalAnchor({ agentId: "agent-1", reason });
harness.driver.endUserScroll({ isAtEnd: false });
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toEqual([]);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "detached",
pendingRequest: null,
});
},
);
it("lets a new gesture supersede a submit requested during the previous gesture", () => {
const harness = createDriverHarness({ isNearBottom: false });
harness.driver.beginUserScroll();
harness.driver.requestLocalAnchor({ agentId: "agent-1", reason: "message-sent" });
harness.driver.endUserScroll({ isAtEnd: false });
harness.driver.beginUserScroll();
harness.driver.endUserScroll({ isAtEnd: false });
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toEqual([]);
expect(harness.driver.getSnapshot()).toMatchObject({ mode: "detached", pendingRequest: null });
});
it("resumes following when a user scroll ends at the end", () => {
const harness = createDriverHarness({
transportBehavior: {
@@ -499,11 +499,16 @@ function createBottomAnchorControllerDriver(
cancelPendingAttempt();
setModeInternal("detached");
},
// Following resumes only for a gesture that ends at the end. Resuming from short of it would
// scroll the rows the finger just let go of, so a release above the end, or with output that
// arrived below it during the gesture, stays detached with the pill.
// A gesture ending short stays detached unless a newer prompt submission requested the end.
// Passive output must not move the rows the finger just let go of.
endUserScroll(params) {
isUserScrollActive = false;
// Submit arrives with the user's action; rewind and tail jumps can arrive after a fetch.
// A newer gesture would have detached again, so only a still-sticky submit owns release.
if (pendingRequest?.reason === "message-sent" && mode === "sticky-bottom") {
evaluate(false, "user_scroll_end");
return;
}
if (params.isAtEnd) {
if (mode === "detached") {
setModeInternal("sticky-bottom");
@@ -25,12 +25,13 @@ function source(gitActions: GitActions): {
dispatched: KeyboardActionDefinition[];
copiedPaths: number;
copiedBranchNames: number;
archivedWorkspaces: number;
toggledLabels: Array<{ name: string; assigned: boolean }>;
} {
const runGitActions: GitAction[] = [];
const dispatched: KeyboardActionDefinition[] = [];
const toggledLabels: Array<{ name: string; assigned: boolean }> = [];
const counters = { copiedPaths: 0, copiedBranchNames: 0 };
const counters = { copiedPaths: 0, copiedBranchNames: 0, archivedWorkspaces: 0 };
return {
value: {
gitActions,
@@ -68,6 +69,7 @@ function source(gitActions: GitActions): {
closePane: "Close pane",
toggleFocusMode: "Toggle focus mode",
toggleExplorerSidebar: "Toggle Explorer sidebar",
archiveWorkspace: "Archive workspace",
rename: "Rename workspace",
copyPath: "Copy workspace path",
copyBranchName: "Copy branch name",
@@ -93,6 +95,9 @@ function source(gitActions: GitActions): {
labelCatalog: null,
dispatch: (action) => dispatched.push(action),
runGitAction: (action) => runGitActions.push(action),
archiveWorkspace: () => {
counters.archivedWorkspaces += 1;
},
copyPath: () => {
counters.copiedPaths += 1;
},
@@ -112,10 +117,48 @@ function source(gitActions: GitActions): {
get copiedBranchNames() {
return counters.copiedBranchNames;
},
get archivedWorkspaces() {
return counters.archivedWorkspaces;
},
};
}
describe("workspace command center contributions", () => {
it("offers archive in a non-Git workspace and runs the workspace's archive", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
const archive = buildWorkspaceCommandCenterContributions(fixture.value).find(
(item) => item.id === "workspace:archive",
);
expect(archive?.visibility).toBe("query");
expect(archive?.presentation).toMatchObject({ title: "Archive workspace" });
archive?.run();
expect(fixture.archivedWorkspaces).toBe(1);
expect(fixture.dispatched).toEqual([]);
});
it("omits archive while the workspace cannot be archived", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
fixture.value.archiveWorkspace = null;
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.some((item) => item.id === "workspace:archive")).toBe(false);
});
it("lists archive once in a Git workspace, through its Git action", () => {
const archive = gitAction("archive-workspace", "Archive workspace");
const fixture = source({ primary: null, secondary: [archive], menu: [] });
fixture.value.capabilities.isGit = true;
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(
contributions.filter((item) => item.id.includes("archive")).map((item) => item.id),
).toEqual(["git:archive-workspace"]);
});
it("makes only the policy-selected primary Git action default-visible and runs it", () => {
const primary = gitAction("commit", "Commit");
const fixture = source({
@@ -50,6 +50,7 @@ export interface WorkspaceCommandCenterLabels {
toggleFocusMode: string;
toggleExplorerSidebar: string;
// Workspace management actions
archiveWorkspace: string;
rename: string;
copyPath: string;
copyBranchName: string;
@@ -79,6 +80,7 @@ export interface WorkspaceCommandCenterIcons {
focusMode?: CommandCenterIcon;
explorerSidebar?: CommandCenterIcon;
// Workspace management action icons
archiveWorkspace?: CommandCenterIcon;
copyPath?: CommandCenterIcon;
copyBranchName?: CommandCenterIcon;
pin?: CommandCenterIcon;
@@ -131,6 +133,8 @@ export interface WorkspaceCommandCenterSource {
labelCatalog: readonly WorkspaceCommandCenterLabelChoice[] | null;
dispatch(action: KeyboardActionDefinition): void;
runGitAction(action: GitAction): void;
/** Null while the workspace cannot be archived. A Git workspace archives through `gitActions`. */
archiveWorkspace: (() => void) | null;
copyPath(): void;
copyBranchName(): void;
toggleLabel(name: string, assigned: boolean): void | Promise<void>;
@@ -585,6 +589,23 @@ export function buildWorkspaceCommandCenterContributions(
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
}
// A Git workspace lists archive among its Git actions; any other workspace archives here.
if (!source.capabilities.isGit && source.archiveWorkspace) {
contributions.push(
buildWorkspaceCallback({
source,
id: "workspace:archive",
rank: 100,
title: source.labels.archiveWorkspace,
keywords: ["archive", "workspace"],
icon: source.icons.archiveWorkspace,
shortcutKeys: source.shortcuts.archiveWorkspace,
run: source.archiveWorkspace,
visibility: "query",
}),
);
}
if (source.capabilities.canPin) {
contributions.push(
buildWorkspaceAction({
@@ -203,7 +203,7 @@ export function useWorkspaceCommandCenterActions(): void {
supportsSetupObservation,
);
const { overrides } = useKeyboardShortcutOverrides();
const { gitActions, isGit } = useGitActions({
const { gitActions, isGit, archiveWorkspace } = useGitActions({
serverId: serverId ?? "",
cwd: cwd ?? "",
icons: GIT_ACTION_ICONS,
@@ -270,6 +270,7 @@ export function useWorkspaceCommandCenterActions(): void {
toggleFocusMode: t("settings.shortcuts.help.toggleFocusMode"),
toggleExplorerSidebar: t("workspace.tabs.explorerSidebar.toggle"),
// Workspace management labels
archiveWorkspace: t("workspace.git.actions.archive.label"),
rename: t("sidebar.workspace.actions.rename"),
copyPath: t("workspace.header.actions.copyPath"),
copyBranchName: t("workspace.header.actions.copyBranchName"),
@@ -280,6 +281,7 @@ export function useWorkspaceCommandCenterActions(): void {
},
icons: {
...WORKSPACE_COMMAND_CENTER_ICONS,
archiveWorkspace: staticIcon(GIT_ACTION_ICONS.archive),
git: (action) => staticIcon(action.icon),
},
shortcuts: resolveWorkspaceShortcuts(overrides),
@@ -301,12 +303,14 @@ export function useWorkspaceCommandCenterActions(): void {
keyboardActionDispatcher.dispatch(action);
},
runGitAction,
archiveWorkspace,
copyPath,
copyBranchName,
toggleLabel,
}),
[
activeTabIndex,
archiveWorkspace,
activeTabKind,
canPin,
canShowSetup,
@@ -2,31 +2,41 @@ import { describe, expect, it } from "vitest";
import { dragStateInitial, dragStateReducer } from "./drag-reducer";
describe("dragStateReducer", () => {
it("starts tracking the active item with a snapshot of the data", () => {
it("starts tracking the active item with the rows on screen", () => {
const next = dragStateReducer(dragStateInitial<string>(), {
type: "start",
id: "alpha",
data: ["alpha", "beta"],
items: ["alpha", "beta"],
});
expect(next).toEqual({ activeId: "alpha", dragItems: ["alpha", "beta"] });
expect(next).toEqual({ activeId: "alpha", dragItems: ["alpha", "beta"], dropped: null });
});
it("clears the active item and the snapshot", () => {
it("clears the active item and the rows", () => {
const next = dragStateReducer(
{ activeId: "alpha", dragItems: ["alpha", "beta"] },
{ activeId: "alpha", dragItems: ["alpha", "beta"], dropped: null },
{ type: "clear" },
);
expect(next).toEqual({ activeId: null, dragItems: null });
expect(next).toEqual({ activeId: null, dragItems: null, dropped: null });
});
it("holds the dropped order in place of the order it replaces", () => {
const dropped = { order: ["beta", "alpha"], replaces: ["alpha", "beta"] };
const next = dragStateReducer(
{ activeId: "alpha", dragItems: ["alpha", "beta"], dropped: null },
{ type: "drop", dropped },
);
expect(next).toEqual({ activeId: null, dragItems: null, dropped });
});
it("replaces an in-flight drag when a new one starts", () => {
const next = dragStateReducer(
{ activeId: "alpha", dragItems: ["alpha", "beta"] },
{ type: "start", id: "beta", data: ["beta", "gamma"] },
{ activeId: "alpha", dragItems: ["alpha", "beta"], dropped: null },
{ type: "start", id: "beta", items: ["beta", "gamma"] },
);
expect(next).toEqual({ activeId: "beta", dragItems: ["beta", "gamma"] });
expect(next).toEqual({ activeId: "beta", dragItems: ["beta", "gamma"], dropped: null });
});
});
@@ -1,19 +1,33 @@
export interface DragState<T> {
activeId: string | null;
dragItems: T[] | null;
/** A dropped key order, shown in place of the owner's until the owner's order changes. */
export interface DroppedOrder {
order: string[];
/** The owner's key order at the drop. */
replaces: string[];
}
export type DragAction<T> = { type: "start"; id: string; data: T[] } | { type: "clear" };
export interface DragState<T> {
activeId: string | null;
/** The rows on screen while a drag is in flight. */
dragItems: T[] | null;
dropped: DroppedOrder | null;
}
export type DragAction<T> =
| { type: "start"; id: string; items: T[] }
| { type: "drop"; dropped: DroppedOrder }
| { type: "clear" };
export function dragStateInitial<T>(): DragState<T> {
return { activeId: null, dragItems: null };
return { activeId: null, dragItems: null, dropped: null };
}
export function dragStateReducer<T>(state: DragState<T>, action: DragAction<T>): DragState<T> {
switch (action.type) {
case "start":
return { activeId: action.id, dragItems: action.data };
return { activeId: action.id, dragItems: action.items, dropped: null };
case "drop":
return { activeId: null, dragItems: null, dropped: action.dropped };
case "clear":
return { activeId: null, dragItems: null };
return dragStateInitial();
}
}
@@ -1,6 +1,6 @@
import { useCallback, useReducer } from "react";
import { useCallback, useMemo, useReducer } from "react";
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
import { dragStateInitial, dragStateReducer } from "./drag-reducer";
import { dragStateInitial, dragStateReducer, type DroppedOrder } from "./drag-reducer";
import { reorderItemsOnDragEnd } from "./reorder-items";
export interface DragReorderHandlers {
@@ -15,6 +15,28 @@ export interface DragReorderState<T> {
handlers: DragReorderHandlers;
}
/**
* The owner's rows in the dropped order, or null once the owner's own order has changed since
* the drop.
*/
function arrangeAsDropped<T>(
data: T[],
keys: string[],
{ order, replaces }: DroppedOrder,
): T[] | null {
const ownerOrderUnchanged =
keys.length === replaces.length && keys.every((key, index) => key === replaces[index]);
if (!ownerOrderUnchanged) return null;
const indexByKey = new Map(keys.map((key, index) => [key, index]));
const arranged: T[] = [];
for (const key of order) {
const index = indexByKey.get(key);
if (index !== undefined) arranged.push(data[index]);
}
return arranged.length === data.length ? arranged : null;
}
export function useDragReorderState<T>({
data,
keyExtractor,
@@ -30,13 +52,22 @@ export function useDragReorderState<T>({
}): DragReorderState<T> {
const [state, dispatch] = useReducer(dragStateReducer<T>, undefined, dragStateInitial<T>);
const keys = useMemo(() => data.map(keyExtractor), [data, keyExtractor]);
const held = useMemo(
() => (state.dropped ? arrangeAsDropped(data, keys, state.dropped) : null),
[data, keys, state.dropped],
);
// The owner's order changed, so the dropped order has nothing left to stand in for.
if (state.dropped && !held) dispatch({ type: "clear" });
const items = state.dragItems ?? held ?? data;
const handleDragStart = useCallback(
(event: DragStartEvent) => {
if (disabled) return;
dispatch({ type: "start", id: String(event.active.id), data });
dispatch({ type: "start", id: String(event.active.id), items });
onDragBegin?.();
},
[data, disabled, onDragBegin],
[disabled, items, onDragBegin],
);
const clearDragState = useCallback(() => {
@@ -46,25 +77,29 @@ export function useDragReorderState<T>({
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
const items = state.dragItems ?? data;
dispatch({ type: "clear" });
if (disabled) return;
const activeId = String(active.id);
const reordered = reorderItemsOnDragEnd({
items,
activeId,
overId: over ? String(over.id) : null,
keyExtractor,
});
if (reordered) onDragEnd?.(reordered, activeId);
const reordered = disabled
? null
: reorderItemsOnDragEnd({
items,
activeId,
overId: over ? String(over.id) : null,
keyExtractor,
});
if (!reordered) {
dispatch({ type: "clear" });
return;
}
// The owner's new order can arrive in a later render. Hold the dropped order until it does.
dispatch({ type: "drop", dropped: { order: reordered.map(keyExtractor), replaces: keys } });
onDragEnd?.(reordered, activeId);
},
[data, disabled, keyExtractor, onDragEnd, state.dragItems],
[disabled, items, keyExtractor, keys, onDragEnd],
);
return {
activeId: state.activeId,
items: state.dragItems ?? data,
items,
handlers: {
onDragStart: handleDragStart,
onDragCancel: clearDragState,
@@ -36,7 +36,10 @@ export interface DraggableListProps<T> {
data: T[];
keyExtractor: (item: T, index: number) => string;
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
/** The new order, and the key of the item the user moved to reach it. */
/**
* The new order, and the key of the item the user moved to reach it. The list keeps showing
* this order until `data` arrives in a different order, so the owner must apply it.
*/
onDragEnd: (data: T[], movedKey: string) => void;
style?: StyleProp<ViewStyle>;
/** Outer container style (useful for nested, non-scrolling lists). */
@@ -1,4 +1,4 @@
import React, { act } from "react";
import React, { act, useCallback, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
@@ -11,6 +11,7 @@ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
interface Item {
id: string;
label?: string;
}
function keyExtractor(item: Item): string {
@@ -41,7 +42,7 @@ function renderRowWithHandle(info: DraggableRenderItemInfo<Item>) {
<DraggableListHandle accessibilityLabel={`Move ${info.item.id}`} testID="row-handle">
<span>grip</span>
</DraggableListHandle>
<span data-testid="row-body">{info.item.id}</span>
<span data-testid="row-body">{info.item.label ?? info.item.id}</span>
</div>
);
}
@@ -87,6 +88,87 @@ function pressAndMove(element: HTMLElement, distance: number) {
});
}
const OWNER_DELAY_MS = 50;
function wait(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
/**
* An owner that relabels its rows as soon as one is dropped, and whose new order reaches the list
* in a later render, like one that saves to a store.
*/
function ListWithLateOwner() {
const [items, setItems] = useState<Item[]>([{ id: "one" }, { id: "two" }]);
const relabelThenReorderLater = useCallback((next: Item[]) => {
const relabel = (item: Item): Item => ({ id: item.id, label: `${item.id} dropped` });
setItems((current) => current.map(relabel));
setTimeout(() => setItems(next.map(relabel)), OWNER_DELAY_MS);
}, []);
return (
<DraggableList
data={items}
keyExtractor={keyExtractor}
renderItem={renderRowWithHandle}
onDragEnd={relabelThenReorderLater}
useDragHandle
/>
);
}
function rowOrder(): (string | null)[] {
return Array.from(container?.querySelectorAll('[data-testid="row-body"]') ?? []).map(
(row) => row.textContent,
);
}
/** Drag the element down by `distance` in two mouse moves, letting dnd-kit measure in between. */
async function dragDown(element: HTMLElement, distance: number) {
const { left, top, width, height } = element.getBoundingClientRect();
const x = left + width / 2;
const y = top + height / 2;
await act(async () => {
element.dispatchEvent(
new MouseEvent("mousedown", { bubbles: true, button: 0, clientX: x, clientY: y }),
);
});
for (const moved of [distance / 2, distance]) {
await act(async () => {
document.dispatchEvent(
new MouseEvent("mousemove", { bubbles: true, clientX: x, clientY: y + moved }),
);
await wait(20);
});
}
act(() => {
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
});
}
describe("DraggableList on web", () => {
it("shows the owner's current rows in the dropped order until its new order arrives", async () => {
act(() => {
root?.render(<ListWithLateOwner />);
});
const [firstHandle] = Array.from(
container?.querySelectorAll<HTMLElement>('[data-testid="row-handle"]') ?? [],
);
const [firstRow, secondRow] = Array.from(
container?.querySelectorAll<HTMLElement>('[data-testid="row-body"]') ?? [],
);
const rowDistance =
secondRow.getBoundingClientRect().top - firstRow.getBoundingClientRect().top;
await dragDown(firstHandle, rowDistance);
expect(rowOrder()).toEqual(["two dropped", "one dropped"]);
await act(() => wait(OWNER_DELAY_MS * 2));
expect(rowOrder()).toEqual(["two dropped", "one dropped"]);
});
});
describe("DraggableListHandle on web", () => {
it("starts the row's drag from the handle, and only from the handle", () => {
const onDragBegin = vi.fn();
@@ -126,6 +126,10 @@ export function smallIconButtonChromeFrameSize(compact = false): number {
return ICON_BUTTON_SIZES.small[controlSizingForLayout(compact)];
}
// Icon buttons sit in header and toolbar rows that clip overflow, so the keyboard focus ring
// from public/index.html is drawn inside the frame instead of around it.
const FOCUS_RING_INSET = -2;
const styles = StyleSheet.create((theme) => ({
large: {
width: bySizing(ICON_BUTTON_SIZES.large),
@@ -135,8 +139,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
outlineWidth: 0,
outlineColor: "transparent",
outlineOffset: FOCUS_RING_INSET,
},
small: {
width: ICON_BUTTON_SIZES.small.pointer,
@@ -146,8 +149,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
outlineWidth: 0,
outlineColor: "transparent",
outlineOffset: FOCUS_RING_INSET,
},
smallTouch: {
width: ICON_BUTTON_SIZES.small.touch,
@@ -157,8 +159,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
outlineWidth: 0,
outlineColor: "transparent",
outlineOffset: FOCUS_RING_INSET,
},
round: {
borderRadius: theme.borderRadius.full,
+15 -1
View File
@@ -209,6 +209,8 @@ interface UseGitActionsResult {
gitActions: GitActions;
branchLabel: string;
isGit: boolean;
/** Archives this workspace, Git or not. Null while it cannot be archived. */
archiveWorkspace: (() => void) | null;
}
interface UseWorkspaceScreenArchiveControllerInput {
@@ -266,6 +268,13 @@ function canArchiveWorkspace(
);
}
function availableArchive(
controller: { canArchive: boolean; isArchiving: boolean },
archive: () => void,
): (() => void) | null {
return controller.canArchive && !controller.isArchiving ? archive : null;
}
function useWorkspaceScreenArchiveController({
serverId,
activeWorkspaceSelection,
@@ -864,7 +873,12 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
[gitActionsInput, baseRefLabel, hasPullRequest, forge, t],
);
return { gitActions, branchLabel, isGit };
return {
gitActions,
branchLabel,
isGit,
archiveWorkspace: availableArchive(archiveController, handleArchiveWorkspace),
};
}
function translateGitActions(