Compare commits

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

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

Refs #9743

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

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

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 20:02:46 +00:00
tieoandClaude Opus 5.5 b55955541e fix(dictation): surface the daemon's stream error instead of a delivery timeout (#9784)
The daemon drops a dictation stream when its STT session errors and answers
every later chunk with dictation_stream_error. The app only listened for
acks, so finishing waited 30s and reported a generic timeout. The sender now
fails finish with the daemon's message, and the daemon logs the failure that
dropped the stream once instead of nothing.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 19:05:47 +00:00
25 changed files with 256 additions and 424 deletions
+2 -6
View File
@@ -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
+1 -3
View File
@@ -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();
+2 -15
View File
@@ -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");
-6
View File
@@ -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: {