Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andClaude Opus 5.5 0273b6b614 fix(app): end bare URLs at CJK and fullwidth punctuation (#9799)
A bare URL that md4c does not autolink is linked by the text-link
pattern. That pattern stopped only at ASCII whitespace, quotes and
sentence punctuation, so "https://a.com、https://b.com!" became one
link, and "看这里:https://example.com/foo,还有" linked ",还有" too.
Opening the link went to a URL ending in the encoded punctuation.

CJK, fullwidth and typographic punctuation (、。,()【】「」“”…) now
ends the URL. CJK letters in paths still stay in the link.

Refs #9743

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 20:52:24 +00:00
paseo-bot[bot]andClaude Opus 5.5 8c88b83cd8 fix(server): preview the newest turn's reply in finished notifications (#9793)
A turn the provider starts on its own, such as Claude waking after a
background subagent, has no user message before its reply. The last
assistant message lookup joined every adjacent assistant row, so it
returned the previous turn's reply with the new one appended, and the
220-character notification preview showed only the old reply.

Stop the backward scan at the turn boundary so the lookup returns only
the newest turn's reply, while chunks of one turn are still joined.

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 20:02:46 +00:00
tieoandClaude Opus 5.5 b55955541e fix(dictation): surface the daemon's stream error instead of a delivery timeout (#9784)
The daemon drops a dictation stream when its STT session errors and answers
every later chunk with dictation_stream_error. The app only listened for
acks, so finishing waited 30s and reported a generic timeout. The sender now
fails finish with the daemon's message, and the daemon logs the failure that
dropped the stream once instead of nothing.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 19:05:47 +00:00
paseo-bot[bot] 2c5f5a1485 test(e2e): keep the planted zombie until its shell has exec'd (#9808)
The daemon-lifecycle cleanup-failure test planted a zombie with
`sleep 0 & echo $!; exec sleep 600`. dash reaps any exited child after
a builtin, so when `sleep 0` exited before `echo` returned, the shell
reaped it and `ps -o stat= -p <pid>` failed. The child now reads fd 3
until the helper closes it, which it does only after the shell has
become `sleep`.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-15 18:38:16 +00:00
685e113358 Fail paseo agent wait when no agent matches the id (#9782)
* fix(cli): fail agent wait when the daemon resolved no agent

The daemon answers wait_for_finish with status "error" both when it could not
resolve the identifier to an agent (final: null) and when the agent it did
resolve finished in error (final: the agent payload). runWaitCommand handed
both to buildWaitResult, which returns a normal result row, so
`paseo agent wait <unknown id>` printed "Agent not found: ..." and exited 0
while every sibling command exits 1 with AGENT_NOT_FOUND (#9736).

Throw AGENT_NOT_FOUND when the state is error and the payload is null, and let
that command error through the inner catch instead of rewrapping it as
WAIT_FAILED. A genuine agent failure still carries its payload, so it keeps
printing its result row. The wire schema is unchanged.

* test(cli): cover the agent wait not-found mapping without module mocks

Move the not-found decision into buildWaitResult, which maps the daemon's
wait reply to the command result, and test that function directly with plain
reply objects instead of mocking the daemon client module. The e2e test in
tests/11-agent-wait.test.ts still covers the command against a real daemon.

Co-authored-by: Eapp1e <163811520+Eapp1e@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): keep daemon errors during agent wait reported as WAIT_FAILED

Map the wait reply to the result after the wait's try/catch, so the
AGENT_NOT_FOUND error leaves without the inner catch passing every coded
error through. Connection and RPC errors from waitForFinish are wrapped as
WAIT_FAILED again, as on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Eapp1e <163811520+Eapp1e@users.noreply.github.com>
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 17:54:27 +00:00
Huyvux12 28322f1519 fix(server): include local provider config when turn detection is local (#9766) (#9779) 2026-11-15 17:09:46 +00: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
28 changed files with 888 additions and 70 deletions
@@ -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");
+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(
@@ -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",
});
});
});
+21 -6
View File
@@ -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,
};
}
+23
View File
@@ -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: {