mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-15 21:04:54 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4293292d00 | ||
|
|
c94c3c47fe | ||
|
|
158032ac40 | ||
|
|
437fb8af0b | ||
|
|
2ef65f7691 | ||
|
|
79620eeea4 | ||
|
|
2c5f5a1485 | ||
|
|
685e113358 | ||
|
|
28322f1519 |
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -86,7 +86,12 @@ type WaitFinishState = Awaited<
|
||||
ReturnType<Awaited<ReturnType<typeof connectToDaemon>>["waitForFinish"]>
|
||||
>;
|
||||
|
||||
function buildWaitResult(args: {
|
||||
/**
|
||||
* Turn the daemon's wait reply into the command result. Throws AGENT_NOT_FOUND when the
|
||||
* daemon resolved no agent: it answers an unknown identifier with an error and no agent
|
||||
* payload, while an agent that finished in error always comes with its payload.
|
||||
*/
|
||||
export function buildWaitResult(args: {
|
||||
state: WaitFinishState;
|
||||
resolvedAgentId: string;
|
||||
recentActivity: string | null;
|
||||
@@ -117,6 +122,13 @@ function buildWaitResult(args: {
|
||||
}
|
||||
|
||||
if (state.status === "error") {
|
||||
if (state.final === null) {
|
||||
throw {
|
||||
code: "AGENT_NOT_FOUND",
|
||||
message: state.error ?? `Agent not found: ${resolvedAgentId}`,
|
||||
details: "Use `paseo ls` to list available agents",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
return {
|
||||
agentId: resolvedAgentId,
|
||||
status: "error",
|
||||
@@ -155,6 +167,7 @@ export async function runWaitCommand(
|
||||
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
let reply: Omit<Parameters<typeof buildWaitResult>[0], "timeoutLabel">;
|
||||
try {
|
||||
try {
|
||||
const state = await client.waitForFinish(agentIdArg, timeoutMs);
|
||||
@@ -166,11 +179,7 @@ export async function runWaitCommand(
|
||||
|
||||
await client.close();
|
||||
|
||||
return {
|
||||
type: "single",
|
||||
data: buildWaitResult({ state, resolvedAgentId, recentActivity, timeoutLabel }),
|
||||
schema: agentWaitSchema,
|
||||
};
|
||||
reply = { state, resolvedAgentId, recentActivity };
|
||||
} catch (waitErr) {
|
||||
await client.close().catch(() => {});
|
||||
|
||||
@@ -198,4 +207,10 @@ export async function runWaitCommand(
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "single",
|
||||
data: buildWaitResult({ ...reply, timeoutLabel }),
|
||||
schema: agentWaitSchema,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
import assert from "node:assert";
|
||||
import { getAvailablePort } from "./helpers/network.ts";
|
||||
import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts";
|
||||
import { $ } from "zx";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
@@ -177,6 +178,28 @@ try {
|
||||
);
|
||||
console.log("timeout option documents no default limit\n");
|
||||
}
|
||||
|
||||
// Test 13: wait reports AGENT_NOT_FOUND for an unknown ID
|
||||
{
|
||||
console.log("Test 13: wait reports AGENT_NOT_FOUND for an unknown ID");
|
||||
const daemon = await startTestDaemon();
|
||||
try {
|
||||
const result = await runPaseoCli(daemon, [
|
||||
"wait",
|
||||
"does-not-exist",
|
||||
"--host",
|
||||
`127.0.0.1:${daemon.port}`,
|
||||
"--json",
|
||||
]);
|
||||
assert.notStrictEqual(result.exitCode, 0, "wait should fail for an unknown ID");
|
||||
const { error } = JSON.parse(result.stderr);
|
||||
assert.strictEqual(error.code, "AGENT_NOT_FOUND", result.stderr);
|
||||
assert.match(error.details, /paseo ls/);
|
||||
} finally {
|
||||
await daemon.stop();
|
||||
}
|
||||
console.log("✓ wait reports AGENT_NOT_FOUND for an unknown ID\n");
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
|
||||
@@ -87,7 +87,12 @@ function shouldIncludeLocalProviderConfig(params: {
|
||||
params.providers.dictationStt.provider === "local") ||
|
||||
(params.providers.voiceStt.enabled !== false &&
|
||||
params.providers.voiceStt.provider === "local") ||
|
||||
(params.providers.voiceTts.enabled !== false && params.providers.voiceTts.provider === "local");
|
||||
(params.providers.voiceTts.enabled !== false &&
|
||||
params.providers.voiceTts.provider === "local") ||
|
||||
// Turn detection is always local, so an enabled local turn detector
|
||||
// needs the local provider config even when no speech site uses it.
|
||||
(params.providers.voiceTurnDetection.enabled !== false &&
|
||||
params.providers.voiceTurnDetection.provider === "local");
|
||||
|
||||
return (
|
||||
localRequestedByFeature ||
|
||||
|
||||
@@ -168,6 +168,122 @@ describe("resolveSpeechConfig", () => {
|
||||
},
|
||||
);
|
||||
|
||||
test("includes local provider config for local turn detection with OpenAI voice STT/TTS", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
features: {
|
||||
dictation: { enabled: false },
|
||||
voiceMode: {
|
||||
enabled: true,
|
||||
stt: { provider: "openai", apiKey: "test-key", model: "gpt-4o-transcribe" },
|
||||
tts: { provider: "openai", apiKey: "test-key", model: "gpt-4o-mini-tts", voice: "alloy" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveSpeechConfig({
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
persisted,
|
||||
});
|
||||
|
||||
expect(result.speech.providers.voiceTurnDetection).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.providers.voiceStt).toEqual({
|
||||
provider: "openai",
|
||||
explicit: true,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.providers.voiceTts).toEqual({
|
||||
provider: "openai",
|
||||
explicit: true,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.providers.dictationStt).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: false,
|
||||
});
|
||||
// Turn detection is always local, so the local provider config must be
|
||||
// present even when no speech site uses the local provider and no
|
||||
// models directory is configured; otherwise voice mode reports
|
||||
// turn_detection_unavailable.
|
||||
expect(result.speech.local).toEqual({
|
||||
modelsDir: path.join("/tmp/paseo-home", "models", "local-speech"),
|
||||
models: {
|
||||
dictationStt: "parakeet-tdt-0.6b-v2-int8",
|
||||
voiceStt: "parakeet-tdt-0.6b-v2-int8",
|
||||
voiceTts: "kokoro-en-v0_19",
|
||||
voiceTtsSpeakerId: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("includes local provider config for local turn detection with dictation on OpenAI", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
features: {
|
||||
dictation: {
|
||||
enabled: true,
|
||||
stt: { provider: "openai", apiKey: "test-key", model: "gpt-4o-transcribe" },
|
||||
},
|
||||
voiceMode: {
|
||||
enabled: true,
|
||||
stt: { provider: "openai", apiKey: "test-key", model: "gpt-4o-transcribe" },
|
||||
tts: { provider: "openai", apiKey: "test-key", model: "gpt-4o-mini-tts", voice: "alloy" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveSpeechConfig({
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
persisted,
|
||||
});
|
||||
|
||||
expect(result.speech.providers.voiceTurnDetection).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.local).toEqual({
|
||||
modelsDir: path.join("/tmp/paseo-home", "models", "local-speech"),
|
||||
models: {
|
||||
dictationStt: "parakeet-tdt-0.6b-v2-int8",
|
||||
voiceStt: "parakeet-tdt-0.6b-v2-int8",
|
||||
voiceTts: "kokoro-en-v0_19",
|
||||
voiceTtsSpeakerId: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("omits local provider config when voice mode is off and no speech site is local", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
features: {
|
||||
dictation: { enabled: false },
|
||||
voiceMode: {
|
||||
enabled: false,
|
||||
stt: { provider: "openai", apiKey: "test-key", model: "gpt-4o-transcribe" },
|
||||
tts: { provider: "openai", apiKey: "test-key", model: "gpt-4o-mini-tts", voice: "alloy" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveSpeechConfig({
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
persisted,
|
||||
});
|
||||
|
||||
expect(result.speech.providers.voiceTurnDetection).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: false,
|
||||
});
|
||||
expect(result.speech.local).toBeUndefined();
|
||||
});
|
||||
|
||||
test("respects disabled dictation and voice mode feature flags", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
features: {
|
||||
|
||||
Reference in New Issue
Block a user