mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 10:42:45 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7db5385a96 | ||
|
|
686588c44b | ||
|
|
48623801a1 | ||
|
|
632a7f8859 | ||
|
|
48d2c9ec36 | ||
|
|
ca894d49a5 |
@@ -242,7 +242,7 @@ parentAgentId === thisAgent.id AND !archivedAt
|
||||
|
||||
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
|
||||
|
||||
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Every subagent pane links back to its parent (`packages/app/src/subagents/parent/`): a pill above the composer when there is one, a parent box in the composer's place when there is not. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
|
||||
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
|
||||
|
||||
|
||||
@@ -99,6 +99,16 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
await expectSubagentRowVisible(page, child.id);
|
||||
});
|
||||
|
||||
await test.step("the parent box in a subagent's sheet closes it onto the parent", async () => {
|
||||
await page.getByTestId(`subagents-track-row-${child.id}`).click();
|
||||
const childSheet = sheet(page, child.title);
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
await expect(childSheet.getByTestId("composer-parent-agent-pill")).toHaveCount(0);
|
||||
await childSheet.getByTestId("parent-agent-callout-open").click();
|
||||
await expect(childSheet).toHaveCount(0);
|
||||
await expectOpenTabCount(page, 1);
|
||||
});
|
||||
|
||||
await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => {
|
||||
await page.getByTestId(`subagents-track-row-${child.id}`).click();
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -136,7 +136,8 @@ test.describe("real provider subagent timelines", () => {
|
||||
panel.getByText("Start chatting with this agent...", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.getByTestId(`workspace-tab-agent_${handle.agentId}`).first().click();
|
||||
// A provider subagent has no composer; its callout is the way back to the parent.
|
||||
await page.getByTestId("parent-agent-callout-open").click();
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({ hasText: "ROOT_DONE" }).last(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,29 @@ test("opening an already-tabbed subagent reveals its left tab without a duplicat
|
||||
}
|
||||
});
|
||||
|
||||
test("a subagent's parent pill returns to its parent", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "subagent-parent-pill-" });
|
||||
try {
|
||||
const pair = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Refactor auth flow",
|
||||
childTitle: "Write session tests",
|
||||
});
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, { workspaceId: pair.workspaceId, agentId: pair.child.id });
|
||||
|
||||
const pill = page.getByTestId("composer-parent-agent-pill");
|
||||
await expect(pill).toHaveAccessibleName("Back to Refactor auth flow");
|
||||
await pill.click();
|
||||
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${pair.parent.id}`)).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a preferred chat file link still opens beside its right-pane agent", async ({ page }) => {
|
||||
const target = "linked.ts:3";
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -276,7 +276,11 @@ export function ComposerTrackRow({
|
||||
* knockout for marks that sit on top of an icon, and nothing sits under this one, so it comes off
|
||||
* too — leaving every mark's visible edge on the same rail whatever state it is in.
|
||||
*/
|
||||
function ComposerTrackMark({ bucket }: { bucket: SidebarStateBucket | null }): ReactElement | null {
|
||||
export function ComposerTrackMark({
|
||||
bucket,
|
||||
}: {
|
||||
bucket: SidebarStateBucket | null;
|
||||
}): ReactElement | null {
|
||||
if (!bucket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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: "حدّث المضيفين لترتيب مساحات العمل",
|
||||
},
|
||||
@@ -1907,6 +1908,13 @@ export const ar: TranslationResources = {
|
||||
backdrop: "خلفية القائمة",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "العودة إلى الوكيل الأصلي",
|
||||
backTo: "العودة إلى {{label}}",
|
||||
parent: "الأصل",
|
||||
parentAgent: "الوكيل الأصلي",
|
||||
openParent: "فتح الوكيل الأصلي",
|
||||
},
|
||||
title: "الوكلاء الفرعيون",
|
||||
pillLabelOne: "وكيل فرعي واحد",
|
||||
pillLabelMany: "{{count}} وكلاء فرعيين",
|
||||
|
||||
@@ -1177,6 +1177,7 @@ export const en = {
|
||||
sort: {
|
||||
label: "Sort",
|
||||
created: "Created",
|
||||
manual: "Manual",
|
||||
lastPrompt: "Last prompt",
|
||||
updateHost: "Update hosts to sort workspaces",
|
||||
},
|
||||
@@ -1932,6 +1933,13 @@ export const en = {
|
||||
backdrop: "Menu backdrop",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Back to parent",
|
||||
backTo: "Back to {{label}}",
|
||||
parent: "Parent",
|
||||
parentAgent: "Parent agent",
|
||||
openParent: "Open parent",
|
||||
},
|
||||
title: "Subagents",
|
||||
pillLabelOne: "1 subagent",
|
||||
pillLabelMany: "{{count}} subagents",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
@@ -1955,6 +1956,13 @@ export const es: TranslationResources = {
|
||||
backdrop: "Fondo del menú",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Volver al padre",
|
||||
backTo: "Volver a {{label}}",
|
||||
parent: "Padre",
|
||||
parentAgent: "Agente padre",
|
||||
openParent: "Abrir padre",
|
||||
},
|
||||
title: "Subagentes",
|
||||
pillLabelOne: "1 subagente",
|
||||
pillLabelMany: "{{count}} subagentes",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
@@ -1953,6 +1954,13 @@ export const fr: TranslationResources = {
|
||||
backdrop: "Arrière-plan du menu",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Retour au parent",
|
||||
backTo: "Retour à {{label}}",
|
||||
parent: "Parent",
|
||||
parentAgent: "Agent parent",
|
||||
openParent: "Ouvrir le parent",
|
||||
},
|
||||
title: "Sous-agents",
|
||||
pillLabelOne: "1 sous-agent",
|
||||
pillLabelMany: "{{count}} sous-agents",
|
||||
|
||||
@@ -1182,6 +1182,7 @@ export const ja: TranslationResources = {
|
||||
sort: {
|
||||
label: "並び順",
|
||||
created: "作成日時",
|
||||
manual: "手動",
|
||||
lastPrompt: "最後のプロンプト",
|
||||
updateHost: "ワークスペースを並べ替えるにはホストを更新してください",
|
||||
},
|
||||
@@ -1926,6 +1927,13 @@ export const ja: TranslationResources = {
|
||||
backdrop: "メニューの背景",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "親に戻る",
|
||||
backTo: "{{label}} に戻る",
|
||||
parent: "親",
|
||||
parentAgent: "親エージェント",
|
||||
openParent: "親を開く",
|
||||
},
|
||||
title: "サブエージェント",
|
||||
pillLabelOne: "サブエージェント 1 件",
|
||||
pillLabelMany: "サブエージェント {{count}} 件",
|
||||
|
||||
@@ -1176,6 +1176,7 @@ export const ko: TranslationResources = {
|
||||
sort: {
|
||||
label: "정렬",
|
||||
created: "생성일",
|
||||
manual: "수동",
|
||||
lastPrompt: "마지막 프롬프트",
|
||||
updateHost: "작업 공간을 정렬하려면 호스트를 업데이트하세요",
|
||||
},
|
||||
@@ -1917,6 +1918,13 @@ export const ko: TranslationResources = {
|
||||
backdrop: "메뉴 배경",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "상위 에이전트로 돌아가기",
|
||||
backTo: "{{label}}(으)로 돌아가기",
|
||||
parent: "상위",
|
||||
parentAgent: "상위 에이전트",
|
||||
openParent: "상위 에이전트 열기",
|
||||
},
|
||||
title: "하위 에이전트",
|
||||
pillLabelOne: "하위 에이전트 1개",
|
||||
pillLabelMany: "하위 에이전트 {{count}}개",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
@@ -1940,6 +1941,13 @@ export const ptBR: TranslationResources = {
|
||||
backdrop: "Fundo do menu",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Voltar ao pai",
|
||||
backTo: "Voltar para {{label}}",
|
||||
parent: "Pai",
|
||||
parentAgent: "Agente pai",
|
||||
openParent: "Abrir pai",
|
||||
},
|
||||
title: "Subagentes",
|
||||
pillLabelOne: "1 subagente",
|
||||
pillLabelMany: "{{count}} subagentes",
|
||||
|
||||
@@ -1186,6 +1186,7 @@ export const ru: TranslationResources = {
|
||||
sort: {
|
||||
label: "Сортировка",
|
||||
created: "Дата создания",
|
||||
manual: "Вручную",
|
||||
lastPrompt: "Последний запрос",
|
||||
updateHost: "Обновите хосты для сортировки рабочих пространств",
|
||||
},
|
||||
@@ -1939,6 +1940,13 @@ export const ru: TranslationResources = {
|
||||
backdrop: "Фон меню",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Назад к родителю",
|
||||
backTo: "Назад к {{label}}",
|
||||
parent: "Родитель",
|
||||
parentAgent: "Родительский агент",
|
||||
openParent: "Открыть родителя",
|
||||
},
|
||||
title: "Субагенты",
|
||||
pillLabelOne: "1 субагент",
|
||||
pillLabelMany: "Субагентов: {{count}}",
|
||||
|
||||
@@ -1161,6 +1161,7 @@ export const zhCN: TranslationResources = {
|
||||
sort: {
|
||||
label: "排序",
|
||||
created: "创建时间",
|
||||
manual: "手动",
|
||||
lastPrompt: "最近提示词",
|
||||
updateHost: "请更新主机以排序工作区",
|
||||
},
|
||||
@@ -1885,6 +1886,13 @@ export const zhCN: TranslationResources = {
|
||||
backdrop: "菜单背景",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "返回父代理",
|
||||
backTo: "返回 {{label}}",
|
||||
parent: "父代理",
|
||||
parentAgent: "父代理",
|
||||
openParent: "打开父代理",
|
||||
},
|
||||
title: "Subagent",
|
||||
pillLabelOne: "1 个 subagent",
|
||||
pillLabelMany: "{{count}} 个 subagent",
|
||||
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
||||
import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
|
||||
import { AgentTracks, hasAgentTracks } from "@/panels/agent-tracks";
|
||||
import { ParentAgentCallout, useParentAgentId } from "@/subagents/parent";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
@@ -1109,7 +1110,11 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
});
|
||||
const hasPluginComposerPills = useHasPluginComposerPills(serverId, workspaceId, agentId);
|
||||
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
|
||||
const parentAgentId = useParentAgentId(serverId, agentId);
|
||||
// With a composer the parent is a pill above it; without one, the box takes the composer's place.
|
||||
const parentPillAgentId = showsComposer ? parentAgentId : null;
|
||||
const hasVisibleAgentTracks = hasAgentTracks({
|
||||
parentAgentId: parentPillAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus: archiveFinishedSubagents.status,
|
||||
@@ -1161,6 +1166,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
composerState,
|
||||
],
|
||||
);
|
||||
const parentCallout = parentAgentId ? (
|
||||
<ParentAgentCallout serverId={serverId} parentAgentId={parentAgentId} />
|
||||
) : null;
|
||||
const composerSection = showsComposer ? (
|
||||
<RenderProfile id={`AgentComposerSection:${agentId}`}>
|
||||
<AgentComposerSection
|
||||
@@ -1179,7 +1187,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
onMessageSent={handleMessageSent}
|
||||
/>
|
||||
</RenderProfile>
|
||||
) : null;
|
||||
) : (
|
||||
parentCallout
|
||||
);
|
||||
const streamContent = (
|
||||
<View style={animatedStaticStyles.content}>
|
||||
{showsComposer ? null : <SheetAgentFacts serverId={serverId} agentId={agentId} />}
|
||||
@@ -1203,6 +1213,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={agentId}
|
||||
parentAgentId={parentPillAgentId}
|
||||
subagentRows={subagentRows}
|
||||
tasks={tasks}
|
||||
archiveFinishedStatus={archiveFinishedSubagents.status}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useSubagentOpensInSheet,
|
||||
type SubagentRow,
|
||||
} from "@/subagents";
|
||||
import { ParentAgentPill } from "@/subagents/parent";
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
import type { TodoEntry } from "@/types/stream";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
@@ -34,6 +35,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
parentAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
@@ -43,6 +45,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
parentAgentId: string | null;
|
||||
subagentRows: SubagentRow[];
|
||||
tasks: TodoEntry[] | undefined;
|
||||
archiveFinishedStatus: ArchiveFinishedStatus;
|
||||
@@ -88,8 +91,12 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
[openSubagentTarget, serverId, workspaceId],
|
||||
);
|
||||
const handleOpenProviderSubagent = useCallback(
|
||||
(parentAgentId: string, subagentId: string) => {
|
||||
openSubagentTarget({ kind: "provider_subagent", parentAgentId, subagentId });
|
||||
(ownerAgentId: string, subagentId: string) => {
|
||||
openSubagentTarget({
|
||||
kind: "provider_subagent",
|
||||
parentAgentId: ownerAgentId,
|
||||
subagentId,
|
||||
});
|
||||
},
|
||||
[openSubagentTarget],
|
||||
);
|
||||
@@ -108,6 +115,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
if (
|
||||
!hasWorkspaceDiffStat &&
|
||||
!hasAgentTracks({
|
||||
parentAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
@@ -119,6 +127,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
|
||||
return (
|
||||
<ComposerTrackBar>
|
||||
{parentAgentId ? <ParentAgentPill serverId={serverId} parentAgentId={parentAgentId} /> : null}
|
||||
<AgentTaskList tasks={tasks} />
|
||||
<SubagentsTrack
|
||||
serverId={serverId}
|
||||
@@ -147,17 +156,20 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
});
|
||||
|
||||
export function hasAgentTracks({
|
||||
parentAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
hasPluginComposerPills = false,
|
||||
}: {
|
||||
parentAgentId: string | null;
|
||||
subagentRows: readonly SubagentRow[];
|
||||
tasks: readonly TodoEntry[] | undefined;
|
||||
archiveFinishedStatus: ArchiveFinishedStatus;
|
||||
hasPluginComposerPills?: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
parentAgentId !== null ||
|
||||
subagentRows.length > 0 ||
|
||||
Boolean(tasks?.length) ||
|
||||
archiveFinishedStatus.kind !== "idle" ||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { usePaneContext } from "@/panels/pane-context";
|
||||
import { PaneSubtitle } from "@/panels/pane-subtitle";
|
||||
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { ParentAgentCallout } from "@/subagents/parent";
|
||||
import { type SubagentRow, useSubagentsForParent } from "@/subagents/select";
|
||||
import { useSubagentOpensInSheet } from "@/subagents/opens-in-sheet";
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
@@ -253,33 +254,37 @@ function ProviderSubagentPanel() {
|
||||
return (
|
||||
<View style={styles.container} testID="provider-subagent-panel">
|
||||
{subtitle ? <PaneSubtitle text={subtitle} testID="provider-subagent-pane-subtitle" /> : null}
|
||||
<AgentStreamView
|
||||
agentId={streamId}
|
||||
serverId={serverId}
|
||||
context={streamContext}
|
||||
streamItems={timeline?.tail ?? EMPTY_STREAM_ITEMS}
|
||||
streamHead={timeline?.head ?? EMPTY_STREAM_ITEMS}
|
||||
turnPresentation={turnPresentation}
|
||||
pendingPermissions={EMPTY_PERMISSIONS}
|
||||
isAuthoritativeHistoryReady
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
readOnly
|
||||
historyPagination={historyPagination}
|
||||
bottomOverlayTailClearance={childTrackClearance.tail}
|
||||
bottomOverlayControlClearance={childTrackClearance.controls}
|
||||
/>
|
||||
<ProviderSubagentChildTrack
|
||||
serverId={serverId}
|
||||
rows={childRows}
|
||||
onOpenProviderSubagent={openProviderChild}
|
||||
opensInSheet={opensInSheet}
|
||||
/>
|
||||
<View style={styles.stream}>
|
||||
<AgentStreamView
|
||||
agentId={streamId}
|
||||
serverId={serverId}
|
||||
context={streamContext}
|
||||
streamItems={timeline?.tail ?? EMPTY_STREAM_ITEMS}
|
||||
streamHead={timeline?.head ?? EMPTY_STREAM_ITEMS}
|
||||
turnPresentation={turnPresentation}
|
||||
pendingPermissions={EMPTY_PERMISSIONS}
|
||||
isAuthoritativeHistoryReady
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
readOnly
|
||||
historyPagination={historyPagination}
|
||||
bottomOverlayTailClearance={childTrackClearance.tail}
|
||||
bottomOverlayControlClearance={childTrackClearance.controls}
|
||||
/>
|
||||
<ProviderSubagentChildTrack
|
||||
serverId={serverId}
|
||||
rows={childRows}
|
||||
onOpenProviderSubagent={openProviderChild}
|
||||
opensInSheet={opensInSheet}
|
||||
/>
|
||||
</View>
|
||||
<ParentAgentCallout serverId={serverId} parentAgentId={target.parentAgentId} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: { flex: 1, minHeight: 0 },
|
||||
stream: { flex: 1, minHeight: 0 },
|
||||
unsupported: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
|
||||
unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" },
|
||||
}));
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CornerUpLeft } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ComposerTrackMark } from "@/composer/tracks";
|
||||
import { FOOTER_HEIGHT } from "@/constants/layout";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useParentAgent, type ParentAgent } from "./use-parent-agent";
|
||||
|
||||
function ParentIcon({
|
||||
icon: Icon,
|
||||
size,
|
||||
color = "",
|
||||
}: {
|
||||
icon: ParentAgent["icon"];
|
||||
size: number;
|
||||
color?: string;
|
||||
}) {
|
||||
return <Icon size={size} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedParentIcon = withUnistyles(ParentIcon);
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
/** Stands in the composer's place on a subagent shown without one: who the parent is, and the way back. */
|
||||
export function ParentAgentCallout({
|
||||
serverId,
|
||||
parentAgentId,
|
||||
}: {
|
||||
serverId: string;
|
||||
parentAgentId: string;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const parent = useParentAgent(serverId, parentAgentId);
|
||||
|
||||
return (
|
||||
<View style={styles.rail}>
|
||||
<View style={styles.callout} testID="parent-agent-callout">
|
||||
<View style={styles.avatar}>
|
||||
<ThemedParentIcon icon={parent.icon} size={18} uniProps={foregroundColorMapping} />
|
||||
</View>
|
||||
<View style={styles.body}>
|
||||
<Text style={styles.eyebrow}>{t("subagents.parent.parent")}</Text>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{parent.label ?? t("subagents.parent.parentAgent")}
|
||||
</Text>
|
||||
<ComposerTrackMark bucket={parent.statusBucket} />
|
||||
</View>
|
||||
</View>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
leftIcon={CornerUpLeft}
|
||||
onPress={parent.open}
|
||||
testID="parent-agent-callout-open"
|
||||
>
|
||||
{t("subagents.parent.openParent")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
rail: {
|
||||
minHeight: FOOTER_HEIGHT,
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
callout: {
|
||||
width: "100%",
|
||||
maxWidth: theme.contentMaxWidth,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
padding: theme.spacing[3],
|
||||
paddingRight: { xs: theme.spacing[3], md: theme.spacing[4] },
|
||||
},
|
||||
avatar: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[0.5],
|
||||
},
|
||||
eyebrow: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
},
|
||||
title: {
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ParentAgentCallout } from "./callout";
|
||||
export { ParentAgentPill } from "./pill";
|
||||
export { useParentAgentId } from "./use-parent-agent";
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { Text } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronLeft } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { composerPillStyles } from "@/composer/pill-styles";
|
||||
import { ComposerTrackMark } from "@/composer/tracks";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useParentAgent } from "./use-parent-agent";
|
||||
|
||||
const ThemedChevronLeft = withUnistyles(ChevronLeft);
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
/**
|
||||
* The way back from a subagent to the agent that started it. It leads the pill rail and names
|
||||
* the parent rather than saying "parent": in a stack of subagents the title is what tells you
|
||||
* where you will land.
|
||||
*/
|
||||
export function ParentAgentPill({
|
||||
serverId,
|
||||
parentAgentId,
|
||||
}: {
|
||||
serverId: string;
|
||||
parentAgentId: string;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const parent = useParentAgent(serverId, parentAgentId);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
||||
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
||||
const bodyStyle = useMemo(
|
||||
() => [composerPillStyles.body, styles.body, isHovered && composerPillStyles.bodyActive],
|
||||
[isHovered],
|
||||
);
|
||||
const labelStyle = useMemo(
|
||||
() => [composerPillStyles.label, styles.label, isHovered && composerPillStyles.labelActive],
|
||||
[isHovered],
|
||||
);
|
||||
const label = parent.label ?? t("subagents.parent.backToParent");
|
||||
|
||||
return (
|
||||
<PressHighlight
|
||||
testID="composer-parent-agent-pill"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("subagents.parent.backTo", { label })}
|
||||
onPress={parent.open}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
style={bodyStyle}
|
||||
highlightStyle={composerPillStyles.bodyActive}
|
||||
>
|
||||
<ThemedChevronLeft
|
||||
size={14}
|
||||
uniProps={isHovered ? foregroundColorMapping : mutedColorMapping}
|
||||
/>
|
||||
<ComposerTrackMark bucket={parent.statusBucket} />
|
||||
<Text style={labelStyle} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</PressHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// The chevron's box is wider than its stroke, so the pill pulls it toward the edge to keep the
|
||||
// visible glyph on the same inset as other pills' leading marks.
|
||||
body: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[1.5],
|
||||
paddingLeft: theme.spacing[2],
|
||||
},
|
||||
label: {
|
||||
flexShrink: 1,
|
||||
maxWidth: { xs: 140, md: 240 },
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAgentDescriptor } from "@/agent-presentation";
|
||||
import type { PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
|
||||
export interface ParentAgent {
|
||||
/** The parent's title, or null while it has none — callers fall back to a generic name. */
|
||||
label: string | null;
|
||||
icon: PanelDescriptor["icon"];
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
open: () => void;
|
||||
}
|
||||
|
||||
/** The parent of a managed agent, or null for a root or detached agent. */
|
||||
export function useParentAgentId(serverId: string, agentId: string): string | null {
|
||||
return useSessionStore((state) => {
|
||||
const session = state.sessions[serverId];
|
||||
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
|
||||
return agent?.parentAgentId ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
/** The parent as its own tab presents it, and the way back to it. */
|
||||
export function useParentAgent(serverId: string, parentAgentId: string): ParentAgent {
|
||||
const descriptor = useAgentDescriptor({ kind: "agent", agentId: parentAgentId }, { serverId });
|
||||
// The parent may live in another workspace; navigateToAgent opens it wherever it is.
|
||||
const open = useCallback(() => {
|
||||
navigateToAgent({ serverId, agentId: parentAgentId });
|
||||
}, [parentAgentId, serverId]);
|
||||
return {
|
||||
label: descriptor.label || null,
|
||||
icon: descriptor.icon,
|
||||
statusBucket: descriptor.statusBucket,
|
||||
open,
|
||||
};
|
||||
}
|
||||
@@ -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