mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-15 21:04:54 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0273b6b614 | ||
|
|
8c88b83cd8 | ||
|
|
b55955541e | ||
|
|
2c5f5a1485 | ||
|
|
685e113358 | ||
|
|
28322f1519 | ||
|
|
4421f0473a | ||
|
|
2a432b1e0a | ||
|
|
1c175b36b3 | ||
|
|
aa9984b899 |
@@ -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"]);
|
||||
|
||||
@@ -96,7 +96,7 @@ export async function listTerminalIds(daemon: IsolatedHostDaemon): Promise<strin
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function psField(pid: number, field: "stat" | "lstart"): Promise<string> {
|
||||
async function psField(pid: number, field: "stat" | "lstart" | "comm"): Promise<string> {
|
||||
const { stdout } = await execFileAsync("ps", ["-o", `${field}=`, "-p", String(pid)], {
|
||||
env: { ...process.env, LC_ALL: "C" },
|
||||
});
|
||||
@@ -117,17 +117,22 @@ export interface UnclosableTerminalProgram {
|
||||
export async function plantUnclosableTerminalProgram(
|
||||
daemon: IsolatedHostDaemon,
|
||||
): Promise<UnclosableTerminalProgram> {
|
||||
const parent = spawn("/bin/sh", ["-c", "sleep 0 & echo $!; exec sleep 600"], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
// The shell reaps any child that has exited whenever it finishes a builtin, so the program
|
||||
// reads fd 3 until we close it, which happens only once the shell has become `sleep`.
|
||||
const parent = spawn("/bin/sh", ["-c", "cat <&3 >/dev/null & echo $!; exec sleep 600 3<&-"], {
|
||||
stdio: ["ignore", "pipe", "ignore", "pipe"],
|
||||
});
|
||||
parent.unref();
|
||||
const release = () => {
|
||||
parent.stdio[3]?.destroy();
|
||||
if (parent.exitCode === null && parent.signalCode === null) parent.kill("SIGKILL");
|
||||
};
|
||||
try {
|
||||
const pid = await new Promise<number>((resolve) => {
|
||||
parent.stdout!.once("data", (chunk: Buffer) => resolve(Number(chunk.toString().trim())));
|
||||
});
|
||||
await expect.poll(() => psField(parent.pid!, "comm")).toMatch(/(^|\/)sleep$/);
|
||||
parent.stdio[3]!.destroy();
|
||||
await expect.poll(async () => (await psField(pid, "stat")).startsWith("Z")).toBe(true);
|
||||
const gone = spawn(process.execPath, ["-e", ""], { stdio: "ignore" });
|
||||
await new Promise((resolve) => gone.once("exit", resolve));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -562,6 +562,34 @@ describe("bare URL sentence punctuation", () => {
|
||||
expect(tree.children?.[0]?.children?.[0]?.attributes?.url).toBe("https://example.com/a.");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["https://a.com、https://b.com!", ["https://a.com", "、", "https://b.com", "!"]],
|
||||
["https://a.com(说明)以及", ["https://a.com", "(说明)以及"]],
|
||||
["看这里:https://example.com/foo,还有", ["看这里:", "https://example.com/foo", ",还有"]],
|
||||
["(https://example.com/a)", ["(", "https://example.com/a", ")"]],
|
||||
["见【https://example.com/a】。", ["见【", "https://example.com/a", "】。"]],
|
||||
["“https://example.com/a”…", ["“", "https://example.com/a", "”…"]],
|
||||
[
|
||||
"见https://zh.wikipedia.org/wiki/人工智能。",
|
||||
["见", "https://zh.wikipedia.org/wiki/人工智能", "。"],
|
||||
],
|
||||
])("ends a bare URL at CJK and typographic punctuation: %s", async (source, expected) => {
|
||||
const prepared = prepareMarkdownSource(source);
|
||||
const tree = recognizeTextLinks(
|
||||
await parseMarkdown(prepared.render(), { latexMath: false }),
|
||||
prepared.linkRecognition.text,
|
||||
prepared.linkRecognition.inlineCode,
|
||||
);
|
||||
const parts = (tree.children?.[0]?.children ?? []).map((node) =>
|
||||
node.type === "Link" ? String(node.attributes?.url) : String(node.content),
|
||||
);
|
||||
expect(parts).toEqual(expected);
|
||||
expect(toNativeLinkRecognition(prepared.linkRecognition).text).toMatchObject({
|
||||
isDisabled: false,
|
||||
isDefault: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("requires workspace context for relative inline paths and escapes literal regex characters", () => {
|
||||
|
||||
@@ -37,9 +37,12 @@ function createTextPattern(path: string, forgeReferencePattern?: string): RegExp
|
||||
|
||||
// Matches bare URLs that MD4C does not autolink, like single-label or bracketed hosts.
|
||||
// Quotes and unbalanced closing parentheses end the URL. Trailing sentence punctuation stays outside.
|
||||
const URL_CHARACTER = String.raw`[^\s<>()"'\x60]`;
|
||||
// CJK, fullwidth and typographic punctuation (、。,()【】「」“”…) also ends it; CJK letters do not.
|
||||
const URL_BOUNDARY =
|
||||
"\\u2018\\u2019\\u201C\\u201D\\u2026\\u3000-\\u3003\\u3008-\\u3011\\u3014-\\u301F\\uFF01-\\uFF0F\\uFF1A-\\uFF20\\uFF3B-\\uFF40\\uFF5B-\\uFF65";
|
||||
const URL_CHARACTER = String.raw`[^\s<>()"'\x60${URL_BOUNDARY}]`;
|
||||
const URL_GROUP = String.raw`\(${URL_CHARACTER}*\)`;
|
||||
const BARE_URL = String.raw`https?://(?:${URL_CHARACTER}|${URL_GROUP})*(?:[^\s<>()"'\x60.,;:!?]|${URL_GROUP})`;
|
||||
const BARE_URL = String.raw`https?://(?:${URL_CHARACTER}|${URL_GROUP})*(?:[^\s<>()"'\x60.,;:!?${URL_BOUNDARY}]|${URL_GROUP})`;
|
||||
|
||||
const TEXT_PATTERN = createTextPattern(PROSE_PATH);
|
||||
// One entry per distinct workspace forge remote; reuse keeps the engine's regex identity stable.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -90,6 +90,16 @@ class FakeDaemonClient {
|
||||
cancelDictationStream(dictationId: string): void {
|
||||
this.cancels.push(dictationId);
|
||||
}
|
||||
|
||||
emitStreamError(dictationId: string, error: string): void {
|
||||
const message = {
|
||||
type: "dictation_stream_error",
|
||||
payload: { dictationId, error, retryable: true },
|
||||
} as SessionOutboundMessage;
|
||||
for (const listener of this.rawMessageListeners) {
|
||||
listener(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tick = async () => {
|
||||
@@ -314,6 +324,29 @@ describe("DictationStreamSender", () => {
|
||||
await expect(finish).resolves.toEqual({ dictationId: "d1", text: "ok" });
|
||||
});
|
||||
|
||||
it("fails finish with the daemon's stream error instead of waiting for acks", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
client.autoAck = false;
|
||||
let nextId = 0;
|
||||
const sender = new DictationStreamSender({
|
||||
client,
|
||||
format: "audio/pcm;rate=16000;bits=16",
|
||||
createDictationId: () => `d${++nextId}`,
|
||||
});
|
||||
|
||||
sender.enqueueSegment("seg0");
|
||||
await tick();
|
||||
client.emitStreamError("d1", "Local speech worker request timed out: commit");
|
||||
sender.enqueueSegment("seg1");
|
||||
await tick();
|
||||
|
||||
const finish = sender.finish(1);
|
||||
await expect(promiseOutcome(finish, 1000)).resolves.toBe("rejected");
|
||||
await expect(finish).rejects.toThrow("Local speech worker request timed out");
|
||||
expect(client.finishes).toEqual([]);
|
||||
expect(sender.getSegmentCount()).toBe(2);
|
||||
});
|
||||
|
||||
it("does not replay long buffered native dictation in one synchronous burst", async () => {
|
||||
const client = new FakeDaemonClient();
|
||||
client.isConnected = false;
|
||||
|
||||
@@ -8,6 +8,10 @@ const DICTATION_DRAIN_IDLE_TIMEOUT_MS = 30_000;
|
||||
const waitForNextFlushTurn = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
type DictationStreamAckMessage = Extract<SessionOutboundMessage, { type: "dictation_stream_ack" }>;
|
||||
type DictationStreamErrorMessage = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "dictation_stream_error" }
|
||||
>;
|
||||
|
||||
interface DrainWaitResult {
|
||||
kind: "signaled" | "timed-out";
|
||||
@@ -64,6 +68,7 @@ export class DictationStreamSender {
|
||||
|
||||
private startGeneration = 0;
|
||||
private startPromise: Promise<void> | null = null;
|
||||
private streamError: Error | null = null;
|
||||
|
||||
constructor(params: DictationStreamSenderParams) {
|
||||
this.format = params.format;
|
||||
@@ -88,10 +93,11 @@ export class DictationStreamSender {
|
||||
}
|
||||
|
||||
this.clientCleanup = client.subscribeRawMessages((message) => {
|
||||
if (message.type !== "dictation_stream_ack") {
|
||||
return;
|
||||
if (message.type === "dictation_stream_ack") {
|
||||
this.handleAck(message);
|
||||
} else if (message.type === "dictation_stream_error") {
|
||||
this.handleStreamError(message);
|
||||
}
|
||||
this.handleAck(message);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -127,6 +133,7 @@ export class DictationStreamSender {
|
||||
this.acknowledgedSeq = -1;
|
||||
this.streamReady = false;
|
||||
this.startPromise = null;
|
||||
this.streamError = null;
|
||||
this.startGeneration += 1;
|
||||
this.resolveDrainWaiters();
|
||||
}
|
||||
@@ -197,6 +204,7 @@ export class DictationStreamSender {
|
||||
this.sendSeq = 0;
|
||||
this.acknowledgedSeq = -1;
|
||||
this.streamReady = false;
|
||||
this.streamError = null;
|
||||
|
||||
const start = (async () => {
|
||||
await client.startDictationStream(dictationId, this.format);
|
||||
@@ -285,6 +293,25 @@ export class DictationStreamSender {
|
||||
this.resolveDrainWaiters();
|
||||
}
|
||||
|
||||
// The daemon drops a stream after an error and answers later chunks with
|
||||
// errors instead of acks, so waiting for delivery can only time out.
|
||||
private handleStreamError(message: DictationStreamErrorMessage): void {
|
||||
if (message.payload.dictationId !== this.dictationId || this.streamError) {
|
||||
return;
|
||||
}
|
||||
this.streamError = new Error(message.payload.error);
|
||||
this.resolveDrainWaiters();
|
||||
}
|
||||
|
||||
private throwStreamError(): void {
|
||||
const error = this.streamError;
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
this.invalidateStream();
|
||||
throw error;
|
||||
}
|
||||
|
||||
private invalidateStream(): void {
|
||||
this.resetStreamForReplay();
|
||||
}
|
||||
@@ -309,11 +336,13 @@ export class DictationStreamSender {
|
||||
|
||||
private async waitForFlushDrain(finalSeq: number): Promise<void> {
|
||||
while (this.hasPendingDelivery(finalSeq)) {
|
||||
this.throwStreamError();
|
||||
const client = this.client;
|
||||
if (!client?.isConnected || !this.dictationId || !this.streamReady) {
|
||||
throw new Error("Failed to flush dictation stream");
|
||||
}
|
||||
const result = await this.waitForDrainSignal();
|
||||
this.throwStreamError();
|
||||
if (result.kind === "timed-out") {
|
||||
this.invalidateStream();
|
||||
throw new Error("Timed out waiting for dictation stream delivery");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { buildWaitResult } from "./wait.js";
|
||||
|
||||
const agentId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
describe("buildWaitResult", () => {
|
||||
it("reports an identifier the daemon could not resolve as a missing agent", () => {
|
||||
expect(() =>
|
||||
buildWaitResult({
|
||||
state: {
|
||||
status: "error",
|
||||
final: null,
|
||||
error: "Agent not found: does-not-exist",
|
||||
lastMessage: null,
|
||||
},
|
||||
resolvedAgentId: "does-not-exist",
|
||||
recentActivity: null,
|
||||
timeoutLabel: null,
|
||||
}),
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
code: "AGENT_NOT_FOUND",
|
||||
message: "Agent not found: does-not-exist",
|
||||
details: "Use `paseo ls` to list available agents",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("still reports an agent that finished in error as a result", () => {
|
||||
const result = buildWaitResult({
|
||||
state: {
|
||||
status: "error",
|
||||
final: { id: agentId, lastError: "The provider returned 500" } as AgentSnapshotPayload,
|
||||
error: "The provider returned 500",
|
||||
lastMessage: null,
|
||||
},
|
||||
resolvedAgentId: agentId,
|
||||
recentActivity: null,
|
||||
timeoutLabel: null,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
agentId,
|
||||
status: "error",
|
||||
message: "The provider returned 500",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -86,7 +86,12 @@ type WaitFinishState = Awaited<
|
||||
ReturnType<Awaited<ReturnType<typeof connectToDaemon>>["waitForFinish"]>
|
||||
>;
|
||||
|
||||
function buildWaitResult(args: {
|
||||
/**
|
||||
* Turn the daemon's wait reply into the command result. Throws AGENT_NOT_FOUND when the
|
||||
* daemon resolved no agent: it answers an unknown identifier with an error and no agent
|
||||
* payload, while an agent that finished in error always comes with its payload.
|
||||
*/
|
||||
export function buildWaitResult(args: {
|
||||
state: WaitFinishState;
|
||||
resolvedAgentId: string;
|
||||
recentActivity: string | null;
|
||||
@@ -117,6 +122,13 @@ function buildWaitResult(args: {
|
||||
}
|
||||
|
||||
if (state.status === "error") {
|
||||
if (state.final === null) {
|
||||
throw {
|
||||
code: "AGENT_NOT_FOUND",
|
||||
message: state.error ?? `Agent not found: ${resolvedAgentId}`,
|
||||
details: "Use `paseo ls` to list available agents",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
return {
|
||||
agentId: resolvedAgentId,
|
||||
status: "error",
|
||||
@@ -155,6 +167,7 @@ export async function runWaitCommand(
|
||||
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
let reply: Omit<Parameters<typeof buildWaitResult>[0], "timeoutLabel">;
|
||||
try {
|
||||
try {
|
||||
const state = await client.waitForFinish(agentIdArg, timeoutMs);
|
||||
@@ -166,11 +179,7 @@ export async function runWaitCommand(
|
||||
|
||||
await client.close();
|
||||
|
||||
return {
|
||||
type: "single",
|
||||
data: buildWaitResult({ state, resolvedAgentId, recentActivity, timeoutLabel }),
|
||||
schema: agentWaitSchema,
|
||||
};
|
||||
reply = { state, resolvedAgentId, recentActivity };
|
||||
} catch (waitErr) {
|
||||
await client.close().catch(() => {});
|
||||
|
||||
@@ -198,4 +207,10 @@ export async function runWaitCommand(
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "single",
|
||||
data: buildWaitResult({ ...reply, timeoutLabel }),
|
||||
schema: agentWaitSchema,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
import assert from "node:assert";
|
||||
import { getAvailablePort } from "./helpers/network.ts";
|
||||
import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts";
|
||||
import { $ } from "zx";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
@@ -177,6 +178,28 @@ try {
|
||||
);
|
||||
console.log("timeout option documents no default limit\n");
|
||||
}
|
||||
|
||||
// Test 13: wait reports AGENT_NOT_FOUND for an unknown ID
|
||||
{
|
||||
console.log("Test 13: wait reports AGENT_NOT_FOUND for an unknown ID");
|
||||
const daemon = await startTestDaemon();
|
||||
try {
|
||||
const result = await runPaseoCli(daemon, [
|
||||
"wait",
|
||||
"does-not-exist",
|
||||
"--host",
|
||||
`127.0.0.1:${daemon.port}`,
|
||||
"--json",
|
||||
]);
|
||||
assert.notStrictEqual(result.exitCode, 0, "wait should fail for an unknown ID");
|
||||
const { error } = JSON.parse(result.stderr);
|
||||
assert.strictEqual(error.code, "AGENT_NOT_FOUND", result.stderr);
|
||||
assert.match(error.details, /paseo ls/);
|
||||
} finally {
|
||||
await daemon.stop();
|
||||
}
|
||||
console.log("✓ wait reports AGENT_NOT_FOUND for an unknown ID\n");
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
|
||||
@@ -13487,6 +13487,51 @@ test.each(["live", "history", "accepted"] as const)(
|
||||
},
|
||||
);
|
||||
|
||||
test("the last assistant message is the newest turn's reply, not joined to the previous turn's", async () => {
|
||||
const session = new TestAgentSession({ provider: "codex", cwd: process.cwd() });
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-last-reply-"));
|
||||
const client = new (class extends TestAgentClient {
|
||||
override async createSession(): Promise<AgentSession> {
|
||||
return session;
|
||||
}
|
||||
})();
|
||||
const manager = new AgentManager({ clients: { codex: client }, logger });
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
const reply = (turnId: string, messageId: string, text: string) =>
|
||||
session.pushEvent({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
turnId,
|
||||
item: { type: "assistant_message", messageId, text },
|
||||
});
|
||||
try {
|
||||
session.pushEvent({ type: "turn_started", provider: "codex", turnId: "turn-1" });
|
||||
reply("turn-1", "message-1", "Earlier status, ");
|
||||
reply("turn-1", "message-1", "streamed in two chunks.");
|
||||
session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "turn-1" });
|
||||
await vi.waitFor(async () =>
|
||||
expect(await manager.getLastAssistantMessage(agent.id)).toBe(
|
||||
"Earlier status, streamed in two chunks.",
|
||||
),
|
||||
);
|
||||
|
||||
// A turn the provider starts on its own, such as a background task waking the agent,
|
||||
// has no user message between its reply and the previous turn's.
|
||||
session.pushEvent({ type: "turn_started", provider: "codex", turnId: "turn-2" });
|
||||
reply("turn-2", "message-2", "New status.");
|
||||
session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "turn-2" });
|
||||
await vi.waitFor(() =>
|
||||
expect(manager.getTimeline(agent.id).at(-1)).toMatchObject({ messageId: "message-2" }),
|
||||
);
|
||||
expect(await manager.getLastAssistantMessage(agent.id)).toBe("New status.");
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("reads the last durable assistant response without loading a stored-only child", async () => {
|
||||
const store = new RecordingTimelineStore();
|
||||
await store.appendCommitted("stored-child", {
|
||||
|
||||
@@ -4049,23 +4049,33 @@ export class AgentManager {
|
||||
private getLastAssistantMessageFromTimeline(
|
||||
timeline: readonly AgentTimelineItem[],
|
||||
): string | null {
|
||||
return this.getLastAssistantMessageSegmentFromTimeline(timeline)?.text ?? null;
|
||||
return (
|
||||
this.getLastAssistantMessageSegmentFromTimeline(timeline.map((item) => ({ item })))?.text ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private getLastAssistantMessageSegmentFromTimeline(
|
||||
timeline: readonly AgentTimelineItem[],
|
||||
timeline: readonly Pick<AgentTimelineRow, "item" | "turnId">[],
|
||||
): { text: string; startsAtBeginning: boolean } | null {
|
||||
// Collect the last contiguous assistant messages (Claude streams chunks)
|
||||
// Collect the last contiguous assistant messages of one turn (Claude streams chunks).
|
||||
// A turn the provider starts on its own has no user message before its reply.
|
||||
const chunks: string[] = [];
|
||||
let replyTurnId: string | undefined;
|
||||
let startsAtBeginning = false;
|
||||
for (let i = timeline.length - 1; i >= 0; i--) {
|
||||
const item = timeline[i];
|
||||
const { item, turnId } = timeline[i];
|
||||
if (item.type !== "assistant_message") {
|
||||
if (chunks.length) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (chunks.length === 0) {
|
||||
replyTurnId = turnId;
|
||||
} else if (turnId !== replyTurnId) {
|
||||
break;
|
||||
}
|
||||
chunks.push(item.text);
|
||||
startsAtBeginning = i === 0;
|
||||
}
|
||||
@@ -4081,8 +4091,9 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
private async getLastAssistantMessageFromStores(agentId: string): Promise<string | null> {
|
||||
const liveTimeline = this.timelineStore.getItems(agentId);
|
||||
const liveSegment = this.getLastAssistantMessageSegmentFromTimeline(liveTimeline);
|
||||
const liveSegment = this.getLastAssistantMessageSegmentFromTimeline(
|
||||
this.timelineStore.getRows(agentId),
|
||||
);
|
||||
if (!this.durableTimelineStore) {
|
||||
return liveSegment?.text ?? null;
|
||||
}
|
||||
|
||||
@@ -345,7 +345,7 @@ export class DictationStreamManager {
|
||||
}): Promise<void> {
|
||||
const state = this.streams.get(params.dictationId);
|
||||
if (!state) {
|
||||
this.failDictationStream(params.dictationId, "Dictation stream not started", true);
|
||||
this.emitUnknownStreamError(params.dictationId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -528,7 +528,17 @@ export class DictationStreamManager {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Chunks keep arriving for a stream the daemon already dropped, one error
|
||||
// per chunk; the failure that dropped it is logged where it happened.
|
||||
private emitUnknownStreamError(dictationId: string): void {
|
||||
this.emit({
|
||||
type: "dictation_stream_error",
|
||||
payload: { dictationId, error: "Dictation stream not started", retryable: true },
|
||||
});
|
||||
}
|
||||
|
||||
private failDictationStream(dictationId: string, error: string, retryable: boolean): void {
|
||||
this.logger.warn({ dictationId, error, retryable }, "Dictation stream failed");
|
||||
this.emit({
|
||||
type: "dictation_stream_error",
|
||||
payload: { dictationId, error, retryable },
|
||||
@@ -543,6 +553,10 @@ export class DictationStreamManager {
|
||||
const state = this.streams.get(dictationId);
|
||||
const debugRecordingPath = await this.maybePersistDictationStreamAudio(dictationId);
|
||||
if (!state || this.streams.get(dictationId) !== state) return;
|
||||
this.logger.warn(
|
||||
{ dictationId, error, retryable, ackSeq: state.ackSeq },
|
||||
"Dictation stream failed",
|
||||
);
|
||||
this.emit({
|
||||
type: "dictation_stream_error",
|
||||
payload: {
|
||||
|
||||
@@ -87,7 +87,12 @@ function shouldIncludeLocalProviderConfig(params: {
|
||||
params.providers.dictationStt.provider === "local") ||
|
||||
(params.providers.voiceStt.enabled !== false &&
|
||||
params.providers.voiceStt.provider === "local") ||
|
||||
(params.providers.voiceTts.enabled !== false && params.providers.voiceTts.provider === "local");
|
||||
(params.providers.voiceTts.enabled !== false &&
|
||||
params.providers.voiceTts.provider === "local") ||
|
||||
// Turn detection is always local, so an enabled local turn detector
|
||||
// needs the local provider config even when no speech site uses it.
|
||||
(params.providers.voiceTurnDetection.enabled !== false &&
|
||||
params.providers.voiceTurnDetection.provider === "local");
|
||||
|
||||
return (
|
||||
localRequestedByFeature ||
|
||||
|
||||
@@ -168,6 +168,122 @@ describe("resolveSpeechConfig", () => {
|
||||
},
|
||||
);
|
||||
|
||||
test("includes local provider config for local turn detection with OpenAI voice STT/TTS", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
features: {
|
||||
dictation: { enabled: false },
|
||||
voiceMode: {
|
||||
enabled: true,
|
||||
stt: { provider: "openai", apiKey: "test-key", model: "gpt-4o-transcribe" },
|
||||
tts: { provider: "openai", apiKey: "test-key", model: "gpt-4o-mini-tts", voice: "alloy" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveSpeechConfig({
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
persisted,
|
||||
});
|
||||
|
||||
expect(result.speech.providers.voiceTurnDetection).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.providers.voiceStt).toEqual({
|
||||
provider: "openai",
|
||||
explicit: true,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.providers.voiceTts).toEqual({
|
||||
provider: "openai",
|
||||
explicit: true,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.providers.dictationStt).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: false,
|
||||
});
|
||||
// Turn detection is always local, so the local provider config must be
|
||||
// present even when no speech site uses the local provider and no
|
||||
// models directory is configured; otherwise voice mode reports
|
||||
// turn_detection_unavailable.
|
||||
expect(result.speech.local).toEqual({
|
||||
modelsDir: path.join("/tmp/paseo-home", "models", "local-speech"),
|
||||
models: {
|
||||
dictationStt: "parakeet-tdt-0.6b-v2-int8",
|
||||
voiceStt: "parakeet-tdt-0.6b-v2-int8",
|
||||
voiceTts: "kokoro-en-v0_19",
|
||||
voiceTtsSpeakerId: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("includes local provider config for local turn detection with dictation on OpenAI", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
features: {
|
||||
dictation: {
|
||||
enabled: true,
|
||||
stt: { provider: "openai", apiKey: "test-key", model: "gpt-4o-transcribe" },
|
||||
},
|
||||
voiceMode: {
|
||||
enabled: true,
|
||||
stt: { provider: "openai", apiKey: "test-key", model: "gpt-4o-transcribe" },
|
||||
tts: { provider: "openai", apiKey: "test-key", model: "gpt-4o-mini-tts", voice: "alloy" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveSpeechConfig({
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
persisted,
|
||||
});
|
||||
|
||||
expect(result.speech.providers.voiceTurnDetection).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.local).toEqual({
|
||||
modelsDir: path.join("/tmp/paseo-home", "models", "local-speech"),
|
||||
models: {
|
||||
dictationStt: "parakeet-tdt-0.6b-v2-int8",
|
||||
voiceStt: "parakeet-tdt-0.6b-v2-int8",
|
||||
voiceTts: "kokoro-en-v0_19",
|
||||
voiceTtsSpeakerId: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("omits local provider config when voice mode is off and no speech site is local", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
features: {
|
||||
dictation: { enabled: false },
|
||||
voiceMode: {
|
||||
enabled: false,
|
||||
stt: { provider: "openai", apiKey: "test-key", model: "gpt-4o-transcribe" },
|
||||
tts: { provider: "openai", apiKey: "test-key", model: "gpt-4o-mini-tts", voice: "alloy" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveSpeechConfig({
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
persisted,
|
||||
});
|
||||
|
||||
expect(result.speech.providers.voiceTurnDetection).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: false,
|
||||
});
|
||||
expect(result.speech.local).toBeUndefined();
|
||||
});
|
||||
|
||||
test("respects disabled dictation and voice mode feature flags", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
features: {
|
||||
|
||||
Reference in New Issue
Block a user