Compare commits

...
Author SHA1 Message Date
an 96020ab773 fix(terminal): forward native touch gestures to mouse-aware apps
Fullscreen applications request wheel reports, while native touch drags
previously moved only local scrollback. Route gestures through the
existing terminal input path and preserve the requested modes on replay.

Co-authored-by: an <4361526@naver.com>
2026-10-24 04:52:34 +02:00
Mohamed Boudra 686588c44b feat(app): restore manual workspace sorting (#7771)
Keep manual workspace order separate from automatic sorting so surviving pre-beta arrangements and subsequent drags remain available across mode changes. Created stays the default; data already discarded by the beta cannot be reconstructed.
2026-10-24 09:06:05 +07:00
paseo-bot[bot] 48623801a1 fix(app): keep tablet sidebar controls above system navigation (#7768)
The wide sidebar reserved only the top safe area, leaving its footer under Android navigation UI. Apply the existing bottom inset to the same interior and add a native geometry regression check.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-24 03:01:19 +02:00
Stephen ZhouandClaude Opus 5.5 632a7f8859 fix(app): keep live workspace rows when a late creation snapshot arrives (#7758)
A workspace.create.response carries the descriptor captured at provision
time but is sent after the initial agent starts. A pin or title change made
in between reaches the app first as a live update; the late response then
replaced that row with the older snapshot, so a new pin vanished from the
sidebar until the next directory update.

Creation and progress snapshots now only seed rows the replica has not
received; existing rows stay owned by directory updates. Archive rollback
still restores a row that was removed.

Closes #5447

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:05:57 +02:00
paseo-bot[bot] 48d2c9ec36 Keep restored Claude background notifications from appearing twice (#7737)
* fix(claude): restore each background task notification once

Queued and delivered copies of one completion had different synthetic tool identities. Prefer their shared task ID so existing timeline projection folds them into one notification.

* fix(claude): preserve separate notifications from one task

A task can emit several Monitor events, including identical text. Assign replay identity per notification body and occurrence, pairing queue and delivery records before timeline projection instead of merging every event by task ID.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-23 23:54:08 +02:00
paseo-bot[bot]andgrueneisfabian ca894d49a5 fix(voice): keep prefetched sentences available for playback (#7756)
Unread speech bodies lost their owning HTTP response while earlier sentences played. Garbage collection then cancelled those bodies and the playback manager skipped their audio. Consume each response inside the provider before returning its audio stream, matching the existing complete-clip playback.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: grueneisfabian <174708694+grueneisfabian@users.noreply.github.com>
2026-10-23 23:01:02 +02:00
43 changed files with 1539 additions and 92 deletions
@@ -0,0 +1,100 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
delayBrowserAgentCreatedStatus,
openNewWorkspaceComposer,
submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
// The creation response carries the descriptor captured when the workspace was provisioned. With an
// initial agent it is sent only after the agent starts, so a pin made in between (by a plugin's
// workspace.created hook, another client, or the CLI) reaches the app first as a live update. The
// late response must not put the old, unpinned row back.
function pinnedRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
}
async function findCreatedWorkspaceId(seeded: SeededWorkspace): Promise<string> {
let createdId: string | undefined;
await expect
.poll(
async () => {
const { entries } = await seeded.client.fetchWorkspaces({
filter: { projectId: seeded.projectId },
});
createdId = entries.find((entry) => entry.id !== seeded.workspaceId)?.id;
return createdId;
},
{ timeout: 30_000 },
)
.toBeTruthy();
return createdId!;
}
// Samples every frame, so a row that leaves Pinned and comes back on a later update still counts.
async function watchPinnedRow(page: Page, testId: string): Promise<() => Promise<number>> {
await page.evaluate((rowTestId) => {
const state = { missingFrames: 0, stopped: false };
(window as unknown as { __pinnedRowWatch: typeof state }).__pinnedRowWatch = state;
const sample = () => {
if (state.stopped) return;
const section = document.querySelector('[data-testid="sidebar-pinned-section"]');
if (!section?.querySelector(`[data-testid="${rowTestId}"]`)) state.missingFrames += 1;
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
}, testId);
return () =>
page.evaluate(() => {
const state = (
window as unknown as { __pinnedRowWatch: { missingFrames: number; stopped: boolean } }
).__pinnedRowWatch;
state.stopped = true;
return state.missingFrames;
});
}
test.describe("New workspace live pin", () => {
test("keeps a pin made before the creation response arrives", async ({ page }) => {
test.setTimeout(120_000);
const creation = await delayBrowserAgentCreatedStatus(page);
const seeded = await seedWorkspace({ repoPrefix: "new-workspace-live-pin-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, seeded);
await submitNewWorkspacePrompt(page, "Pin me while I start");
await creation.waitForCreateRequest();
// The response is held here, carrying the descriptor from before the pin.
await creation.waitForDelayedCreatedStatus();
const workspaceId = await findCreatedWorkspaceId(seeded);
const rowTestId = pinnedRowTestId(workspaceId);
await seeded.client.setWorkspacePinned(workspaceId, true);
const pinnedRow = page.getByTestId("sidebar-pinned-section").getByTestId(rowTestId);
await expect(pinnedRow).toBeVisible({ timeout: 10_000 });
const stopWatching = await watchPinnedRow(page, rowTestId);
creation.release();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({
timeout: 30_000,
});
// Leave time for the released response to be merged and rendered.
await page.waitForTimeout(1_000);
expect(await stopWatching()).toBe(0);
await expect(pinnedRow).toBeVisible();
} finally {
creation.release();
await seeded.cleanup();
}
});
});
@@ -123,7 +123,7 @@ test("projects and pinned chats reorder while ordinary workspaces stay newest fi
}
});
async function selectWorkspaceSort(page: Page, name: "Created" | "Last prompt") {
async function selectWorkspaceSort(page: Page, name: "Manual" | "Created" | "Last prompt") {
await page.getByLabel("Display preferences", { exact: true }).click();
await page.getByRole("menuitem", { name: /^Sort/ }).click();
await page.getByRole("menuitem", { name, exact: true }).click();
@@ -362,3 +362,60 @@ test("compact groups retain active overflow and reveal the current ordered rows"
await fixture.seed.cleanup();
}
});
async function restorePreBetaOrder(page: Page, projectKey: string, workspaceKeys: string[]) {
await page.evaluate(
({ projectKey: scope, workspaceKeys: order }) => {
localStorage.setItem(
"sidebar-project-workspace-order",
JSON.stringify({
version: 1,
state: {
projectOrder: [scope],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: { [scope]: order },
},
}),
);
},
{ projectKey, workspaceKeys },
);
await reconnectSidebar(page);
}
test("Manual restores pre-beta order and keeps drag order across sorting and reloads", async ({
page,
}) => {
const seed = await seedWorkspace({ repoPrefix: "sidebar-manual-" });
try {
const second = await seed.client.createWorkspace({
source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId },
title: "New manual workspace",
});
if (!second.workspace) throw new Error(second.error ?? "Workspace creation failed");
const keys = [seed.workspaceId, second.workspace.id].map((id) => `${getServerId()}:${id}`);
const ids = keys.map((key) => `sidebar-workspace-row-${key}`);
const rows = page.locator(ids.map((id) => `[data-testid="${id}"]`).join(", "));
await gotoAppShell(page);
await waitForSidebarHydration(page);
await restorePreBetaOrder(page, projectEquivalenceViewKey(seed.projectKey), keys);
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
await selectWorkspaceSort(page, "Manual");
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
await selectWorkspaceSort(page, "Created");
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
await selectWorkspaceSort(page, "Last prompt");
await selectWorkspaceSort(page, "Manual");
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow);
await reconnectSidebar(page);
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow);
await selectWorkspaceSort(page, "Created");
await reconnectSidebar(page);
await selectWorkspaceSort(page, "Manual");
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
} finally {
await seed.cleanup();
}
});
@@ -72,6 +72,9 @@ async function expectSortingRequiresHostUpdate(page: Page) {
).toBeDisabled();
await expect(page.getByRole("menuitem", { name: "Created", exact: true })).toHaveCount(0);
await expect(page.getByRole("menuitem", { name: "Last prompt", exact: true })).toHaveCount(0);
await page.getByRole("menuitem", { name: "Manual", exact: true }).click();
await page.getByLabel("Display preferences", { exact: true }).click();
await expect(page.getByRole("menuitem", { name: /^Sort/ })).toContainText("Manual");
await page.keyboard.press("Escape");
}
@@ -0,0 +1,40 @@
// Open a connected host with the wide sidebar visible, keyboard closed, and
// three-button navigation enabled on an Android tablet. Run against a fresh build:
// node packages/app/e2e/mobile/sidebar-system-insets/android.mjs <session> <adb-serial>
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
const [, , session, serial] = process.argv;
assert(session && serial, "Pass the agent-device session and adb serial");
const windowState = execFileSync("adb", ["-s", serial, "shell", "dumpsys", "window"], {
encoding: "utf8",
timeout: 10_000,
});
const navBar = windowState.match(/type=navigationBars frame=\[0,(\d+)\]\[\d+,\d+\] visible=true/);
assert(navBar, "A visible bottom navigation bar is required");
assert(!/type=ime .*visible=true/.test(windowState), "Close the keyboard first");
const navigationTop = Number(navBar[1]);
const { data } = JSON.parse(
execFileSync("agent-device", ["snapshot", "--raw", "--json", "--session", session], {
encoding: "utf8",
timeout: 60_000,
}),
);
const focusedApp = windowState.match(/mCurrentFocus=.*? u\d+ ([^/\s]+)\//)?.[1];
assert.equal(
focusedApp,
data.appBundleId,
"Snapshot must belong to the focused app on this device",
);
for (const id of [
"sidebar-add-project",
"sidebar-hosts-trigger",
"sidebar-help",
"sidebar-settings",
]) {
const button = data.nodes.find((node) => node.identifier === id);
assert(button?.rect, `${id} must be visible above the system navigation bar`);
const bottom = button.rect.y + button.rect.height;
assert(bottom <= navigationTop, `${id} ends at ${bottom}, below navigation top ${navigationTop}`);
console.log(`PASS: ${id} bottom=${bottom}, navigationTop=${navigationTop}`);
}
+5 -2
View File
@@ -719,8 +719,11 @@ function DesktopSidebar({
[active, resizeAnimatedStyle],
);
const desktopSidebarBorderStyle = useMemo(
() => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }],
[insetsTop],
() => [
styles.desktopSidebarBorder,
{ flex: 1, paddingTop: insetsTop, paddingBottom: insets.bottom },
],
[insetsTop, insets.bottom],
);
const sidebarHeaderGroupStyle = useMemo(
() => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome],
@@ -1550,6 +1550,11 @@ function ProjectBlock({
shortcutIndexByWorkspaceKey,
onToggleCollapsed,
onWorkspacePress,
parentGestureRef,
onWorkspaceReorder,
useNestable,
dragGestureHostActive,
manualSort,
onWorktreeCreated,
drag,
isDragging,
@@ -1571,6 +1576,11 @@ function ProjectBlock({
shortcutIndexByWorkspaceKey: Map<string, number>;
onToggleCollapsed: (projectViewKey: string) => void;
onWorkspacePress?: () => void;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void;
useNestable: boolean;
dragGestureHostActive?: boolean;
manualSort: boolean;
onWorktreeCreated?: (workspaceId: string) => void;
drag: () => void;
isDragging: boolean;
@@ -1624,7 +1634,14 @@ function ProjectBlock({
});
const renderWorkspaceRow = useCallback(
(item: SidebarWorkspacePlacement) => {
(
item: SidebarWorkspacePlacement,
input?: {
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
},
) => {
return (
<MemoWorkspaceRowItem
key={item.workspaceKey}
@@ -1640,6 +1657,9 @@ function ProjectBlock({
selectionEnabled={selectionEnabled}
activeWorkspaceSelection={activeWorkspaceSelection}
onWorkspacePress={onWorkspacePress}
drag={input?.drag}
isDragging={input?.isDragging}
dragHandleProps={input?.dragHandleProps}
/>
);
},
@@ -1658,6 +1678,29 @@ function ProjectBlock({
],
);
const renderWorkspace = useCallback(
({
item,
drag: workspaceDrag,
isActive,
dragHandleProps: workspaceDragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
return renderWorkspaceRow(item, {
drag: workspaceDrag,
isDragging: isActive,
dragHandleProps: workspaceDragHandleProps,
});
},
[renderWorkspaceRow],
);
const handleWorkspaceDragEnd = useCallback(
(workspaces: SidebarWorkspacePlacement[]) => {
onWorkspaceReorder(project.viewKey, workspaces);
},
[onWorkspaceReorder, project.viewKey],
);
const toast = useToast();
const { t } = useTranslation();
const [isRemovingProject, setIsRemovingProject] = useState(false);
@@ -1723,12 +1766,29 @@ function ProjectBlock({
if (project.workspaces.length > 0) {
projectChildren = (
<>
<View
testID={`sidebar-workspace-list-${project.viewKey}`}
style={styles.workspaceListContainer}
>
{visibleWorkspaces.map(renderWorkspaceRow)}
</View>
{manualSort ? (
<DraggableList
testID={`sidebar-workspace-list-${project.viewKey}`}
data={visibleWorkspaces}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostActive}
containerStyle={styles.workspaceListContainer}
/>
) : (
<View
testID={`sidebar-workspace-list-${project.viewKey}`}
style={styles.workspaceListContainer}
>
{visibleWorkspaces.map((item) => renderWorkspaceRow(item))}
</View>
)}
{canToggleWorkspaces ? (
<SidebarGroupToggleRow
expanded={workspacesExpanded}
@@ -1803,6 +1863,11 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
previous.onToggleCollapsed === next.onToggleCollapsed &&
previous.onWorkspacePress === next.onWorkspacePress &&
previous.parentGestureRef === next.parentGestureRef &&
previous.onWorkspaceReorder === next.onWorkspaceReorder &&
previous.useNestable === next.useNestable &&
previous.dragGestureHostActive === next.dragGestureHostActive &&
previous.manualSort === next.manualSort &&
previous.onWorktreeCreated === next.onWorktreeCreated &&
previous.drag === next.drag &&
previous.isDragging === next.isDragging &&
@@ -2112,6 +2177,9 @@ function ProjectModeList({
(state) => state.togglePinnedCollapsed,
);
const manualSort = useSidebarViewStore((state) => state.workspaceSort === "manual");
const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder);
const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder);
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder);
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
@@ -2211,6 +2279,30 @@ function ProjectModeList({
[getProjectOrder, setProjectOrder],
);
const handleWorkspaceReorder = useCallback(
(projectViewKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => {
const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey);
const currentWorkspaceOrder = getWorkspaceOrder(projectViewKey);
if (
!hasVisibleOrderChanged({
currentOrder: currentWorkspaceOrder,
reorderedVisibleKeys: reorderedWorkspaceKeys,
})
) {
return;
}
setWorkspaceOrder(
projectViewKey,
mergeWithRemainder({
currentOrder: currentWorkspaceOrder,
reorderedVisibleKeys: reorderedWorkspaceKeys,
}),
);
},
[getWorkspaceOrder, setWorkspaceOrder],
);
const handleWorktreeCreated = useCallback((workspaceId: string) => {
setCreatingWorkspaceIds((current) => {
const next = new Set(current);
@@ -2259,6 +2351,11 @@ function ProjectModeList({
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
onToggleCollapsed={onToggleProjectCollapsed}
onWorkspacePress={onWorkspacePress}
manualSort={manualSort}
parentGestureRef={parentGestureRef}
onWorkspaceReorder={handleWorkspaceReorder}
useNestable={platformIsNative}
dragGestureHostActive={dragGestureHostActive}
onWorktreeCreated={handleWorktreeCreated}
drag={dragState.drag}
isDragging={dragState.isDragging}
@@ -2276,6 +2373,10 @@ function ProjectModeList({
collapsedProjectKeys,
activeWorkspaceSelection,
handleWorktreeCreated,
manualSort,
parentGestureRef,
dragGestureHostActive,
handleWorkspaceReorder,
hostBadgeByServerId,
supportsMultiplicityByServerId,
supportsPinningByServerId,
@@ -244,10 +244,8 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
definitions.push({
id: "sort",
title: t("sidebar.display.sort.label"),
content: workspaceSortSupported ? (
<SortPage preferences={preferences} />
) : (
<MenuItem disabled>{t("sidebar.display.sort.updateHost")}</MenuItem>
content: (
<SortPage preferences={preferences} automaticSortSupported={workspaceSortSupported} />
),
});
}
@@ -325,7 +323,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
<MenuSubTrigger
id="sort"
value={
workspaceSortSupported
workspaceSortSupported || preferences.workspaceSort === "manual"
? t(`sidebar.display.sort.${preferences.workspaceSort}`)
: undefined
}
@@ -399,19 +397,38 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
);
}
function SortPage({ preferences }: { preferences: Preferences }): ReactElement {
function SortPage({
preferences,
automaticSortSupported,
}: {
preferences: Preferences;
automaticSortSupported: boolean;
}): ReactElement {
const { t } = useTranslation();
const { setWorkspaceSort } = preferences;
const selectManual = useCallback(() => setWorkspaceSort("manual"), [setWorkspaceSort]);
const selectCreated = useCallback(() => setWorkspaceSort("created"), [setWorkspaceSort]);
const selectLastPrompt = useCallback(() => setWorkspaceSort("lastPrompt"), [setWorkspaceSort]);
return (
<>
<MenuItem selected={preferences.workspaceSort === "created"} onSelect={selectCreated}>
{t("sidebar.display.sort.created")}
</MenuItem>
<MenuItem selected={preferences.workspaceSort === "lastPrompt"} onSelect={selectLastPrompt}>
{t("sidebar.display.sort.lastPrompt")}
<MenuItem selected={preferences.workspaceSort === "manual"} onSelect={selectManual}>
{t("sidebar.display.sort.manual")}
</MenuItem>
{automaticSortSupported ? (
<>
<MenuItem selected={preferences.workspaceSort === "created"} onSelect={selectCreated}>
{t("sidebar.display.sort.created")}
</MenuItem>
<MenuItem
selected={preferences.workspaceSort === "lastPrompt"}
onSelect={selectLastPrompt}
>
{t("sidebar.display.sort.lastPrompt")}
</MenuItem>
</>
) : (
<MenuItem disabled>{t("sidebar.display.sort.updateHost")}</MenuItem>
)}
</>
);
}
@@ -50,9 +50,9 @@ import {
import {
TERMINAL_GESTURE_LONG_PRESS_MS,
TERMINAL_GESTURE_TAP_TOLERANCE_PX,
advanceTerminalScrollGesture,
classifyTerminalGestureIntent,
resolveTerminalGestureReleaseAction,
routeTerminalGestureRelease,
routeTerminalScrollGesture,
type TerminalGestureIntent,
} from "../terminal/native-renderer/terminal-selection-gesture";
import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy";
@@ -95,6 +95,7 @@ interface NativeTerminalPanState {
didNavigate: boolean;
movedBeyondTapTolerance: boolean;
intent: TerminalGestureIntent | null;
origin: { x: number; y: number };
}
interface NativeTerminalSelectionPress {
@@ -214,6 +215,7 @@ function NativeTerminalEmulator({
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
origin: { x: 0, y: 0 },
});
const inputModeTrackerRef = useRef(new TerminalInputModeTracker());
const lastInputModeStateRef = useRef<TerminalInputModeState>({
@@ -775,31 +777,33 @@ function NativeTerminalEmulator({
);
const handlePanResponderScroll = useCallback(
(currentDy: number) => {
const cellHeight = metricsRef.current?.cellHeight;
if (!cellHeight || cellHeight <= 0) {
(gesture: PanResponderGestureState) => {
const metrics = metricsRef.current;
const viewport = resolveSelectionViewport();
if (!metrics || metrics.cellHeight <= 0 || !viewport) {
return;
}
const panState = panStateRef.current;
const update = advanceTerminalScrollGesture({
const { state, route } = routeTerminalScrollGesture({
state: panState,
currentDy,
cellHeight,
currentDy: gesture.dy,
point: { x: panState.origin.x + gesture.dx, y: panState.origin.y + gesture.dy },
metrics,
viewport,
inputMode: inputModeTrackerRef.current,
});
panState.lastDy = update.state.lastDy;
panState.rowRemainder = update.state.rowRemainder;
panState.didScroll = update.state.didScroll;
const { rowDelta } = update;
if (rowDelta === 0) {
panState.lastDy = state.lastDy;
panState.rowRemainder = state.rowRemainder;
panState.didScroll = state.didScroll;
if (route.kind === "scrollback") {
applyScroll(route.direction, route.rows);
return;
}
if (rowDelta > 0) {
applyScroll("up", rowDelta);
return;
if (route.kind === "input") {
callbacksRef.current.onInput?.(route.data);
}
applyScroll("down", Math.abs(rowDelta));
},
[applyScroll],
[applyScroll, resolveSelectionViewport],
);
const panResponder = useMemo(
@@ -828,6 +832,10 @@ function NativeTerminalEmulator({
return intent === "scroll" || intent === "select";
},
onPanResponderGrant: (event) => {
const point = {
x: event.nativeEvent.locationX,
y: event.nativeEvent.locationY,
};
panStateRef.current = {
lastDy: 0,
rowRemainder: 0,
@@ -835,10 +843,7 @@ function NativeTerminalEmulator({
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
};
const point = {
x: event.nativeEvent.locationX,
y: event.nativeEvent.locationY,
origin: point,
};
const pressStatus = selectionRangeRef.current ? "selecting" : "pressing";
selectionPressRef.current = {
@@ -875,7 +880,7 @@ function NativeTerminalEmulator({
}
if (panState.intent === "scroll") {
handlePanResponderScroll(gesture.dy);
handlePanResponderScroll(gesture);
return;
}
if (panState.didNavigate) {
@@ -903,7 +908,7 @@ function NativeTerminalEmulator({
if (intent === "scroll") {
selectionPressRef.current = null;
handlePanResponderScroll(gesture.dy);
handlePanResponderScroll(gesture);
return;
}
@@ -932,8 +937,9 @@ function NativeTerminalEmulator({
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
origin: { x: 0, y: 0 },
};
const releaseAction = resolveTerminalGestureReleaseAction({
const { action: releaseAction, mouseInput } = routeTerminalGestureRelease({
status: selectionPress?.status ?? "idle",
startedWithSelection: selectionPress?.startedWithSelection,
didScroll,
@@ -942,11 +948,18 @@ function NativeTerminalEmulator({
pressDurationMs: selectionPress ? Date.now() - selectionPress.startedAt : 0,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: terminalScreenRef.current?.scroll.mode ?? "following",
point: { x: selectionPress?.startX ?? 0, y: selectionPress?.startY ?? 0 },
metrics: metricsRef.current,
viewport: resolveSelectionViewport(),
inputMode: inputModeTrackerRef.current,
});
if (releaseAction === "select" && selectionPress) {
beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY });
return;
}
if (mouseInput !== null) {
callbacksRef.current.onInput?.(mouseInput);
}
if (releaseAction === "focus" || releaseAction === "clear") {
focusTerminalFromTap();
}
@@ -961,6 +974,7 @@ function NativeTerminalEmulator({
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
origin: { x: 0, y: 0 },
};
},
}),
@@ -969,6 +983,7 @@ function NativeTerminalEmulator({
clearSelectionLongPressTimeout,
focusTerminalFromTap,
handlePanResponderScroll,
resolveSelectionViewport,
updateSelectionAtPoint,
],
);
@@ -617,7 +617,7 @@ describe("computeSidebarOrderUpdates", () => {
persistedProjectOrder: ["stale-project"],
});
expect(updates).toEqual({ projectOrder: null });
expect(updates).toEqual({ projectOrder: null, workspaceOrders: [] });
});
it("appends unseen projects without changing saved project order", () => {
@@ -979,3 +979,20 @@ it("retains unrelated projects and keyed row placements when one project reorder
expect(after.projects[0].workspaces[0]).toBe(before.projects[0].workspaces[1]);
expect(after.projects[0].workspaces[1]).toBe(before.projects[0].workspaces[0]);
});
it("prepends newly discovered manual workspaces without dropping filtered saved keys", () => {
const projects = [sidebarProject({ projectKey: "p", workspaceKeys: ["new", "b", "a"] })];
expect(
computeSidebarOrderUpdates({
projects,
persistedProjectOrder: ["p"],
getWorkspaceOrder: () => ["srv:hidden", "srv:a", "srv:b"],
}),
).toEqual({
projectOrder: null,
workspaceOrders: [{ projectViewKey: "p", order: ["srv:new", "srv:hidden", "srv:a", "srv:b"] }],
});
expect(
computeSidebarOrderUpdates({ projects, persistedProjectOrder: ["p"] }).workspaceOrders,
).toEqual([]);
});
@@ -535,21 +535,57 @@ export function appendMissingOrderKeys(input: {
return [...input.currentOrder, ...missingKeys];
}
export function prependMissingOrderKeys(input: {
currentOrder: string[];
visibleKeys: string[];
}): string[] {
if (input.visibleKeys.length === 0) {
return input.currentOrder;
}
const existingKeys = new Set(input.currentOrder);
const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key));
if (missingKeys.length === 0) {
return input.currentOrder;
}
return [...missingKeys, ...input.currentOrder];
}
export interface SidebarOrderUpdates {
projectOrder: string[] | null;
workspaceOrders: Array<{ projectViewKey: string; order: string[] }>;
}
export function computeSidebarOrderUpdates(input: {
projects: SidebarProjectEntry[];
persistedProjectOrder: string[];
getWorkspaceOrder?: (projectViewKey: string) => string[];
}): SidebarOrderUpdates {
if (input.projects.length === 0) {
return { projectOrder: null, workspaceOrders: [] };
}
const nextProjectOrder = appendMissingOrderKeys({
currentOrder: input.persistedProjectOrder,
visibleKeys: input.projects.map((project) => project.viewKey),
});
return {
projectOrder: nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder,
};
const projectOrder = nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder;
const workspaceOrders: Array<{ projectViewKey: string; order: string[] }> = [];
for (const project of input.projects) {
if (!input.getWorkspaceOrder) break;
const persistedWorkspaceOrder = input.getWorkspaceOrder(project.viewKey);
const nextWorkspaceOrder = prependMissingOrderKeys({
currentOrder: persistedWorkspaceOrder,
visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey),
});
if (nextWorkspaceOrder !== persistedWorkspaceOrder) {
workspaceOrders.push({ projectViewKey: project.viewKey, order: nextWorkspaceOrder });
}
}
return { projectOrder, workspaceOrders };
}
export interface SidebarLoadingState {
@@ -144,7 +144,10 @@ export function useSidebarWorkspacesList(options?: {
const groupMode = useSidebarViewStore((state) => state.groupMode);
const sortSupport = useHostFeatureMap(directoryServerIds, "workspaceSortTimes");
const workspaceSortSupported = [...sortSupport.values()].every(Boolean);
const sort = workspaceSortSupported && groupMode === "project" ? workspaceSort : undefined;
const sort =
groupMode === "project" && (workspaceSort === "manual" || workspaceSortSupported)
? workspaceSort
: undefined;
const { projects: hostProjects } = useWorkspaceStructure(directoryServerIds, sort);
const selectPlacements = useMemo(createSidebarWorkspacePlacementSelector, []);
@@ -166,12 +169,16 @@ export function useSidebarWorkspacesList(options?: {
const updates = computeSidebarOrderUpdates({
projects,
persistedProjectOrder,
getWorkspaceOrder: sort === "manual" ? orderStore.getWorkspaceOrder : undefined,
});
if (updates.projectOrder) {
orderStore.setProjectOrder(updates.projectOrder);
}
}, [persistedProjectOrder, projects]);
for (const update of updates.workspaceOrders) {
orderStore.setWorkspaceOrder(update.projectViewKey, update.order);
}
}, [persistedProjectOrder, projects, sort]);
const refreshAll = useCallback(() => {
if (!isActive) return;
+1
View File
@@ -1169,6 +1169,7 @@ export const ar: TranslationResources = {
sort: {
label: "الترتيب",
created: "تاريخ الإنشاء",
manual: "يدوي",
lastPrompt: "آخر طلب",
updateHost: "حدّث المضيفين لترتيب مساحات العمل",
},
+1
View File
@@ -1177,6 +1177,7 @@ export const en = {
sort: {
label: "Sort",
created: "Created",
manual: "Manual",
lastPrompt: "Last prompt",
updateHost: "Update hosts to sort workspaces",
},
+1
View File
@@ -1205,6 +1205,7 @@ export const es: TranslationResources = {
sort: {
label: "Orden",
created: "Creación",
manual: "Manual",
lastPrompt: "Último prompt",
updateHost: "Actualiza los hosts para ordenar los espacios de trabajo",
},
+1
View File
@@ -1198,6 +1198,7 @@ export const fr: TranslationResources = {
sort: {
label: "Tri",
created: "Création",
manual: "Manuel",
lastPrompt: "Dernier prompt",
updateHost: "Mettez les hôtes à jour pour trier les espaces de travail",
},
+1
View File
@@ -1182,6 +1182,7 @@ export const ja: TranslationResources = {
sort: {
label: "並び順",
created: "作成日時",
manual: "手動",
lastPrompt: "最後のプロンプト",
updateHost: "ワークスペースを並べ替えるにはホストを更新してください",
},
+1
View File
@@ -1176,6 +1176,7 @@ export const ko: TranslationResources = {
sort: {
label: "정렬",
created: "생성일",
manual: "수동",
lastPrompt: "마지막 프롬프트",
updateHost: "작업 공간을 정렬하려면 호스트를 업데이트하세요",
},
+1
View File
@@ -1195,6 +1195,7 @@ export const ptBR: TranslationResources = {
sort: {
label: "Ordenação",
created: "Criação",
manual: "Manual",
lastPrompt: "Último prompt",
updateHost: "Atualize os hosts para ordenar os espaços de trabalho",
},
+1
View File
@@ -1186,6 +1186,7 @@ export const ru: TranslationResources = {
sort: {
label: "Сортировка",
created: "Дата создания",
manual: "Вручную",
lastPrompt: "Последний запрос",
updateHost: "Обновите хосты для сортировки рабочих пространств",
},
+1
View File
@@ -1161,6 +1161,7 @@ export const zhCN: TranslationResources = {
sort: {
label: "排序",
created: "创建时间",
manual: "手动",
lastPrompt: "最近提示词",
updateHost: "请更新主机以排序工作区",
},
@@ -30,7 +30,7 @@ interface WorkspaceStructureSession {
workspaces: Iterable<WorkspaceDescriptor>;
}
export type WorkspaceSort = "created" | "lastPrompt";
export type WorkspaceSort = "manual" | "created" | "lastPrompt";
interface WorkspaceSortItem {
workspaceName: string;
@@ -266,3 +266,115 @@ it("setup metadata from directory snapshots and transitions resets failure surfa
expect(store.claimFailedSetupSurface({ ...identity, status: "failed" })).toBe(false);
expect(useWorkspaceSetupStore.getState().snapshots).toEqual({});
});
it("keeps a live workspace update when an older creation snapshot arrives", () => {
const serverId = "workspace-create-after-pin";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
const initial = workspace("new");
const pinned = {
...initial,
pinnedAt: "2026-09-26T00:00:00.000Z",
title: "Updated title",
};
try {
replica.applyDelta({ kind: "upsert", workspace: pinned });
const accepted = useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id);
expect(replica.acceptWorkspaces([normalizeWorkspaceDescriptor(initial)])).toEqual([]);
expect(replica.snapshot().workspaces.get(initial.id)).toEqual(
normalizeWorkspaceDescriptor(pinned),
);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toBe(
accepted,
);
} finally {
store.clearSession(serverId);
}
});
it("seeds missing workspaces before live updates and in a mixed creation batch", () => {
const serverId = "workspace-create-before-pin";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
const initial = normalizeWorkspaceDescriptor(workspace("new"));
const second = normalizeWorkspaceDescriptor(workspace("second"));
const pinned = { ...workspace(initial.id), pinnedAt: "2026-09-26T00:00:00.000Z" };
try {
expect(replica.acceptWorkspaces([initial])).toEqual([
{ kind: "workspace", type: "upsert", id: initial.id, value: initial },
]);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toEqual(
initial,
);
replica.applyDelta({ kind: "upsert", workspace: pinned });
expect(replica.acceptWorkspaces([initial, second])).toEqual([
{ kind: "workspace", type: "upsert", id: second.id, value: second },
]);
expect([...useSessionStore.getState().sessions[serverId]!.workspaces.values()]).toEqual([
normalizeWorkspaceDescriptor(pinned),
second,
]);
} finally {
store.clearSession(serverId);
}
});
it("keeps a manual unpin when a stale pinned snapshot arrives", () => {
const serverId = "workspace-manual-unpin";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
const pinned = { ...workspace("new"), pinnedAt: "2026-09-26T00:00:00.000Z" };
const unpinned = { ...pinned, pinnedAt: null };
try {
replica.acceptWorkspaces([normalizeWorkspaceDescriptor(pinned)]);
replica.applyDelta({ kind: "upsert", workspace: unpinned });
expect(replica.acceptWorkspaces([normalizeWorkspaceDescriptor(pinned)])).toEqual([]);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(pinned.id)).toEqual(
normalizeWorkspaceDescriptor(unpinned),
);
} finally {
store.clearSession(serverId);
}
});
it("restores a missing workspace after an archive failure without replacing a live row", () => {
const serverId = "workspace-archive-rollback";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
const initial = normalizeWorkspaceDescriptor(workspace("new"));
try {
replica.acceptWorkspaces([initial]);
markWorkspaceArchivePending({ serverId, workspaceId: initial.id });
replica.removeWorkspaceSnapshot(initial.id);
expect(replica.acceptWorkspaces([initial])).toEqual([]);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(initial.id)).toBe(false);
clearWorkspaceArchivePending({ serverId, workspaceId: initial.id });
expect(replica.acceptWorkspaces([initial])).toEqual([
{ kind: "workspace", type: "upsert", id: initial.id, value: initial },
]);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toEqual(
initial,
);
const updated = { ...workspace(initial.id), title: "Updated during archive" };
replica.applyDelta({ kind: "upsert", workspace: updated });
expect(replica.acceptWorkspaces([initial])).toEqual([]);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toEqual(
normalizeWorkspaceDescriptor(updated),
);
} finally {
clearWorkspaceArchivePending({ serverId, workspaceId: initial.id });
store.clearSession(serverId);
}
});
@@ -82,7 +82,12 @@ export class WorkspaceDirectoryReplica {
}
acceptWorkspaces(workspaces: readonly WorkspaceDescriptor[]): DirectoryReplicaMutation[] {
return workspaces.flatMap((workspace) => this.upsertWorkspace(workspace));
// Creation responses and progress snapshots can arrive after live updates.
// They seed missing rows; the directory owns rows it has already received.
return workspaces.flatMap((workspace) => {
if (this.workspaces.has(workspace.id)) return [];
return this.upsertWorkspace(workspace);
});
}
acceptProject(project: ProjectDescriptor): DirectoryReplicaMutation[] {
@@ -117,13 +117,17 @@ export function useWorkspaceStructure(
(state) => selectProjectOrder(state),
workspaceEqualityFns.deep,
);
const workspaceOrderByProject = useSidebarOrderStore((state) =>
workspaceSort === "manual" ? state.workspaceOrderByProject : undefined,
);
return useMemo(
() =>
composeWorkspaceStructure({
projects,
projectOrder,
workspaceOrderByProject,
}),
[projectOrder, projects],
[projectOrder, projects, workspaceOrderByProject],
);
}
@@ -640,3 +640,27 @@ it("a prompt changes only its project's structure and output preserves both proj
expect(output[0]).toBe(after[0]);
expect(output[1]).toBe(after[1]);
});
it("applies manual order only when requested without mutating the automatic baseline", () => {
const project = {
viewKey: "p",
projectKey: "p",
projectName: "P",
projectKind: "git" as const,
iconWorkingDir: "/repo",
hosts: [],
workspaceKeys: ["h:new", "h:b", "h:a"],
};
const input = { projects: [project], projectOrder: ["p"] };
expect(
composeWorkspaceStructure({
...input,
workspaceOrderByProject: { p: ["h:filtered", "h:a", "h:b"] },
}).projects[0].workspaceKeys,
).toEqual(["h:new", "h:a", "h:b"]);
expect(composeWorkspaceStructure(input).projects[0].workspaceKeys).toEqual([
"h:new",
"h:b",
"h:a",
]);
});
@@ -254,6 +254,7 @@ export function selectProjectOrder(state: SidebarOrderSnapshot): string[] {
export function composeWorkspaceStructure(input: {
projects: WorkspaceStructureProject[];
projectOrder: readonly string[];
workspaceOrderByProject?: Readonly<Record<string, string[]>>;
}): WorkspaceStructure {
if (input.projects.length === 0) {
return EMPTY_WORKSPACE_STRUCTURE;
@@ -265,7 +266,21 @@ export function composeWorkspaceStructure(input: {
getKey: (project) => project.viewKey,
});
return { projects: orderedProjects };
return {
// Keep the shared automatic projection immutable when applying a sidebar preference.
// eslint-disable-next-line oxc/no-map-spread
projects: orderedProjects.map((project) => {
const storedOrder = input.workspaceOrderByProject?.[project.viewKey];
if (!storedOrder) return project;
const orderedProject = { ...project };
orderedProject.workspaceKeys = applyStoredOrdering({
items: project.workspaceKeys,
storedOrder,
getKey: (key) => key,
});
return orderedProject;
}),
};
}
export function selectWorkspaceKeys(state: SessionsSnapshot, serverId: string | null): string[] {
@@ -6,7 +6,7 @@ import {
import { migrateSidebarOrderState, useSidebarOrderStore } from "./sidebar-order-store";
describe("migrateSidebarOrderState", () => {
it("discards legacy workspace ordering while retaining project ordering", () => {
it("preserves legacy workspace ordering across hosts", () => {
const migrated = migrateSidebarOrderState({
projectOrderByServerId: {
"host-a": ["project-a"],
@@ -21,6 +21,7 @@ describe("migrateSidebarOrderState", () => {
expect(migrated).toEqual({
projectOrder: ["project-a"],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: { "project-a": ["host-a:main", "host-a:feature", "host-b:main"] },
});
});
@@ -88,14 +89,18 @@ describe("sidebar order keys that end in whitespace", () => {
});
});
it("preserves project and pinned order while discarding ordinary workspace order", () => {
it("preserves project, pinned and ordinary workspace order", () => {
expect(
migrateSidebarOrderState({
projectOrder: ["b", "a"],
pinnedWorkspaceOrder: ["h:y", "h:x"],
workspaceOrderByProject: { a: ["h:old", "h:new"] },
}),
).toEqual({ projectOrder: ["b", "a"], pinnedWorkspaceOrder: ["h:y", "h:x"] });
).toEqual({
projectOrder: ["b", "a"],
pinnedWorkspaceOrder: ["h:y", "h:x"],
workspaceOrderByProject: { a: ["h:old", "h:new"] },
});
});
it("preserves whitespace in current project and pin identities during upgrade", () => {
@@ -105,5 +110,37 @@ it("preserves whitespace in current project and pin identities during upgrade",
pinnedWorkspaceOrder: ["host:workspace "],
workspaceOrderByProject: {},
}),
).toEqual({ projectOrder: ["project "], pinnedWorkspaceOrder: ["host:workspace "] });
).toEqual({
projectOrder: ["project "],
pinnedWorkspaceOrder: ["host:workspace "],
workspaceOrderByProject: {},
});
});
it("retains a pre-beta manual order when upgrading", () => {
expect(
migrateSidebarOrderState({
projectOrder: ["project"],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: { project: ["host:b", "host:a"] },
}),
).toEqual({
projectOrder: ["project"],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: { project: ["host:b", "host:a"] },
});
});
it("starts with an empty manual order for beta users whose order was discarded", () => {
expect(migrateSidebarOrderState({ projectOrder: ["p"], pinnedWorkspaceOrder: ["h:a"] })).toEqual({
projectOrder: ["p"],
pinnedWorkspaceOrder: ["h:a"],
workspaceOrderByProject: {},
});
});
it("preserves spaces in saved workspace scopes and identities", () => {
expect(
migrateSidebarOrderState({ workspaceOrderByProject: { "project ": ["host:b ", "host:a "] } })
.workspaceOrderByProject,
).toEqual({ "project ": ["host:b ", "host:a "] });
});
+71 -3
View File
@@ -7,10 +7,13 @@ import { createValidatedPersistStorage } from "@/storage/validated-persist-stora
interface SidebarOrderStoreState {
projectOrder: string[];
pinnedWorkspaceOrder: string[];
workspaceOrderByProject: Record<string, string[]>;
getProjectOrder: () => string[];
setProjectOrder: (keys: string[]) => void;
getPinnedWorkspaceOrder: () => string[];
setPinnedWorkspaceOrder: (keys: string[]) => void;
getWorkspaceOrder: (projectViewKey: string) => string[];
setWorkspaceOrder: (projectViewKey: string, keys: string[]) => void;
}
interface SidebarOrderPersistedState {
@@ -25,12 +28,16 @@ const StringArrayRecordSchema = z.record(z.string(), z.array(z.string()));
const SidebarOrderPersistedStateSchema = z.strictObject({
projectOrder: z.array(z.string()).optional(),
pinnedWorkspaceOrder: z.array(z.string()).optional(),
// COMPAT(automaticWorkspaceOrder): v0.13.0; accept discarded keys until 2027-04-22 so strict parsing preserves projects/pins.
workspaceOrderByProject: StringArrayRecordSchema.optional(),
projectOrderByServerId: StringArrayRecordSchema.optional(),
workspaceOrderByServerAndProject: StringArrayRecordSchema.optional(),
});
interface SidebarWorkspaceOrderScope {
serverId: string;
projectViewKey: string;
}
// Trims each key. Only for persisted state read at migration time, where a
// stray space is an artifact of an older format rather than part of the key.
function normalizeKeys(keys: string[]): string[] {
@@ -71,13 +78,42 @@ function dedupeKeys(keys: string[]): string[] {
return deduped;
}
function normalizeWorkspaceOrderByProject(
workspaceOrderByProject: Record<string, string[]> | undefined,
): Record<string, string[]> {
const normalized: Record<string, string[]> = {};
for (const [projectViewKey, order] of Object.entries(workspaceOrderByProject ?? {})) {
const scope = projectViewKey;
if (!scope.trim()) continue;
normalized[scope] = dedupeKeys(order);
}
return normalized;
}
function extractWorkspaceOrderScope(scopeKey: string): SidebarWorkspaceOrderScope | null {
const separatorIndex = scopeKey.indexOf("::");
if (separatorIndex < 0) return null;
const serverId = scopeKey.slice(0, separatorIndex).trim();
const projectViewKey = scopeKey.slice(separatorIndex + 2).trim();
if (!serverId || !projectViewKey) return null;
return { serverId, projectViewKey };
}
function normalizeLegacyWorkspaceKey(serverId: string, rawWorkspaceKey: string): string | null {
const workspaceKey = rawWorkspaceKey.trim();
if (!workspaceKey) return null;
const serverPrefix = `${serverId}:`;
return workspaceKey.startsWith(serverPrefix) ? workspaceKey : `${serverPrefix}${workspaceKey}`;
}
export function migrateSidebarOrderState(persistedState: unknown): {
projectOrder: string[];
pinnedWorkspaceOrder: string[];
workspaceOrderByProject: Record<string, string[]>;
} {
const result = SidebarOrderPersistedStateSchema.safeParse(persistedState);
if (!result.success) {
return { projectOrder: [], pinnedWorkspaceOrder: [] };
return { projectOrder: [], pinnedWorkspaceOrder: [], workspaceOrderByProject: {} };
}
const state: SidebarOrderPersistedState = result.data;
@@ -91,9 +127,26 @@ export function migrateSidebarOrderState(persistedState: unknown): {
}
}
const workspaceOrderByProject = normalizeWorkspaceOrderByProject(state.workspaceOrderByProject);
for (const [scopeKey, order] of Object.entries(state.workspaceOrderByServerAndProject ?? {})) {
const scope = extractWorkspaceOrderScope(scopeKey);
if (!scope) continue;
const existing = workspaceOrderByProject[scope.projectViewKey] ?? [];
const merged = [...existing];
const seen = new Set(merged);
for (const key of order) {
const workspaceKey = normalizeLegacyWorkspaceKey(scope.serverId, key);
if (!workspaceKey || seen.has(workspaceKey)) continue;
seen.add(workspaceKey);
merged.push(workspaceKey);
}
workspaceOrderByProject[scope.projectViewKey] = merged;
}
return {
projectOrder,
pinnedWorkspaceOrder: dedupeKeys(state.pinnedWorkspaceOrder ?? []),
workspaceOrderByProject,
};
}
@@ -102,6 +155,7 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
(set, get) => ({
projectOrder: [],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: {},
getProjectOrder: () => get().projectOrder,
setProjectOrder: (keys) => {
set({ projectOrder: dedupeKeys(keys) });
@@ -110,6 +164,19 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
setPinnedWorkspaceOrder: (keys) => {
set({ pinnedWorkspaceOrder: dedupeKeys(keys) });
},
getWorkspaceOrder: (projectViewKey) => {
if (!projectViewKey.trim()) return [];
return get().workspaceOrderByProject[projectViewKey] ?? [];
},
setWorkspaceOrder: (projectViewKey, keys) => {
if (!projectViewKey.trim()) return;
set((state) => ({
workspaceOrderByProject: {
...state.workspaceOrderByProject,
[projectViewKey]: dedupeKeys(keys),
},
}));
},
}),
{
name: "sidebar-project-workspace-order",
@@ -117,8 +184,9 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
partialize: (state) => ({
projectOrder: state.projectOrder,
pinnedWorkspaceOrder: state.pinnedWorkspaceOrder,
workspaceOrderByProject: state.workspaceOrderByProject,
}),
version: 2,
version: 3,
migrate: migrateSidebarOrderState,
},
),
@@ -300,3 +300,7 @@ it("defaults to Created and persists the Last prompt choice across migration", (
migrateSidebarViewState({ workspaceSort: "lastPrompt", groupMode: "status" }),
).toMatchObject({ workspaceSort: "lastPrompt", groupMode: "status" });
});
it("retains the manual sorting preference on reload", () => {
expect(migrateSidebarViewState({ workspaceSort: "manual" }).workspaceSort).toBe("manual");
});
@@ -91,7 +91,7 @@ const SidebarLabelFilterSchema = z.object({
labels: z.array(z.string()),
});
const SidebarViewPersistedStateSchema = z.strictObject({
workspaceSort: z.enum(["created", "lastPrompt"]).optional(),
workspaceSort: z.enum(["manual", "created", "lastPrompt"]).optional(),
showBackground: z.boolean().optional(),
groupMode: PersistedSidebarGroupModeSchema.optional(),
hostFilters: z.array(z.string()).optional(),
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode";
import {
TERMINAL_GESTURE_LONG_PRESS_MS,
@@ -7,8 +8,64 @@ import {
advanceTerminalScrollGesture,
classifyTerminalGestureIntent,
resolveTerminalGestureReleaseAction,
routeTerminalGestureRelease,
routeTerminalScrollGesture,
type TerminalGestureReleaseRouteInput,
type TerminalScrollGestureRoute,
} from "./terminal-selection-gesture";
const PI_FULLSCREEN_MOUSE_ENABLE =
"\x1b[?1049h\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h";
const PI_FULLSCREEN_MOUSE_DISABLE = "\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l";
function trackerFromOutput(output: string): TerminalInputModeTracker {
const tracker = new TerminalInputModeTracker();
tracker.feed(output);
return tracker;
}
function dragRoutes(input: {
inputMode: TerminalInputModeTracker;
dys: number[];
point?: { x: number; y: number };
}): TerminalScrollGestureRoute[] {
let state = { lastDy: 0, rowRemainder: 0, didScroll: false };
const routes: TerminalScrollGestureRoute[] = [];
for (const currentDy of input.dys) {
const update = routeTerminalScrollGesture({
state,
currentDy,
point: input.point ?? { x: 25, y: 50 },
metrics: { cellWidth: 8, cellHeight: 14 },
viewport: { rows: 30, cols: 40 },
inputMode: input.inputMode,
});
state = update.state;
routes.push(update.route);
}
return routes;
}
function tapRelease(
overrides: Partial<TerminalGestureReleaseRouteInput> = {},
): ReturnType<typeof routeTerminalGestureRelease> {
return routeTerminalGestureRelease({
status: "pressing",
startedWithSelection: false,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
point: { x: 25, y: 50 },
metrics: { cellWidth: 8, cellHeight: 14 },
viewport: { rows: 30, cols: 40 },
inputMode: trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE),
...overrides,
});
}
describe("native terminal gesture policy", () => {
describe("advanceTerminalScrollGesture", () => {
it("keeps consuming a slow drag after scroll intent arms below one row", () => {
@@ -351,4 +408,143 @@ describe("native terminal gesture policy", () => {
).toEqual("none");
});
});
describe("routeTerminalScrollGesture", () => {
it("sends SGR wheel reports at the touched cell while a fullscreen app tracks the mouse", () => {
const inputMode = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE);
expect(dragRoutes({ inputMode, dys: [13, 28, 42, 14] })).toEqual([
{ kind: "none" },
{ kind: "input", data: "\x1b[<64;4;4M\x1b[<64;4;4M" },
{ kind: "input", data: "\x1b[<64;4;4M" },
{ kind: "input", data: "\x1b[<65;4;4M\x1b[<65;4;4M" },
]);
});
it("keeps ordinary shell drags in local scrollback", () => {
const inputMode = trackerFromOutput("\x1b[?2004h$ ls\r\n");
expect(dragRoutes({ inputMode, dys: [13, 28, 14] })).toEqual([
{ kind: "none" },
{ kind: "scrollback", direction: "up", rows: 2 },
{ kind: "scrollback", direction: "down", rows: 1 },
]);
});
it("returns drags to local scrollback after the app disables mouse tracking", () => {
const inputMode = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE);
inputMode.feed(PI_FULLSCREEN_MOUSE_DISABLE);
expect(dragRoutes({ inputMode, dys: [28] })).toEqual([
{ kind: "scrollback", direction: "up", rows: 2 },
]);
});
it("keeps forwarding wheel input after a reconnect replays the daemon preamble", () => {
const daemon = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE);
const client = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE);
client.reset();
client.feed(daemon.getPreamble());
expect(dragRoutes({ inputMode: client, dys: [14] })).toEqual([
{ kind: "input", data: "\x1b[<64;4;4M" },
]);
});
it("keeps pixel reports integral at fractional cell boundaries", () => {
const inputMode = trackerFromOutput("\x1b[?1003h\x1b[?1016h");
const { route } = routeTerminalScrollGesture({
state: { lastDy: 0, rowRemainder: 0, didScroll: false },
currentDy: 15,
point: { x: 999, y: 999 },
metrics: { cellWidth: 8.25, cellHeight: 14.25 },
viewport: { rows: 3, cols: 3 },
inputMode,
});
expect(route).toEqual({ kind: "input", data: "\x1b[<64;25;43M" });
});
it("clamps wheel reports to the visible grid", () => {
const inputMode = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE);
expect(dragRoutes({ inputMode, dys: [-14], point: { x: 999, y: -5 } })).toEqual([
{ kind: "input", data: "\x1b[<65;40;1M" },
]);
});
});
describe("routeTerminalGestureRelease", () => {
it("clicks the tapped cell and still focuses while a fullscreen app tracks the mouse", () => {
expect(tapRelease()).toEqual({
action: "focus",
mouseInput: "\x1b[<0;4;4M\x1b[<0;4;4m",
});
});
it("only focuses on an ordinary shell tap", () => {
expect(tapRelease({ inputMode: trackerFromOutput("\x1b[?2004h$ ls\r\n") })).toEqual({
action: "focus",
mouseInput: null,
});
});
it("sends only the press for X10 tracking", () => {
expect(tapRelease({ inputMode: trackerFromOutput("\x1b[?9h") })).toEqual({
action: "focus",
mouseInput: "\x1b[M $$",
});
});
it("keeps a long press as local selection without clicking", () => {
expect(tapRelease({ pressDurationMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50 })).toEqual({
action: "select",
mouseInput: null,
});
});
it.each([
["moved beyond tap tolerance", { movedBeyondTapTolerance: true }],
["scrolled", { didScroll: true }],
])("never clicks after a drag that %s", (_name, overrides) => {
expect(tapRelease(overrides)).toEqual({ action: "none", mouseInput: null });
});
it("clears an existing selection without clicking", () => {
expect(tapRelease({ status: "selecting", startedWithSelection: true })).toEqual({
action: "clear",
mouseInput: null,
});
});
it("does not send live mouse input from a scrolled-back viewport", () => {
expect(tapRelease({ scrollMode: "scrolled" })).toEqual({
action: "focus",
mouseInput: null,
});
});
it("does not click before the grid is measured", () => {
expect(tapRelease({ metrics: null, viewport: null })).toEqual({
action: "focus",
mouseInput: null,
});
});
it("clamps clicks to the visible grid", () => {
expect(tapRelease({ point: { x: 999, y: -5 } })).toEqual({
action: "focus",
mouseInput: "\x1b[<0;40;1M\x1b[<0;40;1m",
});
});
it("keeps pixel clicks integral at fractional cell boundaries", () => {
expect(
tapRelease({
inputMode: trackerFromOutput("\x1b[?1000h\x1b[?1016h"),
point: { x: 999, y: 999 },
metrics: { cellWidth: 8.25, cellHeight: 14.25 },
viewport: { rows: 3, cols: 3 },
}),
).toEqual({ action: "focus", mouseInput: "\x1b[<0;25;43M\x1b[<0;25;43m" });
});
});
});
@@ -1,3 +1,9 @@
import type {
TerminalInputModeTracker,
TerminalMousePosition,
} from "@getpaseo/protocol/terminal-input-mode";
import type { TerminalGridCellMetrics } from "./terminal-grid-metrics";
export type TerminalGestureStatus = "idle" | "pressing" | "selecting";
export type TerminalGestureIntent = "tap" | "pending" | "select" | "scroll";
export type TerminalGestureScrollMode = "following" | "scrolled";
@@ -33,8 +39,39 @@ export interface TerminalScrollGestureUpdate {
rowDelta: number;
}
export type TerminalScrollGestureRoute =
| { kind: "none" }
| { kind: "scrollback"; direction: "up" | "down"; rows: number }
| { kind: "input"; data: string };
export interface TerminalScrollGestureRouteInput {
state: TerminalScrollGestureState;
currentDy: number;
point: { x: number; y: number };
metrics: TerminalGridCellMetrics;
viewport: { rows: number; cols: number };
inputMode: Pick<TerminalInputModeTracker, "encodeWheel">;
}
export interface TerminalScrollGestureRouteUpdate {
state: TerminalScrollGestureState;
route: TerminalScrollGestureRoute;
}
export type TerminalGestureAction = "none" | "focus" | "select" | "clear";
export interface TerminalGestureReleaseRouteInput extends TerminalGestureReleaseInput {
point: { x: number; y: number };
metrics: TerminalGridCellMetrics | null;
viewport: { rows: number; cols: number } | null;
inputMode: Pick<TerminalInputModeTracker, "encodeLeftClick">;
}
export interface TerminalGestureReleaseRoute {
action: TerminalGestureAction;
mouseInput: string | null;
}
export const TERMINAL_GESTURE_TAP_TOLERANCE_PX = 8;
export const TERMINAL_GESTURE_LONG_PRESS_MS = 450;
export const TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX = 12;
@@ -78,6 +115,60 @@ export function advanceTerminalScrollGesture(input: {
};
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
/** Clamps a touch point to the visible grid; pixel reports stay integral at fractional cell sizes. */
function resolveTerminalMousePosition(input: {
point: { x: number; y: number };
metrics: TerminalGridCellMetrics;
viewport: { rows: number; cols: number };
}): TerminalMousePosition {
const { cellWidth, cellHeight } = input.metrics;
const pixelX = clamp(
Math.floor(input.point.x),
0,
Math.ceil(input.viewport.cols * cellWidth) - 1,
);
const pixelY = clamp(
Math.floor(input.point.y),
0,
Math.ceil(input.viewport.rows * cellHeight) - 1,
);
return {
col: Math.floor(pixelX / cellWidth) + 1,
row: Math.floor(pixelY / cellHeight) + 1,
pixelX: pixelX + 1,
pixelY: pixelY + 1,
};
}
/** A drag scrolls local scrollback unless the foreground app asked for wheel reports. */
export function routeTerminalScrollGesture(
input: TerminalScrollGestureRouteInput,
): TerminalScrollGestureRouteUpdate {
const { state, rowDelta } = advanceTerminalScrollGesture({
state: input.state,
currentDy: input.currentDy,
cellHeight: input.metrics.cellHeight,
});
if (rowDelta === 0) {
return { state, route: { kind: "none" } };
}
const direction = rowDelta > 0 ? "up" : "down";
const rows = Math.abs(rowDelta);
const report = input.inputMode.encodeWheel({
direction,
...resolveTerminalMousePosition(input),
});
if (report === null) {
return { state, route: { kind: "scrollback", direction, rows } };
}
return { state, route: { kind: "input", data: report.repeat(rows) } };
}
export function resolveTerminalGestureReleaseAction(
input: TerminalGestureReleaseInput,
): TerminalGestureAction {
@@ -106,3 +197,19 @@ export function resolveTerminalGestureReleaseAction(
return "focus";
}
/**
* Only a short tap on the live screen clicks; long press, drags and selection clears stay local.
* The caller sends the click before focusing, so the keyboard resize cannot move the target cell.
*/
export function routeTerminalGestureRelease(
input: TerminalGestureReleaseRouteInput,
): TerminalGestureReleaseRoute {
const action = resolveTerminalGestureReleaseAction(input);
const { metrics, viewport } = input;
if (action !== "focus" || input.scrollMode !== "following" || !metrics || !viewport) {
return { action, mouseInput: null };
}
const position = resolveTerminalMousePosition({ point: input.point, metrics, viewport });
return { action, mouseInput: input.inputMode.encodeLeftClick(position) };
}
@@ -118,6 +118,111 @@ describe("TerminalInputModeTracker", () => {
expect(tracker.getPreamble()).toBe("");
});
const PI_FULLSCREEN_MOUSE_ENABLE =
"\x1b[?1049h\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h";
const PI_FULLSCREEN_MOUSE_DISABLE = "\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l";
const wheelUpAt3x4 = { direction: "up" as const, col: 3, row: 4, pixelX: 30, pixelY: 80 };
it("encodes SGR wheel reports while a fullscreen app tracks the mouse", () => {
const tracker = new TerminalInputModeTracker();
expect(tracker.encodeWheel(wheelUpAt3x4)).toBeNull();
expect(tracker.feed(PI_FULLSCREEN_MOUSE_ENABLE).changed).toBe(true);
expect(tracker.encodeWheel(wheelUpAt3x4)).toBe("\x1b[<64;3;4M");
expect(tracker.encodeWheel({ ...wheelUpAt3x4, direction: "down" })).toBe("\x1b[<65;3;4M");
});
it("stops encoding wheel reports once the app disables mouse tracking", () => {
const tracker = new TerminalInputModeTracker();
tracker.feed(PI_FULLSCREEN_MOUSE_ENABLE);
expect(tracker.feed(PI_FULLSCREEN_MOUSE_DISABLE).changed).toBe(true);
expect(tracker.encodeWheel(wheelUpAt3x4)).toBeNull();
expect(tracker.getPreamble()).toBe("");
});
it("replays mouse tracking so a reconnected client keeps forwarding wheel input", () => {
const daemon = new TerminalInputModeTracker();
daemon.feed(PI_FULLSCREEN_MOUSE_ENABLE);
const client = new TerminalInputModeTracker();
client.feed(PI_FULLSCREEN_MOUSE_ENABLE);
client.reset();
expect(client.encodeWheel(wheelUpAt3x4)).toBeNull();
expect(daemon.getPreamble()).toBe("\x1b[?1003h\x1b[?1006h");
client.feed(daemon.getPreamble());
expect(client.encodeWheel(wheelUpAt3x4)).toBe("\x1b[<64;3;4M");
});
it("encodes SGR pixel wheel reports with pixel coordinates", () => {
const tracker = new TerminalInputModeTracker();
tracker.feed("\x1b[?1000h\x1b[?1016h");
expect(tracker.encodeWheel(wheelUpAt3x4)).toBe("\x1b[<64;30;80M");
expect(tracker.getPreamble()).toBe("\x1b[?1000h\x1b[?1016h");
});
it("encodes default wheel reports only while every byte stays ASCII", () => {
const tracker = new TerminalInputModeTracker();
tracker.feed("\x1b[?1000h");
expect(tracker.encodeWheel(wheelUpAt3x4)).toBe("\x1b[M`#$");
expect(tracker.encodeWheel({ ...wheelUpAt3x4, col: 96 })).toBeNull();
expect(tracker.encodeWheel({ ...wheelUpAt3x4, row: 96 })).toBeNull();
});
it("does not encode wheel reports for X10 press-only tracking", () => {
const tracker = new TerminalInputModeTracker();
tracker.feed("\x1b[?9h\x1b[?1006h");
expect(tracker.encodeWheel(wheelUpAt3x4)).toBeNull();
});
const tapAt3x4 = { col: 3, row: 4, pixelX: 30, pixelY: 80 };
it.each([
["no tracking", "", null],
["SGR press/release", PI_FULLSCREEN_MOUSE_ENABLE, "\x1b[<0;3;4M\x1b[<0;3;4m"],
["SGR pixel press/release", "\x1b[?1000h\x1b[?1016h", "\x1b[<0;30;80M\x1b[<0;30;80m"],
["default press and button-3 release", "\x1b[?1000h", "\x1b[M #$\x1b[M##$"],
["X10 default press only", "\x1b[?9h", "\x1b[M #$"],
["X10 SGR press only", "\x1b[?9h\x1b[?1006h", "\x1b[<0;3;4M"],
[
"tracking disabled again",
`${PI_FULLSCREEN_MOUSE_ENABLE}${PI_FULLSCREEN_MOUSE_DISABLE}`,
null,
],
])("encodes a left click with %s", (_name, output, expected) => {
const tracker = new TerminalInputModeTracker();
tracker.feed(output);
expect(tracker.encodeLeftClick(tapAt3x4)).toBe(expected);
});
it("does not encode a default left click that leaves ASCII", () => {
const tracker = new TerminalInputModeTracker();
tracker.feed("\x1b[?1000h");
expect(tracker.encodeLeftClick({ ...tapAt3x4, col: 96 })).toBeNull();
});
it.each([["\x1bc"], ["\x1b", "c"]])(
"clears every tracked mode on a full reset split as %j",
(...chunks) => {
const tracker = new TerminalInputModeTracker();
tracker.feed(`\x1b[>7u\x1b[?2004h${PI_FULLSCREEN_MOUSE_ENABLE}`);
const results = chunks.map((chunk) => tracker.feed(chunk));
expect(results.at(-1)).toEqual({ changed: true, responses: [] });
expect(tracker.getPreamble()).toBe("");
expect(tracker.encodeWheel(wheelUpAt3x4)).toBeNull();
},
);
it("ignores encoded key input sequences", () => {
const tracker = new TerminalInputModeTracker();
+107 -7
View File
@@ -10,6 +10,17 @@ export interface TerminalInputModeState {
bracketedPaste?: boolean;
}
export interface TerminalMousePosition {
col: number;
row: number;
pixelX: number;
pixelY: number;
}
export interface TerminalWheelInput extends TerminalMousePosition {
direction: "up" | "down";
}
export const DEFAULT_TERMINAL_INPUT_MODE_STATE: TerminalInputModeState = {
kittyKeyboardFlags: 0,
win32InputMode: false,
@@ -21,8 +32,20 @@ const ESC = String.fromCharCode(0x1b);
const APPLICATION_CURSOR_KEYS_MODE = 1;
const WIN32_INPUT_MODE = 9001;
const BRACKETED_PASTE_MODE = 2004;
const CSI_INPUT_MODE_SEQUENCE = new RegExp(
`${ESC}\\[(?:([<>=?]?)([0-9;]*)u|\\?([0-9;]*)([hl]))`,
const X10_MOUSE_TRACKING_MODE = 9;
const MOUSE_TRACKING_MODES = new Set([X10_MOUSE_TRACKING_MODE, 1000, 1002, 1003]);
const SGR_MOUSE_ENCODING_MODE = 1006;
const SGR_PIXELS_MOUSE_ENCODING_MODE = 1016;
const MOUSE_ENCODING_MODES = new Set([SGR_MOUSE_ENCODING_MODE, SGR_PIXELS_MOUSE_ENCODING_MODE]);
const LEFT_BUTTON = 0;
const DEFAULT_RELEASE_BUTTON = 3;
const WHEEL_UP_BUTTON = 64;
const WHEEL_DOWN_BUTTON = 65;
const DEFAULT_MOUSE_ENCODING_OFFSET = 32;
const MAX_ASCII_CODE = 127;
const FULL_RESET = `${ESC}c`;
const INPUT_MODE_SEQUENCE = new RegExp(
`${ESC}(?:c|\\[(?:([<>=?]?)([0-9;]*)u|\\?([0-9;]*)([hl])))`,
"g",
);
const INCOMPLETE_CSI_INPUT_MODE_SEQUENCE = new RegExp(`${ESC}\\[[<>=?]?[0-9;]*$`);
@@ -74,6 +97,8 @@ export class TerminalInputModeTracker {
private win32InputMode = false;
private applicationCursorKeys = false;
private bracketedPaste = false;
private mouseTrackingMode = 0;
private mouseEncodingMode = 0;
private readonly kittyKeyboardStack: number[] = [];
private pending = "";
@@ -89,13 +114,19 @@ export class TerminalInputModeTracker {
const responses: string[] = [];
let consumedUntil = 0;
CSI_INPUT_MODE_SEQUENCE.lastIndex = 0;
INPUT_MODE_SEQUENCE.lastIndex = 0;
for (;;) {
const match = CSI_INPUT_MODE_SEQUENCE.exec(text);
const match = INPUT_MODE_SEQUENCE.exec(text);
if (!match) {
break;
}
consumedUntil = CSI_INPUT_MODE_SEQUENCE.lastIndex;
consumedUntil = INPUT_MODE_SEQUENCE.lastIndex;
if (match[0] === FULL_RESET) {
changed = this.getPreamble().length > 0 || changed;
this.reset();
continue;
}
if (match[4]) {
changed = this.applyPrivateModeSequence(match[3] ?? "", match[4]) || changed;
@@ -108,10 +139,10 @@ export class TerminalInputModeTracker {
}
const tail = text.slice(consumedUntil);
const pendingStart = tail.lastIndexOf(`${ESC}[`);
const pendingStart = tail.lastIndexOf(ESC);
if (pendingStart >= 0) {
const pending = tail.slice(pendingStart);
if (INCOMPLETE_CSI_INPUT_MODE_SEQUENCE.test(pending)) {
if (pending === ESC || INCOMPLETE_CSI_INPUT_MODE_SEQUENCE.test(pending)) {
this.pending = pending;
}
}
@@ -124,6 +155,8 @@ export class TerminalInputModeTracker {
this.win32InputMode = false;
this.applicationCursorKeys = false;
this.bracketedPaste = false;
this.mouseTrackingMode = 0;
this.mouseEncodingMode = 0;
this.kittyKeyboardStack.length = 0;
this.pending = "";
}
@@ -159,9 +192,64 @@ export class TerminalInputModeTracker {
if (this.bracketedPaste) {
parts.push("\x1b[?2004h");
}
// The alternate screen is never replayed here: re-entering it would clear the restored screen.
if (this.mouseTrackingMode > 0) {
parts.push(`\x1b[?${this.mouseTrackingMode}h`);
}
if (this.mouseEncodingMode > 0) {
parts.push(`\x1b[?${this.mouseEncodingMode}h`);
}
return parts.join("");
}
/** Returns the wheel report the foreground app requested, or null when it does not track the wheel. */
encodeWheel(input: TerminalWheelInput): string | null {
const isWheelReported =
this.mouseTrackingMode > 0 && this.mouseTrackingMode !== X10_MOUSE_TRACKING_MODE;
if (!isWheelReported) {
return null;
}
const button = input.direction === "up" ? WHEEL_UP_BUTTON : WHEEL_DOWN_BUTTON;
return this.encodeMouseReport(button, input, "M");
}
/** Returns a left press and release, press only under X10, or null when the app does not track the mouse. */
encodeLeftClick(position: TerminalMousePosition): string | null {
if (this.mouseTrackingMode === 0) {
return null;
}
const press = this.encodeMouseReport(LEFT_BUTTON, position, "M");
if (press === null || this.mouseTrackingMode === X10_MOUSE_TRACKING_MODE) {
return press;
}
const isSgr = this.mouseEncodingMode !== 0;
const release = isSgr
? this.encodeMouseReport(LEFT_BUTTON, position, "m")
: this.encodeMouseReport(DEFAULT_RELEASE_BUTTON, position, "M");
return release === null ? null : `${press}${release}`;
}
private encodeMouseReport(
button: number,
position: TerminalMousePosition,
sgrFinal: "M" | "m",
): string | null {
if (this.mouseEncodingMode === SGR_PIXELS_MOUSE_ENCODING_MODE) {
return `\x1b[<${button};${position.pixelX};${position.pixelY}${sgrFinal}`;
}
if (this.mouseEncodingMode === SGR_MOUSE_ENCODING_MODE) {
return `\x1b[<${button};${position.col};${position.row}${sgrFinal}`;
}
// Input travels as UTF-8 text, so a default-encoded byte above ASCII would be re-encoded.
const codes = [button, position.col, position.row].map(
(value) => value + DEFAULT_MOUSE_ENCODING_OFFSET,
);
if (codes.some((code) => code > MAX_ASCII_CODE)) {
return null;
}
return `\x1b[M${String.fromCharCode(...codes)}`;
}
private applyKittyKeyboardSequence(
prefix: string,
params: string,
@@ -205,6 +293,18 @@ export class TerminalInputModeTracker {
const modes = parsePrivateModeParams(params);
let changed = false;
for (const mode of modes) {
const next = final === "h" ? mode : 0;
if (MOUSE_TRACKING_MODES.has(mode)) {
changed = this.mouseTrackingMode !== next || changed;
this.mouseTrackingMode = next;
}
if (MOUSE_ENCODING_MODES.has(mode)) {
changed = this.mouseEncodingMode !== next || changed;
this.mouseEncodingMode = next;
}
}
if (modes.has(WIN32_INPUT_MODE)) {
const previous = this.win32InputMode;
this.win32InputMode = final === "h";
@@ -7,6 +7,7 @@ import type { AgentTimelineItem } from "../../agent-sdk-types.js";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./agent.js";
import { claudeProjectDirSync } from "./project-dir.js";
import { projectTimelineRows } from "../../timeline-projection.js";
describe("Claude session request journal replay", () => {
let root: string;
@@ -137,4 +138,100 @@ describe("Claude session request journal replay", () => {
}),
]);
});
test.each(["remove", "user"])(
"replays a background completion once across queue enqueue and %s records",
async (delivery) => {
const content =
"<task-notification>\n<task-id>bg-1</task-id>\n<status>completed</status>\n<summary>Background command completed</summary>\n</task-notification>";
const items = await replay([
{ type: "queue-operation", operation: "enqueue", content },
delivery === "remove"
? { type: "queue-operation", operation: "remove", content }
: {
type: "user",
uuid: "notification-delivery",
origin: { kind: "task-notification" },
message: { role: "user", content },
},
]);
const projected = projectTimelineRows({
rows: items.map((item, seq) => ({ item, seq, timestamp: "2026-10-23T00:00:00.000Z" })),
mode: "projected",
});
expect(projected.map((row) => row.item)).toEqual([
expect.objectContaining({
type: "tool_call",
name: "task_notification",
status: "completed",
metadata: expect.objectContaining({ taskId: "bg-1" }),
}),
]);
},
);
test("restores each Monitor event and completion of one task once", async () => {
const notifications = ["1", "2", "3", "4"].map(
(event) =>
`<task-notification>\n<task-id>monitor-1</task-id>\n<summary>Monitor event</summary>\n<event>${event}</event>\n</task-notification>`,
);
notifications.push(
"<task-notification>\n<task-id>monitor-1</task-id>\n<status>completed</status>\n<summary>Monitor stream ended</summary>\n</task-notification>",
);
const items = await replay(
notifications.flatMap((content, index) => [
{ type: "queue-operation", operation: "enqueue", content },
{ type: "user", uuid: `delivery-${index}`, message: { role: "user", content } },
]),
);
const projected = projectTimelineRows({
rows: items.map((item, seq) => ({ item, seq, timestamp: "2026-10-23T00:00:00.000Z" })),
mode: "projected",
});
expect(projected.map(({ item }) => item.type === "tool_call" && item.detail)).toEqual(
notifications.map((text, index) =>
expect.objectContaining({
label: index === 4 ? "Monitor stream ended" : "Monitor event",
text,
}),
),
);
});
test.each(["user", "remove", "system"])(
"preserves identical notifications from separate occurrences with %s deliveries",
async (delivery) => {
const content =
"<task-notification>\n<task-id>monitor-repeat</task-id>\n<status>completed</status>\n<summary>Monitor event</summary>\n</task-notification>";
const queued = [1, 2].map(() => ({ type: "queue-operation", operation: "enqueue", content }));
const delivered = [1, 2].map((index) => {
if (delivery === "user") {
return { type: "user", uuid: `repeat-${index}`, message: { role: "user", content } };
}
if (delivery === "remove") {
return { type: "queue-operation", operation: "remove", content };
}
return {
type: "system",
subtype: "task_notification",
uuid: `repeat-${index}`,
task_id: "monitor-repeat",
status: "completed",
summary: "Monitor event",
};
});
const items = await replay([
...queued,
...delivered,
{ type: "queue-operation", operation: "enqueue", content },
]);
const projected = projectTimelineRows({
rows: items.map((item, seq) => ({ item, seq, timestamp: "2026-10-23T00:00:00.000Z" })),
mode: "projected",
});
expect(projected.map(({ item }) => item.type === "tool_call" && item.detail?.label)).toEqual([
"Monitor event",
"Monitor event",
"Monitor event",
]);
},
);
});
@@ -834,7 +834,7 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
.filter((item) => item.type === "tool_call" && item.name === "task_notification"),
).toEqual([
expect.objectContaining({
callId: "task_notification_workflow-notification",
callId: expect.stringMatching(/^task_notification_[a-f0-9]+_1$/),
status: "completed",
}),
]);
@@ -6313,6 +6313,7 @@ export function convertClaudeHistoryEntry(
const userTaskNotificationItem = mapTaskNotificationUserContentToToolCall({
content,
messageId: userMessageId,
notificationId: typeof entry.notificationId === "string" ? entry.notificationId : undefined,
});
if (userTaskNotificationItem) {
return [userTaskNotificationItem];
@@ -97,4 +97,17 @@ describe("task-notification-tool-call", () => {
expect(item).toBeNull();
});
it("keeps notifications without task IDs distinct by their message IDs", () => {
const items = ["message-1", "message-2"].map((messageId) =>
mapTaskNotificationUserContentToToolCall({
content: "<task-notification><status>completed</status></task-notification>",
messageId,
}),
);
expect(items.map((item) => item?.callId)).toEqual([
"task_notification_message-1",
"task_notification_message-2",
]);
});
});
@@ -12,6 +12,7 @@ const OptionalNonEmptyTrimmedStringSchema = z.preprocess(
);
const TaskNotificationEnvelopeSchema = z.object({
notificationId: z.string().optional(),
messageId: z.string().nullable(),
taskId: z.string().nullable(),
toolUseId: z.string().nullable(),
@@ -35,7 +36,9 @@ const TaskNotificationUserContentSchema = z.union([
const TaskNotificationHistoryRecordSchema = z
.object({
notificationId: z.string().optional(),
type: z.string().optional(),
operation: z.string().optional(),
subtype: z.string().optional(),
uuid: z.string().optional(),
message_id: z.string().optional(),
@@ -52,6 +55,7 @@ const TaskNotificationHistoryRecordSchema = z
const MapTaskNotificationUserContentToToolCallInputSchema = z.object({
content: z.unknown(),
messageId: z.string().optional().nullable(),
notificationId: z.string().optional(),
});
export type TaskNotificationEnvelope = z.infer<typeof TaskNotificationEnvelopeSchema>;
@@ -155,6 +159,7 @@ function parseTaskNotificationFromUserContent(
}
return TaskNotificationEnvelopeSchema.parse({
notificationId: parsedInput.data.notificationId,
messageId: toNonEmptyString(parsedInput.data.messageId),
taskId: readTaskNotificationTagValue({ text: rawText, tagName: "task-id" }),
toolUseId:
@@ -186,6 +191,7 @@ function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificatio
const rawText = toNonEmptyString(systemRecord.content);
return TaskNotificationEnvelopeSchema.parse({
notificationId: systemRecord.notificationId,
messageId: toNonEmptyString(systemRecord.uuid) ?? toNonEmptyString(systemRecord.message_id),
taskId:
toNonEmptyString(systemRecord.task_id) ??
@@ -212,25 +218,76 @@ function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificatio
});
}
/** Normalize notification identity for one transcript traversal. Queue bookkeeping and delivery
* are separate records of the same occurrence; each source counts repeated identical bodies
* independently so the second event is not merged with the first. */
export function* assignTaskNotificationReplayIds<T>(entries: Iterable<T>): Generator<T> {
const occurrences = new Map<string, Map<string, number>>();
for (const entry of entries) {
const record = TaskNotificationHistoryRecordSchema.safeParse(entry);
if (!record.success) {
yield entry;
continue;
}
const data = record.data;
const envelope =
data.type === "user"
? parseTaskNotificationFromUserContent({ content: data.message?.content })
: parseTaskNotificationFromSystemRecord(data);
if (!envelope?.taskId || !data.type) {
yield entry;
continue;
}
const signature = taskNotificationReplaySignature(envelope);
const source = data.type === "queue-operation" ? `queue-${data.operation}` : data.type;
const counts = occurrences.get(signature) ?? new Map<string, number>();
const occurrence = (counts.get(source) ?? 0) + 1;
counts.set(source, occurrence);
occurrences.set(signature, counts);
yield { ...entry, notificationId: `task_notification_${signature}_${occurrence}` };
}
}
function taskNotificationReplaySignature(envelope: TaskNotificationEnvelope): string {
// Known fields are canonical across XML user/queue records and structured system records.
// Keep the remaining body too: Monitor events share a summary but carry different <event>s.
const remainder = (envelope.rawText ?? "")
.replace(/<\/?task-notification>/gi, "")
.replace(
/<(task-id|tool-use-id|tool_use_id|status|summary|output-file|output_file)>[\s\S]*?<\/\1>/gi,
"",
)
.trim();
return createHash("sha1")
.update(
JSON.stringify([
envelope.taskId,
envelope.toolUseId,
envelope.status,
envelope.summary,
envelope.outputFile,
remainder,
]),
)
.digest("hex");
}
function normalizeTaskNotificationCallIdSegment(segment: string): string | null {
const normalized = segment.trim().replace(/[^a-zA-Z0-9._:-]+/g, "_");
return normalized.length > 0 ? normalized : null;
}
function buildTaskNotificationCallId(envelope: TaskNotificationEnvelope): string {
if (envelope.notificationId) return envelope.notificationId;
const messageSegment = envelope.messageId
? normalizeTaskNotificationCallIdSegment(envelope.messageId)
: null;
if (messageSegment) {
return `task_notification_${messageSegment}`;
}
if (messageSegment) return `task_notification_${messageSegment}`;
const taskSegment = envelope.taskId
? normalizeTaskNotificationCallIdSegment(envelope.taskId)
: null;
if (taskSegment) {
return `task_notification_${taskSegment}`;
}
if (taskSegment) return `task_notification_${taskSegment}`;
const seed =
[envelope.status, envelope.summary, envelope.outputFile, envelope.rawText]
@@ -1,3 +1,4 @@
import { assignTaskNotificationReplayIds } from "./task-notification-tool-call.js";
import fs from "node:fs";
import path from "node:path";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
@@ -88,21 +89,24 @@ function indexedRecords(
): Iterable<TranscriptEntry> {
return {
*[Symbol.iterator]() {
for (const location of locations) {
const fd = descriptors.get(location.file)!;
const buffer = Buffer.allocUnsafe(location.length);
let read = 0;
while (read < buffer.length) {
const count = fs.readSync(fd, buffer, read, buffer.length - read, location.offset + read);
if (count === 0)
throw new Error(`Claude transcript was truncated during replay: ${location.file}`);
read += count;
}
const entry = parseRecord(buffer.toString("utf8"));
if (entry) yield entry;
}
yield* assignTaskNotificationReplayIds(readEntries());
},
};
function* readEntries(): Generator<TranscriptEntry> {
for (const location of locations) {
const fd = descriptors.get(location.file)!;
const buffer = Buffer.allocUnsafe(location.length);
let read = 0;
while (read < buffer.length) {
const count = fs.readSync(fd, buffer, read, buffer.length - read, location.offset + read);
if (count === 0)
throw new Error(`Claude transcript was truncated during replay: ${location.file}`);
read += count;
}
const entry = parseRecord(buffer.toString("utf8"));
if (entry) yield entry;
}
}
}
function isToolResult(entry: TranscriptEntry): boolean {
@@ -0,0 +1,70 @@
import { createServer } from "node:http";
import { setTimeout as delay } from "node:timers/promises";
import pino from "pino";
import { OpenAITTS } from "../tts.js";
import { TTSManager } from "../../../../agent/tts-manager.js";
if (!global.gc) throw new Error("Run with --expose-gc");
const sentences = ["First sentence.", "Second sentence.", "Third sentence."];
const audio = sentences.map((_, index) => Buffer.alloc(192_000, index + 1));
const requests: string[] = [];
const server = createServer(async (request, response) => {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
const { input } = JSON.parse(Buffer.concat(chunks).toString()) as { input: string };
requests.push(input);
const body = audio[sentences.indexOf(input)];
response.writeHead(200, { "Content-Type": "audio/pcm", "Content-Length": body.length });
response.end(body);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing server address");
const logger = pino({ level: "silent" });
const provider = new OpenAITTS(
{ apiKey: "test", baseUrl: `http://127.0.0.1:${address.port}/v1` },
logger,
);
const manager = new TTSManager("prefetched-playback", logger, provider);
const emitted: { bytes: number; value: number; format: string }[] = [];
try {
await manager.generateAndWaitForPlayback(
sentences.join(" "),
(message) => {
if (message.type !== "audio_output") return;
const buffer = Buffer.from(message.payload.audio, "base64");
emitted.push({ bytes: buffer.length, value: buffer[0], format: message.payload.format });
if (emitted.length === 1) {
// Hold the playback acknowledgement while the other responses are prefetched.
void (async () => {
while (requests.length < sentences.length) await delay(10);
for (let cycle = 0; cycle < 20; cycle++) {
await delay(25);
global.gc!();
}
manager.confirmAudioPlayed(message.payload.id);
})();
} else {
setImmediate(() => manager.confirmAudioPlayed(message.payload.id));
}
},
new AbortController().signal,
true,
);
console.log(JSON.stringify({ requests, emitted }));
} finally {
manager.cleanup();
server.closeAllConnections();
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
@@ -0,0 +1,21 @@
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";
test("prefetched speech survives garbage collection until its playback turn", () => {
const fixture = fileURLToPath(new URL("./test-utils/prefetched-playback.ts", import.meta.url));
const output = execFileSync(process.execPath, ["--expose-gc", "--import", "tsx", fixture], {
encoding: "utf8",
timeout: 15_000,
});
const result = JSON.parse(output);
expect(result.requests.sort()).toEqual(
["First sentence.", "Second sentence.", "Third sentence."].sort(),
);
expect(result.emitted).toEqual([
{ bytes: 192_000, value: 1, format: "pcm" },
{ bytes: 192_000, value: 2, format: "pcm" },
{ bytes: 192_000, value: 3, format: "pcm" },
]);
});
@@ -67,7 +67,9 @@ export class OpenAITTS implements TextToSpeechProvider {
| "pcm",
});
const audioStream = response.body as unknown as Readable;
// Prefetched speech can wait through playback of an earlier sentence. Consume
// the response here so fetch cannot collect it and cancel its unread body.
const audioStream = Readable.from([Buffer.from(await response.arrayBuffer())]);
const duration = Date.now() - startTime;
this.logger.debug({ duration }, "Speech synthesis stream ready");