Compare commits

...
Author SHA1 Message Date
Mohamed Boudra e39f914a82 test(app): match the turn collapse reply by its last streamed chunk
Back-to-back text chunks from the mock stream merge into one message depending
on delivery timing, so the final reply and the folded message count vary run
to run.
2026-10-24 08:28:49 +02:00
Mohamed Boudra 953b448774 test(app): seed e2e settings fields without assuming an empty settings blob
The e2e page fixture now stores a chat display before specs run, so seeds that
skipped whenever any settings blob existed never wrote their own field. They now
merge into the blob and skip only once their own field is stored.
2026-10-24 08:11:53 +02:00
4b90b754c0 feat(app): fold finished agent turns behind a summary line
Long agent turns bury the answer under intermediate messages, thoughts and
tool calls. A new Chat display preference (Full / Collapse tool calls /
Collapse turns) replaces the tool call detail level. Collapse turns folds
everything a finished turn did before its final message into one summary
line under the prompt; opening it shows those rows inline behind the same
indent rail that expanded tool call groups now use.

Expanded turns stay real timeline rows so virtualization, chat find and
scroll-to-message keep working; find and outline jumps open the turn that
holds their target. Opening or closing a turn stops following output so the
summary stays under the pointer.

Collapse turns is the default for new installs only. Existing settings were
materialized on first launch, so stored blobs without the new field map from
the legacy tool call detail level and otherwise stay on Full.

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

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

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

Closes #5447

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

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

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

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

---------

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

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: grueneisfabian <174708694+grueneisfabian@users.noreply.github.com>
2026-10-23 23:01:02 +02:00
69 changed files with 1858 additions and 230 deletions
@@ -1415,12 +1415,14 @@ test("collapsing and expanding restores the real horizontal scroll offset", asyn
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
await page.addInitScript(
({ settingsKey, fontSize }) => {
if (localStorage.getItem(settingsKey)) {
const settings = JSON.parse(localStorage.getItem(settingsKey) ?? "{}");
if (settings.codeFontSize !== undefined) {
return;
}
localStorage.setItem(
settingsKey,
JSON.stringify({
...settings,
theme: "dark",
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
@@ -0,0 +1,100 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
delayBrowserAgentCreatedStatus,
openNewWorkspaceComposer,
submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
// The creation response carries the descriptor captured when the workspace was provisioned. With an
// initial agent it is sent only after the agent starts, so a pin made in between (by a plugin's
// workspace.created hook, another client, or the CLI) reaches the app first as a live update. The
// late response must not put the old, unpinned row back.
function pinnedRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
}
async function findCreatedWorkspaceId(seeded: SeededWorkspace): Promise<string> {
let createdId: string | undefined;
await expect
.poll(
async () => {
const { entries } = await seeded.client.fetchWorkspaces({
filter: { projectId: seeded.projectId },
});
createdId = entries.find((entry) => entry.id !== seeded.workspaceId)?.id;
return createdId;
},
{ timeout: 30_000 },
)
.toBeTruthy();
return createdId!;
}
// Samples every frame, so a row that leaves Pinned and comes back on a later update still counts.
async function watchPinnedRow(page: Page, testId: string): Promise<() => Promise<number>> {
await page.evaluate((rowTestId) => {
const state = { missingFrames: 0, stopped: false };
(window as unknown as { __pinnedRowWatch: typeof state }).__pinnedRowWatch = state;
const sample = () => {
if (state.stopped) return;
const section = document.querySelector('[data-testid="sidebar-pinned-section"]');
if (!section?.querySelector(`[data-testid="${rowTestId}"]`)) state.missingFrames += 1;
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
}, testId);
return () =>
page.evaluate(() => {
const state = (
window as unknown as { __pinnedRowWatch: { missingFrames: number; stopped: boolean } }
).__pinnedRowWatch;
state.stopped = true;
return state.missingFrames;
});
}
test.describe("New workspace live pin", () => {
test("keeps a pin made before the creation response arrives", async ({ page }) => {
test.setTimeout(120_000);
const creation = await delayBrowserAgentCreatedStatus(page);
const seeded = await seedWorkspace({ repoPrefix: "new-workspace-live-pin-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, seeded);
await submitNewWorkspacePrompt(page, "Pin me while I start");
await creation.waitForCreateRequest();
// The response is held here, carrying the descriptor from before the pin.
await creation.waitForDelayedCreatedStatus();
const workspaceId = await findCreatedWorkspaceId(seeded);
const rowTestId = pinnedRowTestId(workspaceId);
await seeded.client.setWorkspacePinned(workspaceId, true);
const pinnedRow = page.getByTestId("sidebar-pinned-section").getByTestId(rowTestId);
await expect(pinnedRow).toBeVisible({ timeout: 10_000 });
const stopWatching = await watchPinnedRow(page, rowTestId);
creation.release();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({
timeout: 30_000,
});
// Leave time for the released response to be merged and rendered.
await page.waitForTimeout(1_000);
expect(await stopWatching()).toBe(0);
await expect(pinnedRow).toBeVisible();
} finally {
creation.release();
await seeded.cleanup();
}
});
});
@@ -123,7 +123,7 @@ test("projects and pinned chats reorder while ordinary workspaces stay newest fi
}
});
async function selectWorkspaceSort(page: Page, name: "Created" | "Last prompt") {
async function selectWorkspaceSort(page: Page, name: "Manual" | "Created" | "Last prompt") {
await page.getByLabel("Display preferences", { exact: true }).click();
await page.getByRole("menuitem", { name: /^Sort/ }).click();
await page.getByRole("menuitem", { name, exact: true }).click();
@@ -362,3 +362,60 @@ test("compact groups retain active overflow and reveal the current ordered rows"
await fixture.seed.cleanup();
}
});
async function restorePreBetaOrder(page: Page, projectKey: string, workspaceKeys: string[]) {
await page.evaluate(
({ projectKey: scope, workspaceKeys: order }) => {
localStorage.setItem(
"sidebar-project-workspace-order",
JSON.stringify({
version: 1,
state: {
projectOrder: [scope],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: { [scope]: order },
},
}),
);
},
{ projectKey, workspaceKeys },
);
await reconnectSidebar(page);
}
test("Manual restores pre-beta order and keeps drag order across sorting and reloads", async ({
page,
}) => {
const seed = await seedWorkspace({ repoPrefix: "sidebar-manual-" });
try {
const second = await seed.client.createWorkspace({
source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId },
title: "New manual workspace",
});
if (!second.workspace) throw new Error(second.error ?? "Workspace creation failed");
const keys = [seed.workspaceId, second.workspace.id].map((id) => `${getServerId()}:${id}`);
const ids = keys.map((key) => `sidebar-workspace-row-${key}`);
const rows = page.locator(ids.map((id) => `[data-testid="${id}"]`).join(", "));
await gotoAppShell(page);
await waitForSidebarHydration(page);
await restorePreBetaOrder(page, projectEquivalenceViewKey(seed.projectKey), keys);
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
await selectWorkspaceSort(page, "Manual");
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
await selectWorkspaceSort(page, "Created");
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
await selectWorkspaceSort(page, "Last prompt");
await selectWorkspaceSort(page, "Manual");
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow);
await reconnectSidebar(page);
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow);
await selectWorkspaceSort(page, "Created");
await reconnectSidebar(page);
await selectWorkspaceSort(page, "Manual");
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
} finally {
await seed.cleanup();
}
});
@@ -72,6 +72,9 @@ async function expectSortingRequiresHostUpdate(page: Page) {
).toBeDisabled();
await expect(page.getByRole("menuitem", { name: "Created", exact: true })).toHaveCount(0);
await expect(page.getByRole("menuitem", { name: "Last prompt", exact: true })).toHaveCount(0);
await page.getByRole("menuitem", { name: "Manual", exact: true }).click();
await page.getByLabel("Display preferences", { exact: true }).click();
await expect(page.getByRole("menuitem", { name: /^Sort/ })).toContainText("Manual");
await page.keyboard.press("Escape");
}
@@ -143,7 +143,7 @@ async function configureOverviewToolCalls(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ toolCallDetailLevel: "overview" }),
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
);
});
}
@@ -161,7 +161,7 @@ test("measures an overview heading that becomes loading after its idle mount", a
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ toolCallDetailLevel: "overview" }),
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
);
});
const agent = await seedMockAgentWorkspace({
@@ -0,0 +1,67 @@
import type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns";
async function useChatDisplay(page: Page, chatDisplay: ChatDisplay): Promise<void> {
await page.addInitScript((value) => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: value }));
}, chatDisplay);
}
async function seedAnsweredAgent(title: string) {
const agent = await seedMockAgentWorkspace({
repoPrefix: "turn-collapse-",
title,
model: "ten-second-stream",
initialPrompt: "Why does the chat stop following new output?",
});
await agent.client.waitForFinish(agent.agentId, 30_000);
await agent.client.sendAgentMessage(agent.agentId, "Make the fix and run the burst script.");
await agent.client.waitForFinish(agent.agentId, 30_000);
return agent;
}
/** Chat rows between the latest turn's summary line and its reply, in document order. */
async function rowsBetweenLatestSummaryAndReply(page: Page): Promise<number> {
return page.evaluate(() => {
const rows = [...document.querySelectorAll("[data-message-id]")];
const summaryRow = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]"));
const replyRow = rows.findLastIndex((row) =>
row.querySelector("[data-testid=assistant-message]"),
);
return replyRow - summaryRow - 1;
});
}
test.describe("Turn collapse", () => {
test("folds each answered turn behind a summary line that opens it", async ({ page }) => {
test.setTimeout(120_000);
await useChatDisplay(page, "collapseTurns");
const agent = await seedAnsweredAgent("Turn collapse");
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const summary = page.getByTestId("turn-summary").last().getByRole("button");
await expect(summary).toHaveText(/^Wrote \d+ messages?, edited 1 file/);
// Text chunks that stream back to back merge into one message, so the reply holds
// at least the stream's last chunk.
await expect(page.getByTestId("assistant-message").last()).toHaveText(
/\(end of synthetic stream\)$/,
);
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await summary.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0);
await expect(summary).toBeInViewport();
await summary.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
} finally {
await agent.cleanup();
}
});
});
@@ -0,0 +1,40 @@
// Open a connected host with the wide sidebar visible, keyboard closed, and
// three-button navigation enabled on an Android tablet. Run against a fresh build:
// node packages/app/e2e/mobile/sidebar-system-insets/android.mjs <session> <adb-serial>
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
const [, , session, serial] = process.argv;
assert(session && serial, "Pass the agent-device session and adb serial");
const windowState = execFileSync("adb", ["-s", serial, "shell", "dumpsys", "window"], {
encoding: "utf8",
timeout: 10_000,
});
const navBar = windowState.match(/type=navigationBars frame=\[0,(\d+)\]\[\d+,\d+\] visible=true/);
assert(navBar, "A visible bottom navigation bar is required");
assert(!/type=ime .*visible=true/.test(windowState), "Close the keyboard first");
const navigationTop = Number(navBar[1]);
const { data } = JSON.parse(
execFileSync("agent-device", ["snapshot", "--raw", "--json", "--session", session], {
encoding: "utf8",
timeout: 60_000,
}),
);
const focusedApp = windowState.match(/mCurrentFocus=.*? u\d+ ([^/\s]+)\//)?.[1];
assert.equal(
focusedApp,
data.appBundleId,
"Snapshot must belong to the focused app on this device",
);
for (const id of [
"sidebar-add-project",
"sidebar-hosts-trigger",
"sidebar-help",
"sidebar-settings",
]) {
const button = data.nodes.find((node) => node.identifier === id);
assert(button?.rect, `${id} must be visible above the system navigation bar`);
const bottom = button.rect.y + button.rect.height;
assert(bottom <= navigationTop, `${id} ends at ${bottom}, below navigation top ${navigationTop}`);
console.log(`PASS: ${id} bottom=${bottom}, navigationTop=${navigationTop}`);
}
+5
View File
@@ -180,6 +180,11 @@ const test = daemonTest.extend<{
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon, ...extraHosts]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
// New installs collapse finished turns; specs assert on the rows inside them, so they
// start from the full chat unless they seed their own settings.
if (!localStorage.getItem("@paseo:app-settings")) {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: "full" }));
}
},
{
daemon: testDaemon,
@@ -116,7 +116,7 @@ export async function withTimelinePlugin(
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ toolCallDetailLevel: "overview" }),
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
);
});
const agent = await seedMockAgentWorkspace({
@@ -63,8 +63,9 @@ export async function seedSidebarFooterPreferences(
): Promise<void> {
await page.addInitScript(
({ key, sidebarFooterItems }) => {
if (localStorage.getItem(key)) return;
localStorage.setItem(key, JSON.stringify({ sidebarFooterItems }));
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
if (settings.sidebarFooterItems) return;
localStorage.setItem(key, JSON.stringify({ ...settings, sidebarFooterItems }));
},
{ key: APP_SETTINGS_KEY, sidebarFooterItems: preferences },
);
@@ -24,6 +24,7 @@ function viewport(root: HTMLElement, capture: Capture) {
const handle: StreamViewportHandle = {
scrollToBottom() {},
prepareForViewportChange() {},
holdReadingPosition() {},
scrollToMessage(_messageId, occurrence) {
if (occurrence) capture.occurrence = occurrence;
},
@@ -265,6 +265,7 @@ describe("useChatOutline", () => {
const viewport: StreamViewportHandle = {
scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(),
holdReadingPosition: vi.fn(),
scrollToMessage,
};
const viewportRef = { current: viewport };
@@ -316,6 +317,7 @@ describe("useChatOutline", () => {
current: {
scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(),
holdReadingPosition: vi.fn(),
scrollToMessage,
},
};
@@ -371,6 +373,7 @@ describe("useChatOutline", () => {
const viewport: StreamViewportHandle = {
scrollToBottom: vi.fn(),
prepareForViewportChange: vi.fn(),
holdReadingPosition: vi.fn(),
scrollToMessage,
};
const livePrompt = {
@@ -4,6 +4,7 @@ import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/tim
import type { InstalledPlugin } from "@/plugins/types";
import {
applyStreamEvent,
getStreamItemMessageId,
hydrateStreamState,
type StreamItem,
type ToolCallItem,
@@ -13,7 +14,7 @@ import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { buildAgentStreamRenderModel } from "./model";
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
import { createStreamPresentation } from "./presentation";
const runtime = {
paseo: {},
@@ -97,7 +98,11 @@ function pluginData(items: StreamItem[]): unknown[] {
return items.flatMap((item) => (item.kind === "plugin" ? [item.data] : []));
}
const presentationOptions = { level: "overview" as const, isTurnActive: true };
const presentationOptions = {
display: "collapseToolCalls" as const,
expandedTurnHostIds: new Set<string>(),
isTurnActive: true,
};
function streamHarness(transform?: TimelineItemTransform) {
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
+64 -16
View File
@@ -1,3 +1,4 @@
import type { ChatDisplay } from "@/hooks/use-settings";
import type { TimelineItemTransform } from "@/plugins/timeline/model";
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
import {
@@ -5,23 +6,32 @@ import {
projectToolCallDetailLevel,
type PreparedToolCallHistory,
type ToolCallDetailLevel,
type ToolCallDetailProjection,
} from "@/tool-calls/detail-level/projection";
import type { StreamItem, UserMessageItem } from "@/types/stream";
import { collapseTurns, type TurnCollapse } from "./turn-collapse/model";
interface PresentationInput {
tail: StreamItem[];
head: StreamItem[];
transform: TimelineItemTransform | undefined;
level: ToolCallDetailLevel;
display: ChatDisplay;
/** User messages whose collapsed turn the reader opened. */
expandedTurnHostIds: ReadonlySet<string>;
isTurnActive: boolean;
}
/** Find and scrolling address a plugin row by the source it replaces. */
export function getStreamItemMessageId(item: StreamItem): string {
if (item.kind === "plugin") return item.sourceId ?? item.id;
return item.id;
export interface StreamPresentation extends ToolCallDetailProjection {
turns: Omit<TurnCollapse, "tail">;
}
const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({
tail: [],
isLatestTurnOpen: false,
expandedHostIds: new Set(),
getGroupedCalls: () => undefined,
});
/** Source messages reach plugins before display projection. */
export function createStreamPresentation() {
const userMessageCache = new WeakMap<UserMessageItem, UserMessageItem>();
@@ -47,12 +57,47 @@ export function createStreamPresentation() {
let preparedTail: StreamItem[] | undefined;
let preparedLevel: ToolCallDetailLevel | undefined;
let preparedHistory: PreparedToolCallHistory | null = null;
let collapsedSource: StreamItem[] | undefined;
let collapsedIsLatestTurnOpen = false;
let collapsedExpandedHostIds: ReadonlySet<string> | undefined;
let collapsed: TurnCollapse | undefined;
function presentRows(item: StreamItem): StreamItem[] {
return [item.kind === "user_message" ? presentUserMessage(item) : item];
}
return (input: PresentationInput) => {
function collapse(
projection: ToolCallDetailProjection,
input: PresentationInput,
): StreamPresentation {
if (input.display !== "collapseTurns") {
return { ...projection, turns: NO_COLLAPSED_TURNS };
}
// Live text updates reach the head only, so the folded history is reused until the
// retained rows, the latest turn's liveness, or the reader's expanded turns change.
const isLatestTurnOpen = input.isTurnActive || projection.head.length > 0;
if (
!collapsed ||
collapsedSource !== projection.tail ||
collapsedIsLatestTurnOpen !== isLatestTurnOpen ||
collapsedExpandedHostIds !== input.expandedTurnHostIds
) {
collapsed = collapseTurns({
tail: projection.tail,
isLatestTurnOpen,
expandedHostIds: input.expandedTurnHostIds,
getGroupedCalls: (itemId) => projection.groupsByHostId.get(itemId)?.run.calls,
});
collapsedSource = projection.tail;
collapsedIsLatestTurnOpen = isLatestTurnOpen;
collapsedExpandedHostIds = input.expandedTurnHostIds;
}
const { tail, ...turns } = collapsed;
return { ...projection, tail, turns };
}
return (input: PresentationInput): StreamPresentation => {
const level: ToolCallDetailLevel = input.display === "full" ? "detailed" : "overview";
// Retained history is not reprojected or regrouped on each live text update.
if (historySource !== input.tail || historyTransform !== input.transform) {
historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap<StreamItem>(
@@ -66,17 +111,20 @@ export function createStreamPresentation() {
const blocks = presentRows(item);
head.push(...blocks);
}
if (preparedTail !== historyRows || preparedLevel !== input.level) {
preparedHistory = prepareToolCallHistory(input.level, historyRows);
if (preparedTail !== historyRows || preparedLevel !== level) {
preparedHistory = prepareToolCallHistory(level, historyRows);
preparedTail = historyRows;
preparedLevel = input.level;
preparedLevel = level;
}
return projectToolCallDetailLevel({
level: input.level,
tail: historyRows,
head,
preparedHistory,
isTurnActive: input.isTurnActive,
});
return collapse(
projectToolCallDetailLevel({
level,
tail: historyRows,
head,
preparedHistory,
isTurnActive: input.isTurnActive,
}),
input,
);
};
}
@@ -361,6 +361,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
bottomAnchorController.prepareForStickyViewportChange();
markNativeViewportSettling();
},
holdReadingPosition: () => bottomAnchorController.detachByUser(),
};
viewportRef.current = handle;
return () => {
@@ -20,7 +20,7 @@ import { withUnistyles } from "react-native-unistyles";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useStableEvent } from "@/hooks/use-stable-event";
import type { StreamItem } from "@/types/stream";
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
@@ -44,7 +44,6 @@ import {
type HistoryStartSettleScheduler,
} from "./history-start-settle-scheduler";
import { useChatFindSelectedMessageId } from "@/agent-stream/chat-find";
import { getStreamItemMessageId } from "./presentation";
import { useScrollToMessage } from "./use-scroll-to-message.web";
interface CreateWebStreamStrategyInput {
@@ -1174,6 +1173,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
scheduleStickToBottom();
},
holdReadingPosition: () => {
cancelPendingStickToBottom();
setFollowOutput(false);
readingAnchor.scroll(scrollContainerRef.current?.scrollTop ?? 0);
},
scrollToMessage,
};
viewportRef.current = handle;
@@ -1186,6 +1190,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [
cancelPendingStickToBottom,
forceStickToBottom,
readingAnchor,
scheduleStickToBottom,
scrollToMessage,
viewportRef,
@@ -55,6 +55,8 @@ export interface StreamViewportHandle {
focus?: () => void;
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
prepareForViewportChange: () => void;
/** Stop following output so the rows on screen stay put while rows below them open or close. */
holdReadingPosition: () => void;
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
}
@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import type { StreamItem, ToolCallItem } from "@/types/stream";
import { collapseTurns, type CollapseTurnsInput } from "./model";
const at = new Date("2026-01-01T00:00:00.000Z");
function user(id: string): StreamItem {
return { kind: "user_message", id, text: id, timestamp: at };
}
function reply(id: string): StreamItem {
return { kind: "assistant_message", id, text: id, timestamp: at };
}
function thought(id: string): StreamItem {
return { kind: "thought", id, text: id, timestamp: at, status: "ready" };
}
function shell(id: string): ToolCallItem {
return {
kind: "tool_call",
id,
timestamp: at,
payload: {
source: "agent",
data: {
provider: "claude",
callId: id,
name: "Bash",
status: "completed",
error: null,
detail: { type: "shell", command: "ls" },
},
},
};
}
function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {}) {
return collapseTurns({
tail,
isLatestTurnOpen: false,
expandedHostIds: new Set(),
getGroupedCalls: () => undefined,
...options,
});
}
const ids = (items: StreamItem[]) => items.map((item) => item.id);
describe("collapseTurns", () => {
it("folds a finished turn's work into its user message and keeps the reply", () => {
const result = collapse([
user("u1"),
thought("t1"),
reply("early"),
shell("c1"),
shell("c2"),
reply("final"),
]);
expect(ids(result.tail)).toEqual(["u1", "final"]);
expect(result.turnsByHostId.get("u1")).toEqual({
hostId: "u1",
expanded: false,
summary: {
messageCount: 1,
tools: expect.objectContaining({ commandCount: 2 }),
},
});
expect(result.hostIdByFoldedMessageId.get("early")).toBe("u1");
});
it("shows an expanded turn's work as nested rows between the prompt and the reply", () => {
const result = collapse([user("u1"), thought("t1"), shell("c1"), reply("final")], {
expandedHostIds: new Set(["u1"]),
});
expect(ids(result.tail)).toEqual(["u1", "t1", "c1", "final"]);
expect([...result.nestedItemIds]).toEqual(["t1", "c1"]);
expect(result.turnsByHostId.get("u1")?.expanded).toBe(true);
});
it("leaves the latest turn alone while it is still producing output", () => {
const tail = [user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")];
const result = collapse(tail, { isLatestTurnOpen: true });
expect(ids(result.tail)).toEqual(["u1", "r1", "u2", "c2", "r2"]);
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
});
it("keeps rows after the reply, and turns with no work before the reply, as they are", () => {
const result = collapse([
user("u1"),
shell("c1"),
reply("r1"),
shell("after"),
user("u2"),
reply("r2"),
]);
expect(ids(result.tail)).toEqual(["u1", "r1", "after", "u2", "r2"]);
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
});
it("does not fold a turn that only thought before its reply", () => {
const tail = [user("u1"), thought("t1"), reply("r1")];
const result = collapse(tail);
expect(result.tail).toBe(tail);
expect(result.turnsByHostId.size).toBe(0);
});
it("does not fold a turn without a reply or whose prompt is above the loaded history", () => {
const tail = [shell("c0"), reply("r0"), user("u1"), shell("c1")];
const result = collapse(tail);
expect(result.tail).toBe(tail);
expect(result.turnsByHostId.size).toBe(0);
});
it("counts every call a tool-call group row stands for", () => {
const group = [shell("c1"), shell("c2"), shell("c3")];
const result = collapse([user("u1"), group[0]!, reply("final")], {
getGroupedCalls: (itemId) => (itemId === "c1" ? group : undefined),
});
expect(result.turnsByHostId.get("u1")?.summary).toMatchObject({
tools: { commandCount: 3 },
});
});
});
@@ -0,0 +1,122 @@
import { getStreamItemMessageId, type StreamItem, type ToolCallItem } from "@/types/stream";
import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model";
export interface TurnSummary {
messageCount: number;
tools: OverviewSummary;
}
export interface CollapsedTurn {
/** The user message the turn answers. Its row carries the turn's summary line. */
hostId: string;
expanded: boolean;
summary: TurnSummary;
}
export interface TurnCollapse {
tail: StreamItem[];
turnsByHostId: ReadonlyMap<string, CollapsedTurn>;
/** Rows of expanded turns, which render inside the turn's indent rail. */
nestedItemIds: ReadonlySet<string>;
/** The turn a folded message belongs to, for surfaces that must reveal it. */
hostIdByFoldedMessageId: ReadonlyMap<string, string>;
}
export interface CollapseTurnsInput {
tail: StreamItem[];
/** The latest turn is still producing output, so it stays unfolded. */
isLatestTurnOpen: boolean;
expandedHostIds: ReadonlySet<string>;
/** The calls a tool-call group row stands for; undefined for a single call. */
getGroupedCalls: (itemId: string) => readonly ToolCallItem[] | undefined;
}
const EMPTY_TURNS: ReadonlyMap<string, CollapsedTurn> = new Map();
const EMPTY_IDS: ReadonlySet<string> = new Set();
const EMPTY_HOSTS: ReadonlyMap<string, string> = new Map();
function summarizeTurn(
items: readonly StreamItem[],
getGroupedCalls: CollapseTurnsInput["getGroupedCalls"],
): TurnSummary {
const calls: ToolCallItem[] = [];
let messageCount = 0;
for (const item of items) {
if (item.kind === "assistant_message") messageCount += 1;
else if (item.kind === "tool_call") calls.push(...(getGroupedCalls(item.id) ?? [item]));
}
return { messageCount, tools: summarizeToolCalls(calls) };
}
function hasWork(summary: TurnSummary): boolean {
return summary.messageCount > 0 || Object.values(summary.tools).some((count) => count > 0);
}
function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number {
for (let index = end - 1; index >= start; index -= 1) {
if (tail[index]?.kind === "assistant_message") return index;
}
return -1;
}
/**
* Folds each answered turn into its user message: everything the agent did before its last
* message collapses into one summary line, and that message stays in the chat. A turn folds
* only once it is complete, its user message is loaded, and it sent messages or called tools
* before its reply. Thoughts fold with the turn but are not counted.
*/
export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
const { tail } = input;
const output: StreamItem[] = [];
const turnsByHostId = new Map<string, CollapsedTurn>();
const nestedItemIds = new Set<string>();
const hostIdByFoldedMessageId = new Map<string, string>();
let runStart = 0;
const flushRun = (runEnd: number) => {
const host = tail[runStart - 1];
const isClosed = runEnd < tail.length || !input.isLatestTurnOpen;
const replyIndex = findLastAssistantIndex(tail, runStart, runEnd);
if (host?.kind !== "user_message" || !isClosed || replyIndex <= runStart) {
output.push(...tail.slice(runStart, runEnd));
return;
}
const folded = tail.slice(runStart, replyIndex);
const summary = summarizeTurn(folded, input.getGroupedCalls);
if (!hasWork(summary)) {
output.push(...tail.slice(runStart, runEnd));
return;
}
const expanded = input.expandedHostIds.has(host.id);
turnsByHostId.set(host.id, { hostId: host.id, expanded, summary });
for (const item of folded) {
hostIdByFoldedMessageId.set(getStreamItemMessageId(item), host.id);
if (expanded) nestedItemIds.add(item.id);
}
if (expanded) output.push(...folded);
output.push(...tail.slice(replyIndex, runEnd));
};
for (let index = 0; index < tail.length; index += 1) {
if (tail[index]?.kind !== "user_message") continue;
flushRun(index);
output.push(tail[index]!);
runStart = index + 1;
}
flushRun(tail.length);
if (turnsByHostId.size === 0) {
return {
tail,
turnsByHostId: EMPTY_TURNS,
nestedItemIds: EMPTY_IDS,
hostIdByFoldedMessageId: EMPTY_HOSTS,
};
}
return {
tail: output.length === tail.length ? tail : output,
turnsByHostId,
nestedItemIds,
hostIdByFoldedMessageId,
};
}
@@ -0,0 +1,48 @@
import { memo, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Summary } from "lucide-react-native";
import { ExpandableBadge } from "@/components/message";
import { useOverviewSummaryLabel } from "@/tool-calls/detail-level/overview/label";
import type { CollapsedTurn } from "./model";
/**
* The row a collapsed turn leaves under its user message, styled like a tool-call group.
* Pressing it opens the turn's rows beneath it.
*/
export const TurnSummaryLine = memo(function TurnSummaryLine({
turn,
onExpandedChange,
}: {
turn: CollapsedTurn;
onExpandedChange: (hostId: string, expanded: boolean) => void;
}) {
const { t } = useTranslation();
const { messageCount, tools } = turn.summary;
const messageParts = useMemo(
() =>
messageCount > 0
? [
t(`turnSummary.messages.${messageCount === 1 ? "one" : "other"}`, {
count: messageCount,
}),
]
: [],
[messageCount, t],
);
const label = useOverviewSummaryLabel(tools, messageParts);
const toggle = useCallback(
() => onExpandedChange(turn.hostId, !turn.expanded),
[onExpandedChange, turn.expanded, turn.hostId],
);
return (
<ExpandableBadge
testID="turn-summary"
label={label}
icon={Summary}
isExpanded={turn.expanded}
onToggle={toggle}
borderlessWhenExpanded
/>
);
});
@@ -1,8 +1,7 @@
import type React from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
import type { Virtualizer } from "@tanstack/react-virtual";
import type { StreamItem } from "@/types/stream";
import { getStreamItemMessageId } from "./presentation";
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle";
import type { ScrollToMessageOccurrence } from "./strategy";
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { StreamItem } from "@/types/stream";
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
import { getStreamItemMessageId } from "./presentation";
// Callers reveal a message, not a row: an assistant message is several block rows and
// the window has to open at the first of them.
+124 -16
View File
@@ -41,7 +41,7 @@ import {
type InlinePathTarget,
} from "@/components/message";
import { PlanCard } from "@/components/plan-card";
import type { StreamItem } from "@/types/stream";
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
import type { PendingMessageSubmission } from "@/composer/submission/model";
import type { TurnPresentation } from "@/timeline/turn-liveness";
import type { PendingPermission } from "@/types/shared";
@@ -62,8 +62,10 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { ToolCallDetailsContent } from "@/components/tool-call-details";
import { QuestionFormCard } from "@/components/question-form-card";
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
import { createStreamPresentation } from "./presentation";
import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view";
import { IndentRail } from "@/components/indent-rail";
import { TurnSummaryLine } from "./turn-collapse/summary-line";
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
@@ -148,9 +150,16 @@ function renderPendingPermissionsNode(input: {
);
}
/**
* Where a row sits in an opened turn: its summary line's row, a row inside the turn, or the
* turn's last row. The rail runs through the gaps between them so the turn reads as one block.
*/
type TurnIndent = "none" | "openedHost" | "nested" | "lastNested";
function renderStreamItemWithTurnFooter(input: {
content: ReactNode;
layoutItem: StreamLayoutItem;
turnIndent: TurnIndent;
strategy: TurnContentStrategy;
supportsTimelineCursor: boolean;
onForkAssistantTurn?: AssistantTurnForkHandler;
@@ -171,7 +180,7 @@ function renderStreamItemWithTurnFooter(input: {
/>
) : null;
const content = (
<StreamItemWrapper itemId={input.layoutItem.item.id} gapBelow={input.layoutItem.gapBelow}>
<StreamItemWrapper gapBelow={input.layoutItem.gapBelow} turnIndent={input.turnIndent}>
{input.content}
</StreamItemWrapper>
);
@@ -333,7 +342,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
) {
const { t } = useTranslation();
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
const chatDisplay = useSettings((settings) => settings.chatDisplay);
const chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled);
const contentMaxWidth = useSettings(resolveContentMaxWidth);
const viewportRef = useRef<StreamViewportHandle | null>(null);
@@ -357,6 +366,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const [expandedToolCallGroupIds, setExpandedToolCallGroupIds] = useState<Set<string>>(
new Set(),
);
const [expandedTurnHostIds, setExpandedTurnHostIds] = useState<ReadonlySet<string>>(new Set());
// Get serverId (fallback to agent's serverId if not provided)
const resolvedServerId = serverId ?? context.serverId ?? "";
@@ -421,6 +431,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
setIsNearBottom(true);
setExpandedInlineToolCallIds(new Set());
setExpandedToolCallGroupIds(new Set());
setExpandedTurnHostIds(new Set());
}, [agentId]);
const isActive = useRetainedPanelActive();
@@ -512,7 +523,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
tail: effectiveStreamItems,
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
transform: transformTimelineItem,
level: toolCallDetailLevel,
display: chatDisplay,
expandedTurnHostIds,
isTurnActive,
}),
[
@@ -520,7 +532,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
effectiveStreamItems,
effectiveStreamHead,
transformTimelineItem,
toolCallDetailLevel,
chatDisplay,
expandedTurnHostIds,
isTurnActive,
],
);
@@ -651,6 +664,43 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[streamRenderStrategy],
);
const setTurnExpanded = useCallback((hostId: string, expanded: boolean) => {
// A turn opens below its summary line; keep that line under the reader's pointer.
viewportRef.current?.holdReadingPosition();
setExpandedTurnHostIds((previous) => {
if (previous.has(hostId) === expanded) return previous;
const next = new Set(previous);
if (expanded) {
next.add(hostId);
} else {
next.delete(hostId);
}
return next;
});
}, []);
// Chat find reveals a match inside a collapsed turn by opening the turn first.
const revealLoadedMessage = useStableEvent((messageId: string): boolean => {
const hostId = presentation.turns.hostIdByFoldedMessageId.get(messageId);
if (hostId && !expandedTurnHostIds.has(hostId)) {
setTurnExpanded(hostId, true);
return true;
}
return revealLoadedHistory(messageId);
});
// Read through stable events so a collapse rerun does not change the renderer identity;
// rows whose turn state changed are revised through `historyRowRevision`.
const getCollapsedTurn = useStableEvent((hostId: string) =>
presentation.turns.turnsByHostId.get(hostId),
);
const getTurnIndent = useStableEvent((layoutItem: StreamLayoutItem): TurnIndent => {
const { nestedItemIds } = presentation.turns;
if (nestedItemIds.has(layoutItem.item.id)) {
const below = layoutItem.belowItem;
return below && nestedItemIds.has(below.id) ? "nested" : "lastNested";
}
return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none";
});
const setToolCallGroupExpanded = useCallback((groupId: string, expanded: boolean) => {
setExpandedToolCallGroupIds((previous) => {
const next = new Set(previous);
@@ -665,7 +715,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const renderUserMessageItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
return (
const collapsedTurn = getCollapsedTurn(item.id);
const message = (
<UserMessage
serverId={resolvedServerId}
agentId={agentId}
@@ -684,8 +735,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}
/>
);
if (!collapsedTurn) {
return message;
}
return (
<>
{message}
<TurnSummaryLine turn={collapsedTurn} onExpandedChange={setTurnExpanded} />
</>
);
},
[context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId],
[
context.capabilities,
agentId,
client,
getCollapsedTurn,
pendingClientMessageIds,
resolvedServerId,
setTurnExpanded,
],
);
const renderAssistantMessageItem = useCallback(
@@ -893,12 +961,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return renderStreamItemWithTurnFooter({
content,
layoutItem,
turnIndent: getTurnIndent(layoutItem),
strategy: streamRenderStrategy,
supportsTimelineCursor: supportsAgentForkContextCursor,
onForkAssistantTurn: readOnly ? undefined : handleForkAssistantTurn,
});
},
[
getTurnIndent,
handleForkAssistantTurn,
readOnly,
renderStreamItemContent,
@@ -1060,13 +1130,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const streamScrollEnabled =
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
expandedInlineToolCallIds.size === 0;
const { nestedItemIds } = presentation.turns;
const historyRowRevision = useMemo(
() => ({
contentById: presentation.historyGroupUpdatesByHostId,
displayStateById: expandedToolCallGroupIds,
displayStateById: {
has: (id: string) =>
expandedToolCallGroupIds.has(id) ||
expandedTurnHostIds.has(id) ||
nestedItemIds.has(id),
},
globalDisplayState: isMobile,
}),
[expandedToolCallGroupIds, isMobile, presentation.historyGroupUpdatesByHostId],
[
expandedToolCallGroupIds,
expandedTurnHostIds,
isMobile,
nestedItemIds,
presentation.historyGroupUpdatesByHostId,
],
);
const findItems = useMemo(
@@ -1080,7 +1162,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
epoch={timelineEpoch}
items={findItems}
viewportRef={viewportRef}
revealLoadedMessage={revealLoadedHistory}
revealLoadedMessage={revealLoadedMessage}
visibleMessageIds={mountedMessageIds}
>
<ToolCallSheetProvider>
@@ -1763,15 +1845,41 @@ const permissionStyles = StyleSheet.create((theme) => ({
}));
interface StreamItemWrapperProps {
itemId: string;
gapBelow: number;
turnIndent: TurnIndent;
children: ReactNode;
}
function StreamItemWrapper({ gapBelow, children }: StreamItemWrapperProps) {
function StreamItemWrapper({ gapBelow, turnIndent, children }: StreamItemWrapperProps) {
const wrapperStyle = useMemo(
() => [stylesheet.streamItemWrapper, { marginBottom: gapBelow }],
[gapBelow],
() => [
stylesheet.streamItemWrapper,
{ marginBottom: turnIndent === "nested" || turnIndent === "openedHost" ? 0 : gapBelow },
],
[gapBelow, turnIndent],
);
return <View style={wrapperStyle}>{children}</View>;
const gapStyle = useMemo(() => ({ paddingBottom: gapBelow }), [gapBelow]);
switch (turnIndent) {
case "none":
return <View style={wrapperStyle}>{children}</View>;
case "openedHost":
return (
<View style={wrapperStyle}>
{children}
<IndentRail style={gapStyle} />
</View>
);
case "nested":
return (
<View style={wrapperStyle}>
<IndentRail style={gapStyle}>{children}</IndentRail>
</View>
);
case "lastNested":
return (
<View style={wrapperStyle}>
<IndentRail>{children}</IndentRail>
</View>
);
}
}
@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
/**
* Collapsed chat rows badge their summary with an icon whose center sits 7px into the content
* column (`ExpandableBadge` bleeds 13px left, then 1px border, 8px padding, 22px icon). The rail
* hangs from that center so opened rows read as children of the line that opened them.
*/
const RAIL_LEFT = 7;
const RAIL_WIDTH = 1;
export const INDENT_RAIL_CONTENT_OFFSET = 21;
/** Indents opened chat rows under the summary that collapsed them, behind a vertical line. */
export function IndentRail({
children,
style,
}: {
children?: ReactNode;
style?: StyleProp<ViewStyle>;
}) {
return <View style={[styles.rail, style]}>{children}</View>;
}
const styles = StyleSheet.create((theme) => ({
rail: {
marginLeft: RAIL_LEFT,
paddingLeft: INDENT_RAIL_CONTENT_OFFSET - RAIL_LEFT - RAIL_WIDTH,
borderLeftWidth: RAIL_WIDTH,
borderLeftColor: theme.colors.border,
},
}));
+5 -2
View File
@@ -719,8 +719,11 @@ function DesktopSidebar({
[active, resizeAnimatedStyle],
);
const desktopSidebarBorderStyle = useMemo(
() => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }],
[insetsTop],
() => [
styles.desktopSidebarBorder,
{ flex: 1, paddingTop: insetsTop, paddingBottom: insets.bottom },
],
[insetsTop, insets.bottom],
);
const sidebarHeaderGroupStyle = useMemo(
() => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome],
+8 -1
View File
@@ -971,6 +971,10 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
},
// Details that do not sit in a card leave the header as a lone highlighted pill.
pressableExpandedDetached: {
borderRadius: theme.borderRadius.full,
},
detailWrapperBorderless: {
borderWidth: 0,
},
@@ -2048,7 +2052,10 @@ export const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.pressable,
isPressed && isInteractive ? expandableBadgeStylesheet.pressablePressed : null,
isExpanded && expandableBadgeStylesheet.pressableExpanded,
isExpanded && !borderlessWhenExpanded && expandableBadgeStylesheet.pressableExpandedAttached,
isExpanded &&
(borderlessWhenExpanded
? expandableBadgeStylesheet.pressableExpandedDetached
: expandableBadgeStylesheet.pressableExpandedAttached),
],
[borderlessWhenExpanded, isExpanded, isInteractive, isPressed],
);
@@ -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>
)}
</>
);
}
@@ -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 {
@@ -42,6 +42,7 @@ import {
saveAppSettings as saveAppSettingsPure,
type AppSettings,
type AppSettingsUpdate,
type ChatDisplay,
type OpenInSidePanePreferences,
type PullRequestOpenLocation,
type DesktopSettingsBridge,
@@ -84,6 +85,7 @@ export {
export type {
AppSettings,
AppSettingsUpdate,
ChatDisplay,
AppLanguage,
OpenInSidePanePreferences,
PullRequestOpenLocation,
@@ -185,6 +185,7 @@ describe("loadAppSettingsFromStorage", () => {
expect(result).toEqual(DEFAULT_CLIENT_SETTINGS);
expect(DEFAULT_CLIENT_SETTINGS.language).toBe("system");
expect(DEFAULT_CLIENT_SETTINGS.chatDisplay).toBe("collapseTurns");
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual(
DEFAULT_CLIENT_SETTINGS,
);
@@ -454,6 +455,7 @@ describe("loadAppSettingsFromStorage", () => {
...DEFAULT_CLIENT_SETTINGS,
theme: "dark",
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
chatDisplay: "full",
});
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual({
manageBuiltInDaemon: false,
@@ -592,6 +594,7 @@ describe("loadSettingsFromStorage", () => {
...DEFAULT_APP_SETTINGS,
theme: "light",
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
chatDisplay: "full",
});
});
@@ -636,6 +639,7 @@ describe("loadSettingsFromStorage", () => {
...DEFAULT_APP_SETTINGS,
theme: "light",
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
chatDisplay: "full",
manageBuiltInDaemon: false,
releaseChannel: "beta",
});
@@ -657,6 +661,7 @@ describe("loadSettingsFromStorage", () => {
...DEFAULT_APP_SETTINGS,
theme: "light",
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
chatDisplay: "full",
});
});
});
@@ -732,7 +737,7 @@ describe("saveAppSettings", () => {
...DEFAULT_CLIENT_SETTINGS,
theme: "light",
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
toolCallDetailLevel: "overview",
chatDisplay: "collapseToolCalls",
});
});
@@ -787,27 +792,45 @@ describe("appearance settings", () => {
expect(result.contentFontSize).toBe(DEFAULT_UI_BASE_FONT_SIZE);
expect(result.codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE);
expect(result.syntaxTheme).toBe("one");
expect(result.toolCallDetailLevel).toBe("detailed");
expect(result.chatDisplay).toBe("full");
});
it("migrates the enabled compact tool call preference to overview", async () => {
it("migrates the enabled compact tool call preference to collapsed tool calls", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ compactToolCalls: true }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview");
expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe("collapseToolCalls");
});
it("clears settings with an unrecognized tool call detail level", async () => {
it.each([
["overview", "collapseToolCalls"],
["concise", "collapseToolCalls"],
["detailed", "full"],
["unknown", "full"],
])("reads the %s tool call detail level as the %s chat display", async (legacy, display) => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "unknown" }),
[APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: legacy }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("detailed");
expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe(display);
});
it("keeps a stored chat display over the tool call detail level it replaced", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({
chatDisplay: "collapseTurns",
toolCallDetailLevel: "detailed",
}),
}),
});
expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe("collapseTurns");
});
it("migrates a switched-off checks row item to the hidden checks display", async () => {
+14 -7
View File
@@ -40,7 +40,8 @@ export type WorkspaceTitleSource = "title" | "branch";
export type PullRequestOpenLocation = "main" | "side" | "explorer";
/** What a sidebar workspace row shows in the space to the right of its title. */
export type SidebarWorkspaceTrailing = "diff" | "timestamp" | "none";
export type ToolCallDetailLevel = "overview" | "detailed";
/** How much of an agent's work the chat shows between the user's message and the reply. */
export type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns";
const ThemePreferenceSchema = z.enum([
...THEME_OPTIONS.map((option) => option.name),
@@ -100,7 +101,7 @@ export interface AppSettings {
/** How usage reads and which windows the sidebar summary shows. */
usage: UsagePreferences;
autoExpandReasoning: boolean;
toolCallDetailLevel: ToolCallDetailLevel;
chatDisplay: ChatDisplay;
chatOutlineEnabled: boolean;
vimKeybindings: boolean;
filePanelAssociations: Record<string, string>;
@@ -156,7 +157,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
sidebarFooterItems: [],
usage: DEFAULT_USAGE_PREFERENCES,
autoExpandReasoning: false,
toolCallDetailLevel: "detailed",
chatDisplay: "collapseTurns",
chatOutlineEnabled: true,
vimKeybindings: false,
filePanelAssociations: {},
@@ -199,6 +200,7 @@ const SidebarRowItemsSchema = z
type StoredAppSettingsFallback = AppSettings & {
uiFontSize?: number;
compactToolCalls?: boolean;
toolCallDetailLevel?: "overview" | "detailed";
manageBuiltInDaemon?: boolean;
releaseChannel?: ReleaseChannel;
needsWrite: boolean;
@@ -251,11 +253,13 @@ const StoredAppSettingsSchema = z
sidebarFooterItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
usage: UsagePreferencesSchema,
autoExpandReasoning: z.boolean().catch(false),
chatDisplay: z.enum(["full", "collapseToolCalls", "collapseTurns"]).optional().catch("full"),
// COMPAT(toolCallDetailLevel): replaced by chatDisplay in v0.13, remove after 2027-04-24.
toolCallDetailLevel: z
.enum(["overview", "detailed"])
.or(z.literal("concise").transform(() => "overview" as const))
.optional()
.catch("detailed"),
.catch(undefined),
// COMPAT(compactToolCalls): migrated in v0.1.105, remove after 2027-01-12.
compactToolCalls: z.boolean().optional().catch(undefined),
chatOutlineEnabled: z.boolean().catch(true),
@@ -305,8 +309,10 @@ const StoredAppSettingsSchema = z
(isChecksHiddenByLegacyRowItem(stored.sidebarRowItems)
? "none"
: DEFAULT_SIDEBAR_CHECKS_DISPLAY);
const toolCallDetailLevel =
stored.toolCallDetailLevel ?? (stored.compactToolCalls ? "overview" : "detailed");
const groupsToolCalls = stored.toolCallDetailLevel
? stored.toolCallDetailLevel === "overview"
: stored.compactToolCalls === true;
const chatDisplay = stored.chatDisplay ?? (groupsToolCalls ? "collapseToolCalls" : "full");
return {
...stored,
openInSidePane,
@@ -321,7 +327,7 @@ const StoredAppSettingsSchema = z
stored.sidebarRowItems.services ??
(stored.sidebarRowItems.scripts === false ? false : DEFAULT_SIDEBAR_ROW_ITEMS.services),
},
toolCallDetailLevel,
chatDisplay,
needsWrite,
};
})
@@ -449,6 +455,7 @@ export function normalizeAppSettings(value: unknown): AppSettings {
manageBuiltInDaemon: _manageBuiltInDaemon,
releaseChannel: _releaseChannel,
compactToolCalls: _compactToolCalls,
toolCallDetailLevel: _toolCallDetailLevel,
uiFontSize: _uiFontSize,
...settings
} = StoredAppSettingsSchema.parse(value);
@@ -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;
+13 -5
View File
@@ -1169,6 +1169,7 @@ export const ar: TranslationResources = {
sort: {
label: "الترتيب",
created: "تاريخ الإنشاء",
manual: "يدوي",
lastPrompt: "آخر طلب",
updateHost: "حدّث المضيفين لترتيب مساحات العمل",
},
@@ -2007,6 +2008,12 @@ export const ar: TranslationResources = {
input: "مدخل",
output: "الإخراج",
},
turnSummary: {
messages: {
one: "كتب {{count}} رسالة",
other: "كتب {{count}} رسائل",
},
},
toolCallGroup: {
editedFiles: {
one: "حرّر {{count}} ملفًا",
@@ -2311,12 +2318,13 @@ export const ar: TranslationResources = {
label: "عرض التفكير دائماً",
description: "إظهار تفكير الوكيل وخطوات الاستدلال بشكل كامل بشكل افتراضي",
},
toolCallDetail: {
label: "عرض استدعاءات الأدوات",
description: "كيفية ظهور استدعاءات الأدوات في المخطط الزمني",
chatDisplay: {
label: "عرض المحادثة",
description: "مقدار عمل الوكيل الذي تعرضه المحادثة",
options: {
overview: "ملخص",
detailed: "التفاصيل الكاملة",
full: "كامل",
collapseToolCalls: "طيّ استدعاءات الأدوات",
collapseTurns: "طيّ الأدوار",
},
},
language: {
+13 -5
View File
@@ -1177,6 +1177,7 @@ export const en = {
sort: {
label: "Sort",
created: "Created",
manual: "Manual",
lastPrompt: "Last prompt",
updateHost: "Update hosts to sort workspaces",
},
@@ -2032,6 +2033,12 @@ export const en = {
input: "Input",
output: "Output",
},
turnSummary: {
messages: {
one: "wrote {{count}} message",
other: "wrote {{count}} messages",
},
},
toolCallGroup: {
editedFiles: {
one: "edited {{count}} file",
@@ -2399,12 +2406,13 @@ export const en = {
label: "Always expand reasoning",
description: "Show agent thinking and chain-of-thought blocks fully expanded by default",
},
toolCallDetail: {
label: "Tool call display",
description: "How tool calls appear in the timeline",
chatDisplay: {
label: "Chat display",
description: "How much of an agent's work the chat shows",
options: {
overview: "Summary",
detailed: "Full detail",
full: "Full",
collapseToolCalls: "Collapse tool calls",
collapseTurns: "Collapse turns",
},
},
language: {
+13 -5
View File
@@ -1205,6 +1205,7 @@ export const es: TranslationResources = {
sort: {
label: "Orden",
created: "Creación",
manual: "Manual",
lastPrompt: "Último prompt",
updateHost: "Actualiza los hosts para ordenar los espacios de trabajo",
},
@@ -2055,6 +2056,12 @@ export const es: TranslationResources = {
input: "Aporte",
output: "Producción",
},
turnSummary: {
messages: {
one: "escribió {{count}} mensaje",
other: "escribió {{count}} mensajes",
},
},
toolCallGroup: {
editedFiles: {
one: "editó {{count}} archivo",
@@ -2327,12 +2334,13 @@ export const es: TranslationResources = {
description:
"Mostrar los bloques de pensamiento y razonamiento del agente totalmente expandidos de forma predeterminada",
},
toolCallDetail: {
label: "Visualización de llamadas a herramientas",
description: "Cómo aparecen las llamadas a herramientas en la cronología",
chatDisplay: {
label: "Visualización del chat",
description: "Cuánto del trabajo del agente muestra el chat",
options: {
overview: "Resumen",
detailed: "Detalle completo",
full: "Completo",
collapseToolCalls: "Contraer llamadas a herramientas",
collapseTurns: "Contraer turnos",
},
},
language: {
+13 -5
View File
@@ -1198,6 +1198,7 @@ export const fr: TranslationResources = {
sort: {
label: "Tri",
created: "Création",
manual: "Manuel",
lastPrompt: "Dernier prompt",
updateHost: "Mettez les hôtes à jour pour trier les espaces de travail",
},
@@ -2054,6 +2055,12 @@ export const fr: TranslationResources = {
input: "Entrée",
output: "Sortie",
},
turnSummary: {
messages: {
one: "{{count}} message écrit",
other: "{{count}} messages écrits",
},
},
toolCallGroup: {
editedFiles: {
one: "{{count}} fichier modifié",
@@ -2325,12 +2332,13 @@ export const fr: TranslationResources = {
label: "Toujours afficher le raisonnement",
description: "Afficher par défaut le raisonnement de l’agent entièrement déplié",
},
toolCallDetail: {
label: "Affichage des appels d’outils",
description: "Comment les appels d’outils apparaissent dans la chronologie",
chatDisplay: {
label: "Affichage du chat",
description: "La part du travail de l’agent affichée dans le chat",
options: {
overview: "Résumé",
detailed: "Détails complets",
full: "Complet",
collapseToolCalls: "Réduire les appels d’outils",
collapseTurns: "Réduire les tours",
},
},
language: {
+13 -5
View File
@@ -1182,6 +1182,7 @@ export const ja: TranslationResources = {
sort: {
label: "並び順",
created: "作成日時",
manual: "手動",
lastPrompt: "最後のプロンプト",
updateHost: "ワークスペースを並べ替えるにはホストを更新してください",
},
@@ -2027,6 +2028,12 @@ export const ja: TranslationResources = {
input: "入力",
output: "出力",
},
turnSummary: {
messages: {
one: "{{count}}件のメッセージを送信",
other: "{{count}}件のメッセージを送信",
},
},
toolCallGroup: {
editedFiles: {
one: "{{count}}個のファイルを編集",
@@ -2295,12 +2302,13 @@ export const ja: TranslationResources = {
label: "常に思考プロセスを展開",
description: "デフォルトでAIのエージェント思考・推論ブロックを完全に展開して表示します",
},
toolCallDetail: {
label: "ツール呼び出しの表示",
description: "タイムラインでのツール呼び出しの表示方法",
chatDisplay: {
label: "チャットの表示",
description: "チャットに表示するエージェントの作業量",
options: {
overview: "要約",
detailed: "すべての詳細",
full: "すべて",
collapseToolCalls: "ツール呼び出しを折りたたむ",
collapseTurns: "ターンを折りたたむ",
},
},
language: {
+13 -5
View File
@@ -1176,6 +1176,7 @@ export const ko: TranslationResources = {
sort: {
label: "정렬",
created: "생성일",
manual: "수동",
lastPrompt: "마지막 프롬프트",
updateHost: "작업 공간을 정렬하려면 호스트를 업데이트하세요",
},
@@ -2017,6 +2018,12 @@ export const ko: TranslationResources = {
input: "입력",
output: "출력",
},
turnSummary: {
messages: {
one: "{{count}} 메시지를 작성했습니다.",
other: "{{count}} 메시지를 작성했습니다.",
},
},
toolCallGroup: {
editedFiles: {
one: "{{count}} 파일을 편집했습니다.",
@@ -2286,12 +2293,13 @@ export const ko: TranslationResources = {
label: "추론 항상 펼치기",
description: "에이전트의 사고 및 추론 블록을 기본적으로 모두 펼쳐 표시합니다.",
},
toolCallDetail: {
label: "도구 호출 표시",
description: "타임라인에 도구 호출이 표시되는 방식",
chatDisplay: {
label: "채팅 표시",
description: "채팅에 표시할 에이전트 작업의 양",
options: {
overview: "요약",
detailed: "전체 세부정보",
full: "전체",
collapseToolCalls: "도구 호출 접기",
collapseTurns: "턴 접기",
},
},
language: {
+13 -5
View File
@@ -1195,6 +1195,7 @@ export const ptBR: TranslationResources = {
sort: {
label: "Ordenação",
created: "Criação",
manual: "Manual",
lastPrompt: "Último prompt",
updateHost: "Atualize os hosts para ordenar os espaços de trabalho",
},
@@ -2040,6 +2041,12 @@ export const ptBR: TranslationResources = {
input: "Entrada",
output: "Saída",
},
turnSummary: {
messages: {
one: "escreveu {{count}} mensagem",
other: "escreveu {{count}} mensagens",
},
},
toolCallGroup: {
editedFiles: {
one: "editou {{count}} arquivo",
@@ -2310,12 +2317,13 @@ export const ptBR: TranslationResources = {
description:
"Mostrar os blocos de pensamento e raciocínio do agente totalmente expandidos por padrão",
},
toolCallDetail: {
label: "Exibição de chamadas de ferramentas",
description: "Como as chamadas de ferramentas aparecem na linha do tempo",
chatDisplay: {
label: "Exibição do chat",
description: "Quanto do trabalho do agente o chat mostra",
options: {
overview: "Resumo",
detailed: "Detalhes completos",
full: "Completo",
collapseToolCalls: "Recolher chamadas de ferramentas",
collapseTurns: "Recolher turnos",
},
},
language: {
+13 -5
View File
@@ -1186,6 +1186,7 @@ export const ru: TranslationResources = {
sort: {
label: "Сортировка",
created: "Дата создания",
manual: "Вручную",
lastPrompt: "Последний запрос",
updateHost: "Обновите хосты для сортировки рабочих пространств",
},
@@ -2040,6 +2041,12 @@ export const ru: TranslationResources = {
input: "Входные данные",
output: "Выходные данные",
},
turnSummary: {
messages: {
one: "написано {{count}} сообщение",
other: "написаны сообщения ({{count}})",
},
},
toolCallGroup: {
editedFiles: {
one: "изменён {{count}} файл",
@@ -2312,12 +2319,13 @@ export const ru: TranslationResources = {
description:
"По умолчанию полностью разворачивать блоки размышлений и хода рассуждений агента",
},
toolCallDetail: {
label: "Отображение вызовов инструментов",
description: "Как вызовы инструментов отображаются на временной шкале",
chatDisplay: {
label: "Отображение чата",
description: "Какую часть работы агента показывает чат",
options: {
overview: "Сводка",
detailed: "Все подробности",
full: "Полностью",
collapseToolCalls: "Сворачивать вызовы инструментов",
collapseTurns: "Сворачивать ходы",
},
},
language: {
+13 -5
View File
@@ -1161,6 +1161,7 @@ export const zhCN: TranslationResources = {
sort: {
label: "排序",
created: "创建时间",
manual: "手动",
lastPrompt: "最近提示词",
updateHost: "请更新主机以排序工作区",
},
@@ -1985,6 +1986,12 @@ export const zhCN: TranslationResources = {
input: "输入",
output: "输出",
},
turnSummary: {
messages: {
one: "写了 {{count}} 条消息",
other: "写了 {{count}} 条消息",
},
},
toolCallGroup: {
editedFiles: {
one: "编辑了 {{count}} 个文件",
@@ -2250,12 +2257,13 @@ export const zhCN: TranslationResources = {
label: "始终展开推理过程",
description: "默认情况下完全展开 AI 的思考和推理过程",
},
toolCallDetail: {
label: "工具调用显示",
description: "工具调用在时间线中的显示方式",
chatDisplay: {
label: "聊天显示",
description: "聊天中显示多少智能体的工作",
options: {
overview: "摘要",
detailed: "完整详情",
full: "完整",
collapseToolCalls: "折叠工具调用",
collapseTurns: "折叠轮次",
},
},
language: {
@@ -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[] {
@@ -8,29 +8,25 @@ import {
SettingsSwitch,
} from "@/components/settings";
import { isNative } from "@/constants/platform";
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
import { useAppSettings, type ChatDisplay } from "@/hooks/use-settings";
const TOOL_CALL_DETAIL_LEVELS: readonly AppSettings["toolCallDetailLevel"][] = [
"detailed",
"overview",
];
const CHAT_DISPLAYS: readonly ChatDisplay[] = ["full", "collapseToolCalls", "collapseTurns"];
export function ChatSection() {
const { t } = useTranslation();
const { settings, updateSettings } = useAppSettings();
const toolCallDetailOptions = useMemo(
const chatDisplayOptions = useMemo(
() =>
TOOL_CALL_DETAIL_LEVELS.map((value) => ({
CHAT_DISPLAYS.map((value) => ({
value,
label: t(`settings.general.toolCallDetail.options.${value}`),
label: t(`settings.general.chatDisplay.options.${value}`),
})),
[t],
);
const changeToolCallDetailLevel = useCallback(
(toolCallDetailLevel: AppSettings["toolCallDetailLevel"]) =>
void updateSettings({ toolCallDetailLevel }),
const changeChatDisplay = useCallback(
(chatDisplay: ChatDisplay) => void updateSettings({ chatDisplay }),
[updateSettings],
);
const changeAutoExpandReasoning = useCallback(
@@ -53,11 +49,11 @@ export function ChatSection() {
onValueChange={changeAutoExpandReasoning}
/>
<SettingsSelect
label={t("settings.general.toolCallDetail.label")}
hint={t("settings.general.toolCallDetail.description")}
value={settings.toolCallDetailLevel}
options={toolCallDetailOptions}
onValueChange={changeToolCallDetailLevel}
label={t("settings.general.chatDisplay.label")}
hint={t("settings.general.chatDisplay.description")}
value={settings.chatDisplay}
options={chatDisplayOptions}
onValueChange={changeChatDisplay}
/>
{isNative ? null : (
<SettingsSwitch
@@ -117,13 +117,17 @@ export function useWorkspaceStructure(
(state) => selectProjectOrder(state),
workspaceEqualityFns.deep,
);
const workspaceOrderByProject = useSidebarOrderStore((state) =>
workspaceSort === "manual" ? state.workspaceOrderByProject : undefined,
);
return useMemo(
() =>
composeWorkspaceStructure({
projects,
projectOrder,
workspaceOrderByProject,
}),
[projectOrder, projects],
[projectOrder, projects, workspaceOrderByProject],
);
}
@@ -640,3 +640,27 @@ it("a prompt changes only its project's structure and output preserves both proj
expect(output[0]).toBe(after[0]);
expect(output[1]).toBe(after[1]);
});
it("applies manual order only when requested without mutating the automatic baseline", () => {
const project = {
viewKey: "p",
projectKey: "p",
projectName: "P",
projectKind: "git" as const,
iconWorkingDir: "/repo",
hosts: [],
workspaceKeys: ["h:new", "h:b", "h:a"],
};
const input = { projects: [project], projectOrder: ["p"] };
expect(
composeWorkspaceStructure({
...input,
workspaceOrderByProject: { p: ["h:filtered", "h:a", "h:b"] },
}).projects[0].workspaceKeys,
).toEqual(["h:new", "h:a", "h:b"]);
expect(composeWorkspaceStructure(input).projects[0].workspaceKeys).toEqual([
"h:new",
"h:b",
"h:a",
]);
});
@@ -254,6 +254,7 @@ export function selectProjectOrder(state: SidebarOrderSnapshot): string[] {
export function composeWorkspaceStructure(input: {
projects: WorkspaceStructureProject[];
projectOrder: readonly string[];
workspaceOrderByProject?: Readonly<Record<string, string[]>>;
}): WorkspaceStructure {
if (input.projects.length === 0) {
return EMPTY_WORKSPACE_STRUCTURE;
@@ -265,7 +266,21 @@ export function composeWorkspaceStructure(input: {
getKey: (project) => project.viewKey,
});
return { projects: orderedProjects };
return {
// Keep the shared automatic projection immutable when applying a sidebar preference.
// eslint-disable-next-line oxc/no-map-spread
projects: orderedProjects.map((project) => {
const storedOrder = input.workspaceOrderByProject?.[project.viewKey];
if (!storedOrder) return project;
const orderedProject = { ...project };
orderedProject.workspaceKeys = applyStoredOrdering({
items: project.workspaceKeys,
storedOrder,
getKey: (key) => key,
});
return orderedProject;
}),
};
}
export function selectWorkspaceKeys(state: SessionsSnapshot, serverId: string | null): string[] {
@@ -6,7 +6,7 @@ import {
import { migrateSidebarOrderState, useSidebarOrderStore } from "./sidebar-order-store";
describe("migrateSidebarOrderState", () => {
it("discards legacy workspace ordering while retaining project ordering", () => {
it("preserves legacy workspace ordering across hosts", () => {
const migrated = migrateSidebarOrderState({
projectOrderByServerId: {
"host-a": ["project-a"],
@@ -21,6 +21,7 @@ describe("migrateSidebarOrderState", () => {
expect(migrated).toEqual({
projectOrder: ["project-a"],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: { "project-a": ["host-a:main", "host-a:feature", "host-b:main"] },
});
});
@@ -88,14 +89,18 @@ describe("sidebar order keys that end in whitespace", () => {
});
});
it("preserves project and pinned order while discarding ordinary workspace order", () => {
it("preserves project, pinned and ordinary workspace order", () => {
expect(
migrateSidebarOrderState({
projectOrder: ["b", "a"],
pinnedWorkspaceOrder: ["h:y", "h:x"],
workspaceOrderByProject: { a: ["h:old", "h:new"] },
}),
).toEqual({ projectOrder: ["b", "a"], pinnedWorkspaceOrder: ["h:y", "h:x"] });
).toEqual({
projectOrder: ["b", "a"],
pinnedWorkspaceOrder: ["h:y", "h:x"],
workspaceOrderByProject: { a: ["h:old", "h:new"] },
});
});
it("preserves whitespace in current project and pin identities during upgrade", () => {
@@ -105,5 +110,37 @@ it("preserves whitespace in current project and pin identities during upgrade",
pinnedWorkspaceOrder: ["host:workspace "],
workspaceOrderByProject: {},
}),
).toEqual({ projectOrder: ["project "], pinnedWorkspaceOrder: ["host:workspace "] });
).toEqual({
projectOrder: ["project "],
pinnedWorkspaceOrder: ["host:workspace "],
workspaceOrderByProject: {},
});
});
it("retains a pre-beta manual order when upgrading", () => {
expect(
migrateSidebarOrderState({
projectOrder: ["project"],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: { project: ["host:b", "host:a"] },
}),
).toEqual({
projectOrder: ["project"],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: { project: ["host:b", "host:a"] },
});
});
it("starts with an empty manual order for beta users whose order was discarded", () => {
expect(migrateSidebarOrderState({ projectOrder: ["p"], pinnedWorkspaceOrder: ["h:a"] })).toEqual({
projectOrder: ["p"],
pinnedWorkspaceOrder: ["h:a"],
workspaceOrderByProject: {},
});
});
it("preserves spaces in saved workspace scopes and identities", () => {
expect(
migrateSidebarOrderState({ workspaceOrderByProject: { "project ": ["host:b ", "host:a "] } })
.workspaceOrderByProject,
).toEqual({ "project ": ["host:b ", "host:a "] });
});
+71 -3
View File
@@ -7,10 +7,13 @@ import { createValidatedPersistStorage } from "@/storage/validated-persist-stora
interface SidebarOrderStoreState {
projectOrder: string[];
pinnedWorkspaceOrder: string[];
workspaceOrderByProject: Record<string, string[]>;
getProjectOrder: () => string[];
setProjectOrder: (keys: string[]) => void;
getPinnedWorkspaceOrder: () => string[];
setPinnedWorkspaceOrder: (keys: string[]) => void;
getWorkspaceOrder: (projectViewKey: string) => string[];
setWorkspaceOrder: (projectViewKey: string, keys: string[]) => void;
}
interface SidebarOrderPersistedState {
@@ -25,12 +28,16 @@ const StringArrayRecordSchema = z.record(z.string(), z.array(z.string()));
const SidebarOrderPersistedStateSchema = z.strictObject({
projectOrder: z.array(z.string()).optional(),
pinnedWorkspaceOrder: z.array(z.string()).optional(),
// COMPAT(automaticWorkspaceOrder): v0.13.0; accept discarded keys until 2027-04-22 so strict parsing preserves projects/pins.
workspaceOrderByProject: StringArrayRecordSchema.optional(),
projectOrderByServerId: StringArrayRecordSchema.optional(),
workspaceOrderByServerAndProject: StringArrayRecordSchema.optional(),
});
interface SidebarWorkspaceOrderScope {
serverId: string;
projectViewKey: string;
}
// Trims each key. Only for persisted state read at migration time, where a
// stray space is an artifact of an older format rather than part of the key.
function normalizeKeys(keys: string[]): string[] {
@@ -71,13 +78,42 @@ function dedupeKeys(keys: string[]): string[] {
return deduped;
}
function normalizeWorkspaceOrderByProject(
workspaceOrderByProject: Record<string, string[]> | undefined,
): Record<string, string[]> {
const normalized: Record<string, string[]> = {};
for (const [projectViewKey, order] of Object.entries(workspaceOrderByProject ?? {})) {
const scope = projectViewKey;
if (!scope.trim()) continue;
normalized[scope] = dedupeKeys(order);
}
return normalized;
}
function extractWorkspaceOrderScope(scopeKey: string): SidebarWorkspaceOrderScope | null {
const separatorIndex = scopeKey.indexOf("::");
if (separatorIndex < 0) return null;
const serverId = scopeKey.slice(0, separatorIndex).trim();
const projectViewKey = scopeKey.slice(separatorIndex + 2).trim();
if (!serverId || !projectViewKey) return null;
return { serverId, projectViewKey };
}
function normalizeLegacyWorkspaceKey(serverId: string, rawWorkspaceKey: string): string | null {
const workspaceKey = rawWorkspaceKey.trim();
if (!workspaceKey) return null;
const serverPrefix = `${serverId}:`;
return workspaceKey.startsWith(serverPrefix) ? workspaceKey : `${serverPrefix}${workspaceKey}`;
}
export function migrateSidebarOrderState(persistedState: unknown): {
projectOrder: string[];
pinnedWorkspaceOrder: string[];
workspaceOrderByProject: Record<string, string[]>;
} {
const result = SidebarOrderPersistedStateSchema.safeParse(persistedState);
if (!result.success) {
return { projectOrder: [], pinnedWorkspaceOrder: [] };
return { projectOrder: [], pinnedWorkspaceOrder: [], workspaceOrderByProject: {} };
}
const state: SidebarOrderPersistedState = result.data;
@@ -91,9 +127,26 @@ export function migrateSidebarOrderState(persistedState: unknown): {
}
}
const workspaceOrderByProject = normalizeWorkspaceOrderByProject(state.workspaceOrderByProject);
for (const [scopeKey, order] of Object.entries(state.workspaceOrderByServerAndProject ?? {})) {
const scope = extractWorkspaceOrderScope(scopeKey);
if (!scope) continue;
const existing = workspaceOrderByProject[scope.projectViewKey] ?? [];
const merged = [...existing];
const seen = new Set(merged);
for (const key of order) {
const workspaceKey = normalizeLegacyWorkspaceKey(scope.serverId, key);
if (!workspaceKey || seen.has(workspaceKey)) continue;
seen.add(workspaceKey);
merged.push(workspaceKey);
}
workspaceOrderByProject[scope.projectViewKey] = merged;
}
return {
projectOrder,
pinnedWorkspaceOrder: dedupeKeys(state.pinnedWorkspaceOrder ?? []),
workspaceOrderByProject,
};
}
@@ -102,6 +155,7 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
(set, get) => ({
projectOrder: [],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: {},
getProjectOrder: () => get().projectOrder,
setProjectOrder: (keys) => {
set({ projectOrder: dedupeKeys(keys) });
@@ -110,6 +164,19 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
setPinnedWorkspaceOrder: (keys) => {
set({ pinnedWorkspaceOrder: dedupeKeys(keys) });
},
getWorkspaceOrder: (projectViewKey) => {
if (!projectViewKey.trim()) return [];
return get().workspaceOrderByProject[projectViewKey] ?? [];
},
setWorkspaceOrder: (projectViewKey, keys) => {
if (!projectViewKey.trim()) return;
set((state) => ({
workspaceOrderByProject: {
...state.workspaceOrderByProject,
[projectViewKey]: dedupeKeys(keys),
},
}));
},
}),
{
name: "sidebar-project-workspace-order",
@@ -117,8 +184,9 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
partialize: (state) => ({
projectOrder: state.projectOrder,
pinnedWorkspaceOrder: state.pinnedWorkspaceOrder,
workspaceOrderByProject: state.workspaceOrderByProject,
}),
version: 2,
version: 3,
migrate: migrateSidebarOrderState,
},
),
@@ -300,3 +300,7 @@ it("defaults to Created and persists the Last prompt choice across migration", (
migrateSidebarViewState({ workspaceSort: "lastPrompt", groupMode: "status" }),
).toMatchObject({ workspaceSort: "lastPrompt", groupMode: "status" });
});
it("retains the manual sorting preference on reload", () => {
expect(migrateSidebarViewState({ workspaceSort: "manual" }).workspaceSort).toBe("manual");
});
@@ -91,7 +91,7 @@ const SidebarLabelFilterSchema = z.object({
labels: z.array(z.string()),
});
const SidebarViewPersistedStateSchema = z.strictObject({
workspaceSort: z.enum(["created", "lastPrompt"]).optional(),
workspaceSort: z.enum(["manual", "created", "lastPrompt"]).optional(),
showBackground: z.boolean().optional(),
groupMode: PersistedSidebarGroupModeSchema.optional(),
hostFilters: z.array(z.string()).optional(),
@@ -0,0 +1,45 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { OverviewSummary } from "./model";
function joinSummaryParts(parts: string[], conjunction: string): string {
if (parts.length === 0) {
return "";
}
let joined = parts[0] ?? "";
if (parts.length === 2) {
joined = `${parts[0]} ${conjunction} ${parts[1]}`;
} else if (parts.length > 2) {
joined = `${parts.slice(0, -1).join(", ")}, ${conjunction} ${parts.at(-1)}`;
}
const firstCharacter = joined[0];
return firstCharacter ? `${firstCharacter.toLocaleUpperCase()}${joined.slice(1)}` : joined;
}
/**
* One sentence of what a stretch of agent work did, such as "Edited 2 files and ran 3 commands".
* Leading parts come first, for summaries that count more than tool calls.
*/
export function useOverviewSummaryLabel(
summary: OverviewSummary,
leadingParts: readonly string[] = [],
): string {
const { t } = useTranslation();
return useMemo(() => {
const parts = [...leadingParts];
const entries = [
[summary.editedFileCount, "toolCallGroup.editedFiles"],
[summary.commandCount, "toolCallGroup.commands"],
[summary.readFileCount, "toolCallGroup.readFiles"],
[summary.searchCount, "toolCallGroup.searches"],
[summary.otherToolCount, "toolCallGroup.otherTools"],
[summary.paseoCallCount, "toolCallGroup.paseoCalls"],
] as const;
for (const [count, key] of entries) {
if (count > 0) {
parts.push(t(`${key}.${count === 1 ? "one" : "other"}`, { count }));
}
}
return joinSummaryParts(parts, t("toolCallGroup.and"));
}, [leadingParts, summary, t]);
}
@@ -1,4 +1,5 @@
import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization";
import type { ToolCallItem } from "@/types/stream";
import { describeToolCall, type ToolCallRun } from "../grouping";
const DIRECT_PASEO_TOOL_PREFIX = "paseo_";
@@ -28,19 +29,18 @@ function isSearchCall(name: string): boolean {
return DIRECT_SEARCH_TOOL_SUFFIX_PATTERN.test(name);
}
export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup {
/** Counts tool calls by what they did, for any summary of a stretch of agent work. */
export function summarizeToolCalls(calls: readonly ToolCallItem[]): OverviewSummary {
const editedFiles = new Set<string>();
const readFiles = new Set<string>();
let isLoading = false;
let commandCount = 0;
let searchCount = 0;
let otherToolCount = 0;
let paseoCallCount = 0;
for (const call of run.calls) {
for (const call of calls) {
const descriptor = describeToolCall(call);
const normalizedName = descriptor.name.trim().toLowerCase();
isLoading ||= descriptor.status === "running" || descriptor.status === "executing";
if (isPaseoCall(descriptor.name, normalizedName)) {
paseoCallCount += 1;
} else if (descriptor.detail.type === "edit" || descriptor.detail.type === "write") {
@@ -56,7 +56,7 @@ export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup {
}
}
const summary = {
return {
editedFileCount: editedFiles.size,
commandCount,
readFileCount: readFiles.size,
@@ -64,10 +64,17 @@ export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup {
otherToolCount,
paseoCallCount,
};
}
export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup {
const isLoading = run.calls.some((call) => {
const status = describeToolCall(call).status;
return status === "running" || status === "executing";
});
return {
mode: "overview",
run,
isLoading,
summary,
summary: summarizeToolCalls(run.calls),
};
}
@@ -1,11 +1,12 @@
import React, { memo, useCallback, useMemo, useRef, type ReactNode } from "react";
import React, { memo, useCallback, useRef, type ReactNode } from "react";
import { ScrollView } from "react-native";
import { useTranslation } from "react-i18next";
import { Wrench } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { IndentRail } from "@/components/indent-rail";
import { ExpandableBadge } from "@/components/message";
import { useIsCompactFormFactor } from "@/constants/layout";
import { type OverviewSummary, type OverviewToolCallGroup } from "./model";
import { useOverviewSummaryLabel } from "./label";
import type { OverviewToolCallGroup } from "./model";
import { OverviewToolCallGroupSheet } from "./sheet";
interface OverviewGroupProps {
@@ -18,41 +19,6 @@ interface OverviewGroupProps {
const TOOL_CALL_GROUP_MAX_HEIGHT = 400;
function joinSummaryParts(parts: string[], conjunction: string): string {
if (parts.length === 0) {
return "";
}
let joined = parts[0] ?? "";
if (parts.length === 2) {
joined = `${parts[0]} ${conjunction} ${parts[1]}`;
} else if (parts.length > 2) {
joined = `${parts.slice(0, -1).join(", ")}, ${conjunction} ${parts.at(-1)}`;
}
const firstCharacter = joined[0];
return firstCharacter ? `${firstCharacter.toLocaleUpperCase()}${joined.slice(1)}` : joined;
}
function useOverviewSummary(summary: OverviewSummary): string {
const { t } = useTranslation();
return useMemo(() => {
const parts: string[] = [];
const entries = [
[summary.editedFileCount, "toolCallGroup.editedFiles"],
[summary.commandCount, "toolCallGroup.commands"],
[summary.readFileCount, "toolCallGroup.readFiles"],
[summary.searchCount, "toolCallGroup.searches"],
[summary.otherToolCount, "toolCallGroup.otherTools"],
[summary.paseoCallCount, "toolCallGroup.paseoCalls"],
] as const;
for (const [count, key] of entries) {
if (count > 0) {
parts.push(t(`${key}.${count === 1 ? "one" : "other"}`, { count }));
}
}
return joinSummaryParts(parts, t("toolCallGroup.and"));
}, [summary, t]);
}
export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView({
group,
expanded,
@@ -62,7 +28,7 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView
}: OverviewGroupProps) {
const scrollRef = useRef<ScrollView>(null);
const isCompact = useIsCompactFormFactor();
const aggregateSummary = useOverviewSummary(group.summary);
const aggregateSummary = useOverviewSummaryLabel(group.summary);
const scrollToLatest = useCallback(() => {
scrollRef.current?.scrollToEnd({ animated: false });
}, []);
@@ -82,7 +48,7 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView
showsVerticalScrollIndicator
onContentSizeChange={scrollToLatest}
>
{children}
<IndentRail>{children}</IndentRail>
</ScrollView>
),
[children, scrollToLatest],
@@ -1,5 +1,4 @@
import type { StreamItem } from "@/types/stream";
import type { ToolCallDetailLevel } from "@/hooks/use-settings/storage";
import {
groupLiveToolCalls,
prepareGroupedHistory,
@@ -8,7 +7,8 @@ import {
} from "./grouping";
import { buildOverviewGroup, type OverviewToolCallGroup } from "./overview/model";
export type { ToolCallDetailLevel } from "@/hooks/use-settings/storage";
/** Whether consecutive tool calls show one by one or as one summary row. */
export type ToolCallDetailLevel = "overview" | "detailed";
export type ToolCallDetailGroup = OverviewToolCallGroup;
export interface PreparedToolCallHistory {
+6
View File
@@ -771,6 +771,12 @@ export type AgentToolCallItem = ToolCallItem & {
payload: { source: "agent"; data: AgentToolCallData };
};
/** Find and scrolling address a plugin row by the source it replaces. */
export function getStreamItemMessageId(item: StreamItem): string {
if (item.kind === "plugin") return item.sourceId ?? item.id;
return item.id;
}
export function isAgentToolCallItem(item: StreamItem): item is AgentToolCallItem {
return item.kind === "tool_call" && item.payload.source === "agent";
}
@@ -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");