Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 4293292d00 fix(app): preserve web modal focus across workspace switches 2026-11-15 21:52:45 +01:00
Mohamed Boudra c94c3c47fe fix(app): release hidden workspace overlay keyboard ownership 2026-11-15 21:39:40 +01:00
Mohamed Boudra 158032ac40 fix(app): preserve pending transcription and hide inactive modals 2026-11-15 21:22:37 +01:00
Mohamed Boudra 437fb8af0b test(app): verify review drafts survive retained workspace switches 2026-11-15 21:00:40 +01:00
Mohamed Boudra 2ef65f7691 fix(app): close sheets and dictation when retained owners hide 2026-11-15 20:46:46 +01:00
Mohamed Boudra 79620eeea4 perf(app): keep recent native workspace switches warm
A one-workspace native budget remounted chat and composer on every return. Retain three recent workspaces with the existing LRU and expiry; tear down native file-sheet owners on navigation and hide modal sheets for inactive panels.
2026-11-15 20:30:18 +01: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
24 changed files with 635 additions and 91 deletions
+6 -2
View File
@@ -126,8 +126,12 @@ keeps Explorer underneath and closing the sheet uncovers it. Semantic navigation
actual destination before revealing it, including deduplicated targets.
Compact overlay selection and saved dock visibility are independent. Folding changes geometry,
not either mode’s visibility intent. Native workspace retention remains one workspace; web retains
up to ten with its existing inactive TTL.
not either mode’s visibility intent. Native retains up to three recent workspaces; web retains
up to ten. Both use the existing inactive TTL. A native workspace with an open file sheet still
unmounts on navigation because its sheet stack uses the app-wide host. Other sheets close when
their retained owner becomes inactive, unless the owner already hides them on focus loss.
Import-session and tab-rename forms retain their open state on return. Review drafts survive
presentation unmounts.
PR discovery belongs to the active workspace: split layouts discover while active, and other
layouts discover while Explorer is active. The rail requests summary status; selected visible PR
+3 -1
View File
@@ -58,7 +58,9 @@ cannot deliver them.
Register keyboard handling with `useOverlayRegistration` from `@/keyboard`.
Pass the surface's open state, layer, handled keys and `onKeyDown`. The highest
layer gets first refusal; the latest registration wins a tie. Native registration
does not need a DOM ref. Web adds focus trapping and restoration through its adapter.
does not need a DOM ref. The native adapter releases overlay keyboard ownership when a retained
panel hides, even if the owner keeps its open state. Web releases ownership when the scope
detaches, preserving the original focus-return target across a hide.
Declaring Tab takes it from the web focus trap and hands it to `onKeyDown`; the command
center rotates its tabs with it. Native sources drop Tab from the declarations.
@@ -148,3 +148,51 @@ test.describe("mobile bottom sheet reopen", () => {
}
});
});
async function switchToNextWorkspace(page: Page, workspaceId: string) {
await page.keyboard.press("Alt+]");
await page.waitForURL((url) => url.pathname.endsWith(`/workspace/${workspaceId}`));
}
test("leaving a retained workspace dismisses its model sheet", async ({ page }) => {
const first = await openMockAgentAtMobileBreakpoint(page);
const second = await seedMockAgentWorkspace({
repoPrefix: "sheet-other-workspace-",
title: "Other workspace",
initialPrompt: "Prepare another workspace.",
});
try {
await openModelSelector(page);
await switchToNextWorkspace(page, second.workspaceId);
await expect(page.getByTestId("agent-controls-model-sheet")).not.toBeInViewport();
await expectComposerVisible(page);
await switchToNextWorkspace(page, first.workspaceId);
await expect(page.getByTestId("agent-controls-model-sheet")).not.toBeInViewport();
await expectComposerVisible(page);
} finally {
await second.cleanup();
await first.cleanup();
}
});
test("leaving a retained workspace closes its context window sheet", async ({ page }) => {
const first = await openMockAgentAtMobileBreakpoint(page);
const second = await seedMockAgentWorkspace({
repoPrefix: "context-sheet-other-",
title: "Other workspace",
});
try {
await page.getByRole("button", { name: /Context window.*used/ }).click();
const sheet = page.getByRole("dialog", { name: "Context window", exact: true });
await expect(sheet).toBeInViewport();
await switchToNextWorkspace(page, second.workspaceId);
await expect(sheet).not.toBeInViewport();
await expectComposerVisible(page);
await switchToNextWorkspace(page, first.workspaceId);
await expect(sheet).not.toBeInViewport();
await expectComposerVisible(page);
} finally {
await second.cleanup();
await first.cleanup();
}
});
@@ -1,3 +1,4 @@
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { expect, test } from "../support/fixtures";
import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
@@ -310,3 +311,32 @@ test.describe("Composer attachments", () => {
}
});
});
test("image lightbox hides when its retained workspace becomes inactive", async ({ page }) => {
const first = await seedMockAgentWorkspace({
repoPrefix: "image-owner-",
title: "Image workspace",
});
const second = await seedMockAgentWorkspace({
repoPrefix: "image-other-",
title: "Other workspace",
});
try {
await openAgentRoute(page, first);
await expectComposerVisible(page);
await attachImageFromMenu(page, TEST_IMAGE);
await openImageLightbox(page);
await page.keyboard.press("Alt+]");
await page.waitForURL((url) => url.pathname.endsWith(`/workspace/${second.workspaceId}`));
await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible();
await expectComposerVisible(page);
await page.keyboard.press("Alt+]");
await page.waitForURL((url) => url.pathname.endsWith(`/workspace/${first.workspaceId}`));
await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible();
await closeImageLightbox(page);
await expect(page.getByRole("button", { name: "Open image attachment" }).first()).toBeFocused();
} finally {
await second.cleanup();
await first.cleanup();
}
});
@@ -248,6 +248,33 @@ test.describe("review comments", () => {
}
});
test("compact review draft survives a workspace switch", async ({ page }) => {
const workspace = await seedChangedAgent("retained-review-draft-");
const other = await seedMockAgentWorkspace({
repoPrefix: "review-other-",
title: "Other workspace",
});
try {
await openReviewDiff(page, workspace);
await showCompactLayout(page);
await tapFirstAddedDiffLine(page);
await expectCompactCommentSheet(page);
await fillReviewComment(page, "Keep this unsaved review");
await page.keyboard.press("Alt+]");
await page.waitForURL((url) => url.pathname.endsWith(`/workspace/${other.workspaceId}`));
await expect(page.getByTestId("review-comment-sheet")).not.toBeInViewport();
await page.keyboard.press("Alt+]");
await page.waitForURL((url) => url.pathname.endsWith(`/workspace/${workspace.workspaceId}`));
await expect(page.getByRole("textbox", { name: "Review comment" })).toHaveValue(
"Keep this unsaved review",
);
await cancelReviewComment(page);
} finally {
await other.cleanup();
await workspace.cleanup();
}
});
test("wide review comments retain inline editing", async ({ page }) => {
const workspace = await seedChangedAgent("wide-review-comment-");
try {
@@ -70,6 +70,8 @@ function enableDictationCapability(message: WebSocketMessage): WebSocketMessage
async function installSyntheticMicrophone(page: Page): Promise<void> {
await page.addInitScript(() => {
const mediaDevices = navigator.mediaDevices;
const tracks: MediaStreamTrack[] = [];
Object.defineProperty(window, "dictationTestTracks", { value: tracks });
Object.defineProperty(mediaDevices, "getUserMedia", {
configurable: true,
value: async () => {
@@ -78,13 +80,15 @@ async function installSyntheticMicrophone(page: Page): Promise<void> {
const destination = context.createMediaStreamDestination();
oscillator.connect(destination);
oscillator.start();
tracks.push(...destination.stream.getTracks());
return destination.stream;
},
});
});
}
async function installDictationFailureHarness(page: Page) {
async function installDictationFailureHarness(page: Page, holdTranscript = false) {
let deliverTranscript: (() => void) | null = null;
let resolveAudioChunk!: () => void;
let resolveCreateRequest!: () => void;
const audioChunk = new Promise<void>((resolve) => {
@@ -122,12 +126,14 @@ async function installDictationFailureHarness(page: Page) {
if (type === "dictation_stream_finish" && dictationId) {
sendSessionMessage(ws, {
type: "dictation_stream_finish_accepted",
payload: { dictationId, timeoutMs: 5_000 },
});
sendSessionMessage(ws, {
type: "dictation_stream_final",
payload: { dictationId, text: TRANSCRIPT },
payload: { dictationId, timeoutMs: holdTranscript ? 60_000 : 5_000 },
});
deliverTranscript = () =>
sendSessionMessage(ws, {
type: "dictation_stream_final",
payload: { dictationId, text: TRANSCRIPT },
});
if (!holdTranscript) deliverTranscript();
return;
}
if (request && type === "workspace.create.request" && typeof request.requestId === "string") {
@@ -155,6 +161,11 @@ async function installDictationFailureHarness(page: Page) {
return {
waitForAudio: () => audioChunk,
hasPendingTranscript: () => deliverTranscript !== null,
deliverTranscript: () => {
if (!deliverTranscript) throw new Error("No pending transcript");
deliverTranscript();
},
waitForCreateRequest: () => createRequest,
failWorkspaceCreation: () => {
if (!failCreate) {
@@ -254,3 +265,75 @@ test("dictation shortcut leaves retained fullscreen and exposes recording contro
await agent.cleanup();
}
});
test("leaving a retained workspace stops dictation and preserves its draft", async ({ page }) => {
const first = await seedMockAgentWorkspace({
repoPrefix: "dictation-owner-",
title: "Recording workspace",
});
const second = await seedMockAgentWorkspace({
repoPrefix: "dictation-other-",
title: "Other workspace",
});
await installSyntheticMicrophone(page);
const harness = await installDictationFailureHarness(page);
try {
await openAgentRoute(page, first);
await expectComposerVisible(page);
await fillComposerDraft(page, "Keep this draft");
await page.getByRole("button", { name: "Start dictation", exact: true }).click();
await harness.waitForAudio();
await page.keyboard.press("Alt+]");
await page.waitForURL((url) => url.pathname.endsWith(`/workspace/${second.workspaceId}`));
await expect.poll(() => microphoneTracksEnded(page)).toBe(true);
await page.keyboard.press("Alt+]");
await page.waitForURL((url) => url.pathname.endsWith(`/workspace/${first.workspaceId}`));
await expect(composerLocator(page)).toHaveValue("Keep this draft");
await expect(page.getByRole("button", { name: "Cancel dictation", exact: true })).toHaveCount(
0,
);
} finally {
await second.cleanup();
await first.cleanup();
}
});
function microphoneTracksEnded(page: Page): Promise<boolean> {
return page.evaluate(() => {
const tracks = (window as unknown as { dictationTestTracks: MediaStreamTrack[] })
.dictationTestTracks;
return tracks.length > 0 && tracks.every((track) => track.readyState === "ended");
});
}
test("confirmed dictation survives leaving and returning while pending", async ({ page }) => {
const first = await seedMockAgentWorkspace({
repoPrefix: "dictation-processing-",
title: "Processing workspace",
});
const second = await seedMockAgentWorkspace({
repoPrefix: "dictation-other-",
title: "Other workspace",
});
await installSyntheticMicrophone(page);
const harness = await installDictationFailureHarness(page, true);
try {
await openAgentRoute(page, first);
await expectComposerVisible(page);
await page.getByRole("button", { name: "Start dictation", exact: true }).click();
await harness.waitForAudio();
await page.getByRole("button", { name: "Insert transcription", exact: true }).click();
await expect.poll(harness.hasPendingTranscript).toBe(true);
await page.keyboard.press("Alt+]");
await page.waitForURL((url) => url.pathname.endsWith(`/workspace/${second.workspaceId}`));
await expect.poll(() => microphoneTracksEnded(page)).toBe(true);
await page.keyboard.press("Alt+]");
await page.waitForURL((url) => url.pathname.endsWith(`/workspace/${first.workspaceId}`));
await expect(page.getByRole("button", { name: "Cancel dictation", exact: true })).toBeVisible();
harness.deliverTranscript();
await expect(composerLocator(page)).toHaveValue(TRANSCRIPT);
} finally {
await second.cleanup();
await first.cleanup();
}
});
@@ -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));
@@ -237,7 +237,12 @@ function WorkspaceDeckEntry({
}) {
const hasHydratedWorkspaces = useHasHydratedWorkspaces(selection.serverId);
const workspaceExists = useWorkspaceExists(selection.serverId, selection.workspaceId);
const hasOpenNativeSheets = useWorkspaceLayoutStore(
(state) =>
isNative && (state.sheetsByWorkspace[getWorkspaceSelectionKey(selection)]?.length ?? 0) > 0,
);
const shouldKeepMounted = shouldKeepWorkspaceDeckEntryMounted({
hasOpenNativeSheets,
isActive: active,
hasHydratedWorkspaces,
workspaceExists,
@@ -662,7 +662,7 @@ function CompactSheetBody({
export function AdaptiveModalSheet({
header,
visible,
visible: requestedVisible,
onClose,
onDismiss,
children,
@@ -680,9 +680,15 @@ export function AdaptiveModalSheet({
}: AdaptiveModalSheetProps) {
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const isKeyboardVisible = useKeyboardVisibility(visible);
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
const isSheet = isMobile || !isWeb;
const { visible, sheetRef, handleSheetChange, handleSheetDismiss } =
useIsolatedBottomSheetVisibility({
visible: requestedVisible,
isEnabled: isSheet,
onClose,
});
const isKeyboardVisible = useKeyboardVisibility(visible);
// The sheet registers itself; the desktop dialog does not use it.
useDismissibleOverlay(visible && !isSheet, onClose);
const footerView = useMemo(
@@ -697,11 +703,6 @@ export function AdaptiveModalSheet({
}
}, [visible]);
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible,
isEnabled: isSheet,
onClose,
});
const [shouldRenderWeb, setShouldRenderWeb] = useState(visible);
const [isWebClosing, setIsWebClosing] = useState(false);
const modalLayer = useGlobalOverlayLayer("modal", visible || shouldRenderWeb);
@@ -1,3 +1,4 @@
import { useRetainedPanelActive } from "@/components/retained-panel";
import {
BottomSheetModal as GorhomBottomSheetModal,
type BottomSheetModalProps,
@@ -160,7 +161,7 @@ export const IsolatedBottomSheetModal = forwardRef<
});
export function useIsolatedBottomSheetVisibility({
visible,
visible: requestedVisible,
isEnabled,
onClose,
onBack,
@@ -171,6 +172,8 @@ export function useIsolatedBottomSheetVisibility({
/** Offered Android Back before it closes the sheet; returns true when it handled the press. */
onBack?: () => boolean;
}) {
const active = useRetainedPanelActive();
const visible = requestedVisible && active;
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
const onBackRef = useRef(onBack);
@@ -201,11 +204,12 @@ export function useIsolatedBottomSheetVisibility({
const handleSheetDismiss = useCallback(() => tracker.handleSheetDismiss(), [tracker]);
useEffect(() => {
tracker.syncDesired({ visible, isEnabled });
}, [isEnabled, tracker, visible]);
tracker.syncDesired({ visible: requestedVisible, active, isEnabled });
}, [active, isEnabled, tracker, requestedVisible]);
useDismissibleOverlay(visible && isEnabled !== false, onClose);
return {
visible,
sheetRef: setSheetRef,
handleSheetChange,
handleSheetDismiss,
@@ -291,3 +291,18 @@ it("does not cancel the owner's editor when the compact sheet leaves the tree on
expect(context.closeCount()).toBe(0);
expect(context.backPress.press()).toBe(false);
});
it("closes a retained owner's sheet and releases Back when the owner becomes inactive", () => {
const { sheet, tracker, backPress, closeCount } = setup();
tracker.attachController(sheet);
tracker.syncDesired({ visible: true, active: true });
tracker.handleSheetIndexChange(0);
tracker.syncDesired({ visible: true, active: false });
expect(sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]);
expect(closeCount()).toBe(1);
expect(backPress.press()).toBe(false);
tracker.syncDesired({ visible: false, active: false });
tracker.handleSheetDismiss();
tracker.syncDesired({ visible: false, active: true });
expect(sheet.events).toHaveLength(2);
});
@@ -5,6 +5,7 @@ export interface BottomSheetController {
export interface BottomSheetVisibilityInput {
visible: boolean;
active?: boolean;
isEnabled?: boolean;
}
@@ -101,8 +102,15 @@ export function createBottomSheetVisibilityTracker(opts: {
}
},
syncDesired(next) {
visible = next.visible;
visible = next.visible && next.active !== false;
isEnabled = next.isEnabled;
// Leaving a retained owner is a close, not a temporary presentation pause.
// Clear its desired state so returning does not reopen the old sheet.
if (next.visible && next.active === false) {
dismiss();
notifyClose();
return;
}
if (isEnabled === false) return;
if (visible) {
present();
+15 -2
View File
@@ -1,3 +1,4 @@
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useMemo } from "react";
import {
Modal as NativeModal,
@@ -23,11 +24,23 @@ type ModalProps = Omit<NativeModalProps, "onRequestClose"> & {
function noClose() {}
export function Modal(props: ModalProps) {
const { visible = true, onRequestClose, style } = props;
const { visible: requestedVisible = true, onRequestClose, style } = props;
const active = useRetainedPanelActive();
const visible = requestedVisible && active;
useDismissibleOverlay(visible && onRequestClose !== undefined, onRequestClose ?? noClose);
const windowFrame = useAndroidWindowFrame();
const containerStyle = useMemo(() => [windowFrame, style], [windowFrame, style]);
return <NativeModal supportedOrientations={orientations} {...props} style={containerStyle} />;
// Hidden web chats freeze before a modal exit animation can finish.
// Remove the portal while its owner is inactive.
if (!active) return null;
return (
<NativeModal
supportedOrientations={orientations}
{...props}
visible={visible}
style={containerStyle}
/>
);
}
/**
+6
View File
@@ -1,3 +1,4 @@
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -16,6 +17,7 @@ import {
export function useDictation(options: UseDictationOptions): UseDictationResult {
const { t } = useTranslation();
const active = useRetainedPanelActive();
const {
client,
onTranscript,
@@ -336,6 +338,10 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
}
}, [audio, clearStreamingState, reportError, stopDurationTracking]);
useEffect(() => {
if (!active && isRecording && !isProcessing) void cancelDictation();
}, [active, cancelDictation, isRecording, isProcessing]);
const confirmDictation = useCallback(async () => {
if (actionGateRef.current.confirming) {
return;
@@ -1,18 +1,21 @@
import { useRetainedPanelActive } from "@/components/retained-panel";
import type { OverlayRegistration } from "./overlay-contract";
import { useLayoutEffect, useRef } from "react";
import { overlays } from "./overlays";
const setScope = (_node: unknown) => {};
export function useOverlayRegistration(input: OverlayRegistration) {
const ownerActive = useRetainedPanelActive();
const active = input.active && ownerActive;
const current = useRef(input);
current.current = input;
useLayoutEffect(() => {
if (!input.active) return;
if (!active) return;
return overlays.register({
getLayer: () => current.current.layer,
getKeys: () => current.current.keys,
onKeyDown: (event) => current.current.onKeyDown(event),
});
}, [input.active]);
}, [active]);
return setScope;
}
@@ -1,48 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildEffectiveBindings } from "./keyboard-shortcuts";
import { createKeyboardPipeline } from "./internal/pipeline";
import { createOverlayRegistry } from "./internal/overlays";
describe("DOM source adapter", () => {
it("runs DOM input through the same resolver, preserving cancellation and cleanup", async () => {
const { attachDomKeyboardSource } = await import("./internal/sources/dom.web");
const actions: string[] = [];
const input = document.createElement("textarea");
input.dataset.testid = "message-input";
document.body.append(input);
input.focus();
const pipeline = createKeyboardPipeline({
getContext: () => ({ isMac: true, usesDesktopBindings: false, commandCenterOpen: false }),
getBindings: () => buildEffectiveBindings({ "command-center-toggle-cmd-k-mac": "Cmd+J" }),
isCapturing: () => false,
dispatch: (match) => {
actions.push(match.action);
return true;
},
overlays: createOverlayRegistry(),
});
const detach = attachDomKeyboardSource({
keyDown: pipeline.keyDown,
keyUp() {},
blur() {},
commandCenterOpen: () => false,
subscribeCommandCenter: () => () => {},
});
const event = new KeyboardEvent("keydown", {
key: "j",
code: "KeyJ",
metaKey: true,
bubbles: true,
cancelable: true,
});
input.dispatchEvent(event);
expect(actions).toEqual(["command-center.toggle"]);
expect(event.defaultPrevented).toBe(true);
detach();
input.dispatchEvent(
new KeyboardEvent("keydown", { key: "j", code: "KeyJ", metaKey: true, bubbles: true }),
);
expect(actions).toHaveLength(1);
input.remove();
pipeline.dispose();
});
});
@@ -0,0 +1,97 @@
import React from "react";
import { describe, expect, it } from "vitest";
import { buildEffectiveBindings } from "./keyboard-shortcuts";
import { createKeyboardPipeline } from "./internal/pipeline";
import { createOverlayRegistry } from "./internal/overlays";
describe("DOM source adapter", () => {
it("runs DOM input through the same resolver, preserving cancellation and cleanup", async () => {
const { attachDomKeyboardSource } = await import("./internal/sources/dom.web");
const actions: string[] = [];
const input = document.createElement("textarea");
input.dataset.testid = "message-input";
document.body.append(input);
input.focus();
const pipeline = createKeyboardPipeline({
getContext: () => ({ isMac: true, usesDesktopBindings: false, commandCenterOpen: false }),
getBindings: () => buildEffectiveBindings({ "command-center-toggle-cmd-k-mac": "Cmd+J" }),
isCapturing: () => false,
dispatch: (match) => {
actions.push(match.action);
return true;
},
overlays: createOverlayRegistry(),
});
const detach = attachDomKeyboardSource({
keyDown: pipeline.keyDown,
keyUp() {},
blur() {},
commandCenterOpen: () => false,
subscribeCommandCenter: () => () => {},
});
const event = new KeyboardEvent("keydown", {
key: "j",
code: "KeyJ",
metaKey: true,
bubbles: true,
cancelable: true,
});
input.dispatchEvent(event);
expect(actions).toEqual(["command-center.toggle"]);
expect(event.defaultPrevented).toBe(true);
detach();
input.dispatchEvent(
new KeyboardEvent("keydown", { key: "j", code: "KeyJ", metaKey: true, bubbles: true }),
);
expect(actions).toHaveLength(1);
input.remove();
pipeline.dispose();
});
});
it("releases a hidden retained owner's overlay keys while its scope stays mounted", async () => {
const { createElement, act } = await import("react");
const { createRoot } = await import("react-dom/client");
const { RetainedPanelActivity } = await import("@/components/retained-panel");
const { getTopOverlayKeys, hasActiveOverlay } = await import("./index");
// Exercise the native adapter itself in a real React renderer, bypassing Vite's .web resolution.
const nativeAdapterPath = "./internal/overlay-registration.ts";
const { useOverlayRegistration } = (await import(
/* @vite-ignore */ nativeAdapterPath
)) as typeof import("./internal/overlay-registration");
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
function Viewer() {
const scope = useOverlayRegistration({
active: true,
layer: 100,
keys: ["Escape"],
manageFocus: false,
onKeyDown: () => true,
});
return createElement("button", { ref: scope, type: "button" }, "Viewer");
}
async function render(active: boolean) {
await act(async () =>
root.render(
<RetainedPanelActivity active={active}>
<Viewer />
</RetainedPanelActivity>,
),
);
}
try {
await render(true);
expect(getTopOverlayKeys()).toEqual(["Escape"]);
await render(false);
expect(container.querySelector("button")).not.toBeNull();
expect(hasActiveOverlay()).toBe(false);
expect(getTopOverlayKeys()).toEqual([]);
await render(true);
expect(getTopOverlayKeys()).toEqual(["Escape"]);
} finally {
await act(async () => root.unmount());
container.remove();
}
});
@@ -30,15 +30,24 @@ function retainedWorkspaceIds(
}
describe("reconcileRetainedWorkspaceSelections", () => {
it("retains only the active workspace on native", () => {
const entries = reconcileRetainedWorkspaceSelections({
currentEntries: [retained("A", null)],
activeSelection: workspace("B"),
now: 2,
maxMountedWorkspaces: resolveWorkspaceDeckRetentionLimit({ isNative: true }),
});
it("keeps recent native workspaces warm and evicts the least recently used workspace", () => {
let entries = [retained("A", null)];
const visit = (workspaceId: string, now: number) => {
entries = reconcileRetainedWorkspaceSelections({
currentEntries: entries,
activeSelection: workspace(workspaceId),
now,
maxMountedWorkspaces: resolveWorkspaceDeckRetentionLimit({ isNative: true }),
});
return retainedWorkspaceIds(entries);
};
expect(retainedWorkspaceIds(entries)).toEqual(["B"]);
expect(visit("B", 1)).toEqual(["B", "A"]);
expect(visit("C", 2)).toEqual(["C", "B", "A"]);
expect(visit("A", 3)).toEqual(["A", "C", "B"]);
expect(visit("D", 4)).toEqual(["D", "A", "C"]);
expect(visit("A", 5)).toEqual(["A", "D", "C"]);
expect(visit("E", 6)).toEqual(["E", "A", "D"]);
});
it("retains the desktop deck limit on web", () => {
@@ -179,9 +188,23 @@ describe("resolveWorkspaceDeckEntries", () => {
});
describe("shouldKeepWorkspaceDeckEntryMounted", () => {
it("tears down native sheet owners when leaving, while keeping the active sheet mounted", () => {
for (const isActive of [true, false]) {
expect(
shouldKeepWorkspaceDeckEntryMounted({
isActive,
hasOpenNativeSheets: true,
hasHydratedWorkspaces: true,
workspaceExists: true,
}),
).toBe(isActive);
}
});
it("keeps the active workspace mounted even when it is missing from hydrated workspaces", () => {
expect(
shouldKeepWorkspaceDeckEntryMounted({
hasOpenNativeSheets: false,
isActive: true,
hasHydratedWorkspaces: true,
workspaceExists: false,
@@ -192,6 +215,7 @@ describe("shouldKeepWorkspaceDeckEntryMounted", () => {
it("keeps inactive workspaces mounted until workspace hydration finishes", () => {
expect(
shouldKeepWorkspaceDeckEntryMounted({
hasOpenNativeSheets: false,
isActive: false,
hasHydratedWorkspaces: false,
workspaceExists: false,
@@ -202,6 +226,7 @@ describe("shouldKeepWorkspaceDeckEntryMounted", () => {
it("unmounts inactive workspaces that are gone after hydration", () => {
expect(
shouldKeepWorkspaceDeckEntryMounted({
hasOpenNativeSheets: false,
isActive: false,
hasHydratedWorkspaces: true,
workspaceExists: false,
@@ -212,6 +237,7 @@ describe("shouldKeepWorkspaceDeckEntryMounted", () => {
it("keeps inactive workspaces that still exist after hydration", () => {
expect(
shouldKeepWorkspaceDeckEntryMounted({
hasOpenNativeSheets: false,
isActive: false,
hasHydratedWorkspaces: true,
workspaceExists: true,
@@ -9,7 +9,9 @@ export interface RetainedWorkspaceSelection {
}
export function resolveWorkspaceDeckRetentionLimit(input: { isNative: boolean }): number {
return input.isNative ? 1 : WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES;
// Keep recent switches warm without retaining the desktop-sized set of
// native chat trees. Reconciliation evicts the least recently used entry.
return input.isNative ? 3 : WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES;
}
interface ReconcileRetainedWorkspaceSelectionsInput {
@@ -28,6 +30,7 @@ interface GetNextWorkspaceDeckExpirationDelayInput {
}
interface WorkspaceDeckEntryMountInput {
hasOpenNativeSheets: boolean;
isActive: boolean;
hasHydratedWorkspaces: boolean;
workspaceExists: boolean;
@@ -159,6 +162,7 @@ export function resolveWorkspaceDeckEntries({
}
export function shouldKeepWorkspaceDeckEntryMounted({
hasOpenNativeSheets,
isActive,
hasHydratedWorkspaces,
workspaceExists,
@@ -166,6 +170,9 @@ export function shouldKeepWorkspaceDeckEntryMounted({
if (isActive) {
return true;
}
// Native sheets can present on the app-wide host. Preserve their existing
// teardown on navigation until the sheet stack supports inactive owners.
if (hasOpenNativeSheets) return false;
if (!hasHydratedWorkspaces) {
return true;
}
@@ -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 });
@@ -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: {