mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-15 21:04:54 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0273b6b614 | ||
|
|
8c88b83cd8 | ||
|
|
b55955541e |
@@ -126,12 +126,8 @@ 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 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.
|
||||
not either mode’s visibility intent. Native workspace retention remains one workspace; web retains
|
||||
up to ten with its existing inactive TTL.
|
||||
|
||||
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,9 +58,7 @@ 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. 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.
|
||||
does not need a DOM ref. Web adds focus trapping and restoration through its adapter.
|
||||
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,51 +148,3 @@ 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,4 +1,3 @@
|
||||
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";
|
||||
@@ -311,32 +310,3 @@ 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,33 +248,6 @@ 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,8 +70,6 @@ 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 () => {
|
||||
@@ -80,15 +78,13 @@ 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, holdTranscript = false) {
|
||||
let deliverTranscript: (() => void) | null = null;
|
||||
async function installDictationFailureHarness(page: Page) {
|
||||
let resolveAudioChunk!: () => void;
|
||||
let resolveCreateRequest!: () => void;
|
||||
const audioChunk = new Promise<void>((resolve) => {
|
||||
@@ -126,14 +122,12 @@ async function installDictationFailureHarness(page: Page, holdTranscript = false
|
||||
if (type === "dictation_stream_finish" && dictationId) {
|
||||
sendSessionMessage(ws, {
|
||||
type: "dictation_stream_finish_accepted",
|
||||
payload: { dictationId, timeoutMs: holdTranscript ? 60_000 : 5_000 },
|
||||
payload: { dictationId, timeoutMs: 5_000 },
|
||||
});
|
||||
sendSessionMessage(ws, {
|
||||
type: "dictation_stream_final",
|
||||
payload: { dictationId, text: TRANSCRIPT },
|
||||
});
|
||||
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") {
|
||||
@@ -161,11 +155,6 @@ async function installDictationFailureHarness(page: Page, holdTranscript = false
|
||||
|
||||
return {
|
||||
waitForAudio: () => audioChunk,
|
||||
hasPendingTranscript: () => deliverTranscript !== null,
|
||||
deliverTranscript: () => {
|
||||
if (!deliverTranscript) throw new Error("No pending transcript");
|
||||
deliverTranscript();
|
||||
},
|
||||
waitForCreateRequest: () => createRequest,
|
||||
failWorkspaceCreation: () => {
|
||||
if (!failCreate) {
|
||||
@@ -265,75 +254,3 @@ 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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -237,12 +237,7 @@ 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: requestedVisible,
|
||||
visible,
|
||||
onClose,
|
||||
onDismiss,
|
||||
children,
|
||||
@@ -680,15 +680,9 @@ 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(
|
||||
@@ -703,6 +697,11 @@ 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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import {
|
||||
BottomSheetModal as GorhomBottomSheetModal,
|
||||
type BottomSheetModalProps,
|
||||
@@ -161,7 +160,7 @@ export const IsolatedBottomSheetModal = forwardRef<
|
||||
});
|
||||
|
||||
export function useIsolatedBottomSheetVisibility({
|
||||
visible: requestedVisible,
|
||||
visible,
|
||||
isEnabled,
|
||||
onClose,
|
||||
onBack,
|
||||
@@ -172,8 +171,6 @@ 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);
|
||||
@@ -204,12 +201,11 @@ export function useIsolatedBottomSheetVisibility({
|
||||
const handleSheetDismiss = useCallback(() => tracker.handleSheetDismiss(), [tracker]);
|
||||
|
||||
useEffect(() => {
|
||||
tracker.syncDesired({ visible: requestedVisible, active, isEnabled });
|
||||
}, [active, isEnabled, tracker, requestedVisible]);
|
||||
tracker.syncDesired({ visible, isEnabled });
|
||||
}, [isEnabled, tracker, visible]);
|
||||
useDismissibleOverlay(visible && isEnabled !== false, onClose);
|
||||
|
||||
return {
|
||||
visible,
|
||||
sheetRef: setSheetRef,
|
||||
handleSheetChange,
|
||||
handleSheetDismiss,
|
||||
|
||||
@@ -291,18 +291,3 @@ 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,7 +5,6 @@ export interface BottomSheetController {
|
||||
|
||||
export interface BottomSheetVisibilityInput {
|
||||
visible: boolean;
|
||||
active?: boolean;
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -102,15 +101,8 @@ export function createBottomSheetVisibilityTracker(opts: {
|
||||
}
|
||||
},
|
||||
syncDesired(next) {
|
||||
visible = next.visible && next.active !== false;
|
||||
visible = next.visible;
|
||||
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,4 +1,3 @@
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Modal as NativeModal,
|
||||
@@ -24,23 +23,11 @@ type ModalProps = Omit<NativeModalProps, "onRequestClose"> & {
|
||||
function noClose() {}
|
||||
|
||||
export function Modal(props: ModalProps) {
|
||||
const { visible: requestedVisible = true, onRequestClose, style } = props;
|
||||
const active = useRetainedPanelActive();
|
||||
const visible = requestedVisible && active;
|
||||
const { visible = true, onRequestClose, style } = props;
|
||||
useDismissibleOverlay(visible && onRequestClose !== undefined, onRequestClose ?? noClose);
|
||||
const windowFrame = useAndroidWindowFrame();
|
||||
const containerStyle = useMemo(() => [windowFrame, style], [windowFrame, style]);
|
||||
// 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}
|
||||
/>
|
||||
);
|
||||
return <NativeModal supportedOrientations={orientations} {...props} style={containerStyle} />;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
|
||||
export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
const { t } = useTranslation();
|
||||
const active = useRetainedPanelActive();
|
||||
const {
|
||||
client,
|
||||
onTranscript,
|
||||
@@ -338,10 +336,6 @@ 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,21 +1,18 @@
|
||||
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 (!active) return;
|
||||
if (!input.active) return;
|
||||
return overlays.register({
|
||||
getLayer: () => current.current.layer,
|
||||
getKeys: () => current.current.keys,
|
||||
onKeyDown: (event) => current.current.onKeyDown(event),
|
||||
});
|
||||
}, [active]);
|
||||
}, [input.active]);
|
||||
return setScope;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
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,24 +30,15 @@ function retainedWorkspaceIds(
|
||||
}
|
||||
|
||||
describe("reconcileRetainedWorkspaceSelections", () => {
|
||||
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);
|
||||
};
|
||||
it("retains only the active workspace on native", () => {
|
||||
const entries = reconcileRetainedWorkspaceSelections({
|
||||
currentEntries: [retained("A", null)],
|
||||
activeSelection: workspace("B"),
|
||||
now: 2,
|
||||
maxMountedWorkspaces: resolveWorkspaceDeckRetentionLimit({ isNative: true }),
|
||||
});
|
||||
|
||||
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"]);
|
||||
expect(retainedWorkspaceIds(entries)).toEqual(["B"]);
|
||||
});
|
||||
|
||||
it("retains the desktop deck limit on web", () => {
|
||||
@@ -188,23 +179,9 @@ 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,
|
||||
@@ -215,7 +192,6 @@ describe("shouldKeepWorkspaceDeckEntryMounted", () => {
|
||||
it("keeps inactive workspaces mounted until workspace hydration finishes", () => {
|
||||
expect(
|
||||
shouldKeepWorkspaceDeckEntryMounted({
|
||||
hasOpenNativeSheets: false,
|
||||
isActive: false,
|
||||
hasHydratedWorkspaces: false,
|
||||
workspaceExists: false,
|
||||
@@ -226,7 +202,6 @@ describe("shouldKeepWorkspaceDeckEntryMounted", () => {
|
||||
it("unmounts inactive workspaces that are gone after hydration", () => {
|
||||
expect(
|
||||
shouldKeepWorkspaceDeckEntryMounted({
|
||||
hasOpenNativeSheets: false,
|
||||
isActive: false,
|
||||
hasHydratedWorkspaces: true,
|
||||
workspaceExists: false,
|
||||
@@ -237,7 +212,6 @@ describe("shouldKeepWorkspaceDeckEntryMounted", () => {
|
||||
it("keeps inactive workspaces that still exist after hydration", () => {
|
||||
expect(
|
||||
shouldKeepWorkspaceDeckEntryMounted({
|
||||
hasOpenNativeSheets: false,
|
||||
isActive: false,
|
||||
hasHydratedWorkspaces: true,
|
||||
workspaceExists: true,
|
||||
|
||||
@@ -9,9 +9,7 @@ export interface RetainedWorkspaceSelection {
|
||||
}
|
||||
|
||||
export function resolveWorkspaceDeckRetentionLimit(input: { isNative: boolean }): number {
|
||||
// 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;
|
||||
return input.isNative ? 1 : WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES;
|
||||
}
|
||||
|
||||
interface ReconcileRetainedWorkspaceSelectionsInput {
|
||||
@@ -30,7 +28,6 @@ interface GetNextWorkspaceDeckExpirationDelayInput {
|
||||
}
|
||||
|
||||
interface WorkspaceDeckEntryMountInput {
|
||||
hasOpenNativeSheets: boolean;
|
||||
isActive: boolean;
|
||||
hasHydratedWorkspaces: boolean;
|
||||
workspaceExists: boolean;
|
||||
@@ -162,7 +159,6 @@ export function resolveWorkspaceDeckEntries({
|
||||
}
|
||||
|
||||
export function shouldKeepWorkspaceDeckEntryMounted({
|
||||
hasOpenNativeSheets,
|
||||
isActive,
|
||||
hasHydratedWorkspaces,
|
||||
workspaceExists,
|
||||
@@ -170,9 +166,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user