mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96020ab773 | ||
|
|
686588c44b | ||
|
|
48623801a1 | ||
|
|
632a7f8859 | ||
|
|
48d2c9ec36 | ||
|
|
ca894d49a5 | ||
|
|
d6fa231894 | ||
|
|
fed19f3d29 | ||
|
|
74dc764f0d |
+1
-1
@@ -1 +1 @@
|
||||
sha256-iy1sFWI+odVTFG4jvNcJfv3xGAcQrbNkNQ+gtmInNCU=
|
||||
sha256-n6m3m0541/mBbMN1xrh/2uBCRQc62yicK1cdvyyUmmU=
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -28,6 +28,23 @@ function failure(message: string) {
|
||||
}
|
||||
|
||||
describe("direct connection failure copy", () => {
|
||||
it.each([
|
||||
"paseo-qa-nonexistent.invalid",
|
||||
"ssl-paseo-qa.invalid",
|
||||
"tls-paseo-qa.invalid",
|
||||
"certificate-paseo-qa.invalid",
|
||||
])(
|
||||
"identifies Android DNS failures for %s without treating the hostname as TLS evidence",
|
||||
(host) => {
|
||||
const message = `Unable to resolve host "${host}": No address associated with hostname`;
|
||||
expect(failure(message)).toEqual({
|
||||
title: "Failed: tcp://example.test:443?ssl=true",
|
||||
detail: labels.hostNotFound,
|
||||
raw: `transport error — ${message}`,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("identifies Android certificate validation errors without calling them DNS failures", () => {
|
||||
const message =
|
||||
"java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.";
|
||||
@@ -43,6 +60,7 @@ describe("direct connection failure copy", () => {
|
||||
["SSL handshake failed", labels.tlsError],
|
||||
["TLS handshake failed", labels.tlsError],
|
||||
["getaddrinfo ENOTFOUND example.test", labels.hostNotFound],
|
||||
["getaddrinfo ENOTFOUND ssl.example.test", labels.hostNotFound],
|
||||
["Host not found", labels.hostNotFound],
|
||||
["connect ECONNREFUSED", labels.refused],
|
||||
["connection timed out", labels.timedOut],
|
||||
|
||||
@@ -83,6 +83,8 @@ export function buildConnectionFailureCopy(input: {
|
||||
rawLower.includes("err_connection_refused")
|
||||
) {
|
||||
detail = labels.refused;
|
||||
} else if (rawLower.includes("unable to resolve host") || rawLower.includes("enotfound")) {
|
||||
detail = labels.hostNotFound;
|
||||
} else if (
|
||||
rawLower.includes("certificate") ||
|
||||
rawLower.includes("certpathvalidator") ||
|
||||
@@ -91,7 +93,7 @@ export function buildConnectionFailureCopy(input: {
|
||||
rawLower.includes("ssl")
|
||||
) {
|
||||
detail = labels.tlsError;
|
||||
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
|
||||
} else if (rawLower.includes("not found")) {
|
||||
detail = labels.hostNotFound;
|
||||
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
|
||||
detail = labels.hostUnreachable;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1169,6 +1169,7 @@ export const ar: TranslationResources = {
|
||||
sort: {
|
||||
label: "الترتيب",
|
||||
created: "تاريخ الإنشاء",
|
||||
manual: "يدوي",
|
||||
lastPrompt: "آخر طلب",
|
||||
updateHost: "حدّث المضيفين لترتيب مساحات العمل",
|
||||
},
|
||||
|
||||
@@ -1177,6 +1177,7 @@ export const en = {
|
||||
sort: {
|
||||
label: "Sort",
|
||||
created: "Created",
|
||||
manual: "Manual",
|
||||
lastPrompt: "Last prompt",
|
||||
updateHost: "Update hosts to sort workspaces",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -1182,6 +1182,7 @@ export const ja: TranslationResources = {
|
||||
sort: {
|
||||
label: "並び順",
|
||||
created: "作成日時",
|
||||
manual: "手動",
|
||||
lastPrompt: "最後のプロンプト",
|
||||
updateHost: "ワークスペースを並べ替えるにはホストを更新してください",
|
||||
},
|
||||
|
||||
@@ -1176,6 +1176,7 @@ export const ko: TranslationResources = {
|
||||
sort: {
|
||||
label: "정렬",
|
||||
created: "생성일",
|
||||
manual: "수동",
|
||||
lastPrompt: "마지막 프롬프트",
|
||||
updateHost: "작업 공간을 정렬하려면 호스트를 업데이트하세요",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -1186,6 +1186,7 @@ export const ru: TranslationResources = {
|
||||
sort: {
|
||||
label: "Сортировка",
|
||||
created: "Дата создания",
|
||||
manual: "Вручную",
|
||||
lastPrompt: "Последний запрос",
|
||||
updateHost: "Обновите хосты для сортировки рабочих пространств",
|
||||
},
|
||||
|
||||
@@ -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 "] });
|
||||
});
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -374,13 +374,19 @@ class AcpRuntime {
|
||||
/**
|
||||
* Fallback ids for streamed chunks that carry no `messageId`, one per
|
||||
* chunk kind. Consecutive chunks of a kind continue the same timeline
|
||||
* item; a chunk of the other kind, a tool call, a plan, a user message,
|
||||
* or a turn boundary ends it (#4699).
|
||||
* item; a chunk of another kind, a tool call, a plan, or a turn boundary
|
||||
* ends it (#4699).
|
||||
*/
|
||||
private readonly fallbackChunkIds = new Map<
|
||||
"agent_message_chunk" | "agent_thought_chunk",
|
||||
"agent_message_chunk" | "agent_thought_chunk" | "user_message_chunk",
|
||||
string
|
||||
>();
|
||||
/**
|
||||
* Active while a persisted session loads. ACP agents replay the loaded
|
||||
* session's updates before session/load resolves; the value follows the
|
||||
* open request's history contract.
|
||||
*/
|
||||
private replayWindow: "replay" | "skip" | null = null;
|
||||
private closing = false;
|
||||
private processFailed = false;
|
||||
private configTransaction = false;
|
||||
@@ -499,15 +505,23 @@ class AcpRuntime {
|
||||
};
|
||||
let response: Pick<NewSessionResponse, "modes" | "configOptions">;
|
||||
if (nativeSessionId) {
|
||||
response = await this.call(
|
||||
this.connection.loadSession({
|
||||
sessionId: nativeSessionId,
|
||||
cwd: input.config.cwd,
|
||||
mcpServers,
|
||||
_meta: metadata,
|
||||
}),
|
||||
);
|
||||
// ACP agents replay the loaded session's updates before session/load resolves,
|
||||
// so the native id must be registered first for them to pass the session filter.
|
||||
this.nativeSessionId = nativeSessionId;
|
||||
this.replayWindow = input.history === "replay" ? "replay" : "skip";
|
||||
try {
|
||||
response = await this.call(
|
||||
this.connection.loadSession({
|
||||
sessionId: nativeSessionId,
|
||||
cwd: input.config.cwd,
|
||||
mcpServers,
|
||||
_meta: metadata,
|
||||
}),
|
||||
);
|
||||
await this.drainNotifications();
|
||||
} finally {
|
||||
this.replayWindow = null;
|
||||
}
|
||||
} else {
|
||||
const newSession = await this.call(
|
||||
this.connection.newSession({
|
||||
@@ -898,6 +912,14 @@ class AcpRuntime {
|
||||
|
||||
private sessionUpdate(notification: SessionNotification): void {
|
||||
if (notification.sessionId !== this.nativeSessionId) return;
|
||||
if (this.replayWindow === "skip") return;
|
||||
if (
|
||||
this.replayWindow === "replay" &&
|
||||
notification.update.sessionUpdate === "user_message_chunk"
|
||||
) {
|
||||
this.reduceUserMessageChunk(notification.update);
|
||||
return;
|
||||
}
|
||||
this.reduceUpdate(notification.update);
|
||||
}
|
||||
|
||||
@@ -911,13 +933,14 @@ class AcpRuntime {
|
||||
this.fallbackChunkIds.clear();
|
||||
return;
|
||||
}
|
||||
// A chunk of one kind ends the other kind's item, whatever its content
|
||||
// or id: reasoning, then text, then reasoning again are three items.
|
||||
// A chunk of one kind ends every other kind's open item, whatever its
|
||||
// content or id: reasoning, then text, then reasoning again are three items.
|
||||
this.fallbackChunkIds.delete(
|
||||
update.sessionUpdate === "agent_message_chunk"
|
||||
? "agent_thought_chunk"
|
||||
: "agent_message_chunk",
|
||||
);
|
||||
this.fallbackChunkIds.delete("user_message_chunk");
|
||||
if (update.content.type !== "text") return;
|
||||
const id = this.resolveChunkId(update.sessionUpdate, update.messageId);
|
||||
const text = `${this.messages.get(id) ?? ""}${update.content.text}`;
|
||||
@@ -945,8 +968,31 @@ class AcpRuntime {
|
||||
this.reduceStateUpdate(update);
|
||||
}
|
||||
|
||||
// Historical user messages arrive only as replayed chunks; a live prompt
|
||||
// already echoes its own user message when it is admitted.
|
||||
private reduceUserMessageChunk(
|
||||
update: Extract<SessionUpdate, { sessionUpdate: "user_message_chunk" }>,
|
||||
): void {
|
||||
this.fallbackChunkIds.delete("agent_message_chunk");
|
||||
this.fallbackChunkIds.delete("agent_thought_chunk");
|
||||
if (update.content.type !== "text") return;
|
||||
const id = this.resolveChunkId("user_message_chunk", update.messageId);
|
||||
const text = `${this.messages.get(id) ?? ""}${update.content.text}`;
|
||||
this.messages.set(id, text);
|
||||
this.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: this.options.boundarySessionId,
|
||||
item: {
|
||||
type: "user_message",
|
||||
id,
|
||||
text,
|
||||
...(update.messageId ? { messageId: update.messageId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private resolveChunkId(
|
||||
kind: "agent_message_chunk" | "agent_thought_chunk",
|
||||
kind: "agent_message_chunk" | "agent_thought_chunk" | "user_message_chunk",
|
||||
messageId: string | null | undefined,
|
||||
): string {
|
||||
if (messageId) {
|
||||
|
||||
@@ -1500,3 +1500,187 @@ describe("ACP streamed message boundaries", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function userChunk(text: string, messageId?: string): SessionUpdate {
|
||||
return {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text },
|
||||
...(messageId === undefined ? {} : { messageId }),
|
||||
};
|
||||
}
|
||||
|
||||
async function openReplaySession(
|
||||
replay: SessionUpdate[],
|
||||
history: "replay" | "skip",
|
||||
): Promise<{ connection: ProviderConnection; events: ProviderEvent[] }> {
|
||||
const harness = connectorHarness({
|
||||
capabilities: { loadSession: true },
|
||||
handleRequest(instance, request) {
|
||||
if (request.method !== "session/load") return false;
|
||||
for (const update of replay) {
|
||||
instance.notify("session/update", { sessionId: "connector-session", update });
|
||||
}
|
||||
instance.respond(request, {
|
||||
sessionId: "connector-session",
|
||||
modes: null,
|
||||
configOptions: [],
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const registration = runAcpProvider({
|
||||
id: "replay-acp",
|
||||
label: "Replay ACP",
|
||||
connector: harness.connector,
|
||||
});
|
||||
const connection = await registration.connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message", "session.persistence"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
const input = openInput();
|
||||
input.persistence = { version: 1, data: { sessionId: "connector-session" } };
|
||||
input.history = history;
|
||||
await connection.send(input);
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
return { connection, events };
|
||||
}
|
||||
|
||||
function timelineProjection(events: ProviderEvent[]): Array<Record<string, string>> {
|
||||
return events.flatMap((event) => {
|
||||
if (event.type !== "timeline.item") return [];
|
||||
const item = event.item;
|
||||
if (
|
||||
item.type === "user_message" ||
|
||||
item.type === "assistant_message" ||
|
||||
item.type === "reasoning"
|
||||
) {
|
||||
return [{ type: item.type, id: item.id, text: item.text }];
|
||||
}
|
||||
if (item.type === "tool_call") return [{ type: item.type, id: item.id, status: item.status }];
|
||||
return [{ type: item.type, id: item.id }];
|
||||
});
|
||||
}
|
||||
|
||||
describe("ACP session history replay", () => {
|
||||
it("restores the timeline replayed before session/load resolves", async () => {
|
||||
const { connection, events } = await openReplaySession(
|
||||
[
|
||||
userChunk("What is the ", "user-1"),
|
||||
userChunk("temperature?", "user-1"),
|
||||
textChunk("Checking the sensor", "agent_thought_chunk"),
|
||||
{
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "tool-1",
|
||||
title: "Read sensor",
|
||||
kind: "read",
|
||||
status: "in_progress",
|
||||
},
|
||||
{ sessionUpdate: "tool_call_update", toolCallId: "tool-1", status: "completed" },
|
||||
textChunk("It is 25°C"),
|
||||
],
|
||||
"replay",
|
||||
);
|
||||
try {
|
||||
expect(timelineProjection(events)).toEqual([
|
||||
{ type: "user_message", id: "user-1", text: "What is the " },
|
||||
{ type: "user_message", id: "user-1", text: "What is the temperature?" },
|
||||
{ type: "reasoning", id: "agent_thought_chunk:1", text: "Checking the sensor" },
|
||||
{ type: "tool_call", id: "tool-1", status: "running" },
|
||||
{ type: "tool_call", id: "tool-1", status: "completed" },
|
||||
{ type: "assistant_message", id: "agent_message_chunk:2", text: "It is 25°C" },
|
||||
]);
|
||||
const lastReplayed = events.findLastIndex((event) => event.type === "timeline.item");
|
||||
const opened = events.findIndex((event) => event.type === "session.opened");
|
||||
expect(lastReplayed).toBeLessThan(opened);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("continues anonymous user chunks into one message and starts a new one after an agent reply", async () => {
|
||||
const { connection, events } = await openReplaySession(
|
||||
[
|
||||
userChunk("first "),
|
||||
userChunk("question"),
|
||||
textChunk("answer"),
|
||||
userChunk("second question"),
|
||||
],
|
||||
"replay",
|
||||
);
|
||||
try {
|
||||
expect(timelineProjection(events)).toEqual([
|
||||
{ type: "user_message", id: "user_message_chunk:1", text: "first " },
|
||||
{ type: "user_message", id: "user_message_chunk:1", text: "first question" },
|
||||
{ type: "assistant_message", id: "agent_message_chunk:2", text: "answer" },
|
||||
{ type: "user_message", id: "user_message_chunk:3", text: "second question" },
|
||||
]);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not duplicate the live prompt echo when the agent echoes it as a user chunk", async () => {
|
||||
const harness = connectorHarness({
|
||||
capabilities: { loadSession: true },
|
||||
promptUpdates: (promptText) => [
|
||||
{ sessionUpdate: "user_message_chunk", content: { type: "text", text: promptText } },
|
||||
{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Acknowledged" } },
|
||||
],
|
||||
});
|
||||
const registration = runAcpProvider({
|
||||
id: "echo-acp",
|
||||
label: "Echo ACP",
|
||||
connector: harness.connector,
|
||||
});
|
||||
const connection = await registration.connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message", "session.persistence"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
const input = openInput();
|
||||
input.persistence = { version: 1, data: { sessionId: "connector-session" } };
|
||||
input.history = "replay";
|
||||
await connection.send(input);
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
|
||||
await connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "session-1",
|
||||
prompt: {
|
||||
clientMessageId: "echo-1",
|
||||
delivery: "auto",
|
||||
input: { type: "message", content: [{ type: "text", text: "hello" }] },
|
||||
},
|
||||
});
|
||||
try {
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) =>
|
||||
event.type === "session.turn" &&
|
||||
event.turnId === "acp:echo-1" &&
|
||||
event.state === "completed",
|
||||
);
|
||||
expect(timelineProjection(events)).toEqual([
|
||||
{ type: "user_message", id: "echo-1", text: "hello" },
|
||||
{ type: "assistant_message", id: "agent_message_chunk:1", text: "Acknowledged" },
|
||||
]);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops replayed updates when the open request asks to skip history", async () => {
|
||||
const { connection, events } = await openReplaySession(
|
||||
[userChunk("What is in the README?", "user-1"), textChunk("It describes the project.")],
|
||||
"skip",
|
||||
);
|
||||
try {
|
||||
expect(timelineProjection(events)).toEqual([]);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user