mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 11:12:53 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20665a18cd | ||
|
|
7225c0fe12 | ||
|
|
f9ac58b181 |
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 0.13.1 - 2026-10-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project ([#7896](https://github.com/getpaseo/paseo/pull/7896) by [@budivoogt](https://github.com/budivoogt))
|
||||
|
||||
## 0.13.0 - 2026-10-24
|
||||
|
||||
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 更新日志
|
||||
|
||||
## 0.13.1 - 2026-10-26
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复开启“显示后台工作区”且项目包含至少八个工作区时的启动崩溃([#7896](https://github.com/getpaseo/paseo/pull/7896),由 [@budivoogt](https://github.com/budivoogt) 贡献)
|
||||
|
||||
## 0.13.0 - 2026-10-24
|
||||
|
||||
折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。
|
||||
|
||||
@@ -242,7 +242,7 @@ parentAgentId === thisAgent.id AND !archivedAt
|
||||
|
||||
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
|
||||
|
||||
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Every subagent pane links back to its parent (`packages/app/src/subagents/parent/`): a pill above the composer when there is one, a parent box in the composer's place when there is not. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
|
||||
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-DZOPOf81PkUxdD65BrfkTZhs6/DkfDWCFZ/SU+QrZbg=
|
||||
sha256-n6m3m0541/mBbMN1xrh/2uBCRQc62yicK1cdvyyUmmU=
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -35695,7 +35695,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37961,12 +37961,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.13.0",
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"@getpaseo/server": "0.13.0",
|
||||
"@getpaseo/client": "0.13.1",
|
||||
"@getpaseo/protocol": "0.13.1",
|
||||
"@getpaseo/server": "0.13.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -38213,10 +38213,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"@getpaseo/relay": "0.13.0",
|
||||
"@getpaseo/protocol": "0.13.1",
|
||||
"@getpaseo/relay": "0.13.1",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -38228,7 +38228,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -38472,7 +38472,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -42124,7 +42124,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -42153,15 +42153,15 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"i18next": "^26.3.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.13.0",
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"@getpaseo/client": "0.13.1",
|
||||
"@getpaseo/protocol": "0.13.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.2.3",
|
||||
@@ -42171,8 +42171,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.13.0",
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"@getpaseo/client": "0.13.1",
|
||||
"@getpaseo/protocol": "0.13.1",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -42194,7 +42194,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -42221,7 +42221,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
"@noble/curves": "^2.4.0",
|
||||
@@ -42442,16 +42442,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.13.0",
|
||||
"@getpaseo/highlight": "0.13.0",
|
||||
"@getpaseo/plugin": "0.13.0",
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"@getpaseo/relay": "0.13.0",
|
||||
"@getpaseo/client": "0.13.1",
|
||||
"@getpaseo/highlight": "0.13.1",
|
||||
"@getpaseo/plugin": "0.13.1",
|
||||
"@getpaseo/protocol": "0.13.1",
|
||||
"@getpaseo/relay": "0.13.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -43372,7 +43372,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -44982,7 +44982,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
async function readFileLinkTooltip(page: Page): Promise<void> {
|
||||
const link = page.getByRole("link", { name: "the guide", exact: true });
|
||||
await link.hover();
|
||||
await expect(page.getByRole("tooltip")).toContainText("guide.md");
|
||||
}
|
||||
|
||||
async function leaveFileLink(page: Page): Promise<void> {
|
||||
await page.mouse.move(5, 5);
|
||||
await expect(page.getByRole("tooltip")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function openLinkedFile(page: Page): Promise<void> {
|
||||
await page.getByRole("link", { name: "the guide", exact: true }).click();
|
||||
await expect(page.getByTestId("markdown-preview-frame")).toContainText("Example guide");
|
||||
await leaveFileLink(page);
|
||||
}
|
||||
|
||||
async function returnToAgent(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Tooltip regression", exact: true }).click();
|
||||
await expect(page.getByRole("link", { name: "the guide", exact: true })).toBeVisible();
|
||||
await leaveFileLink(page);
|
||||
}
|
||||
|
||||
async function switchToFileTab(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "guide.md", exact: true }).click();
|
||||
await expect(page.getByTestId("markdown-preview-frame")).toContainText("Example guide");
|
||||
await leaveFileLink(page);
|
||||
}
|
||||
|
||||
test("opening an assistant file link dismisses its tooltip over the file tab", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
testInfo.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "tooltip-5811-",
|
||||
title: "Tooltip regression",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse: "Read [the guide](./guide.md).",
|
||||
mockStreamingAssistantIntervalMs: 5,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(agent.cwd, "guide.md"),
|
||||
"# Example guide\n\n" + "Plain paragraph.\n\n".repeat(150),
|
||||
);
|
||||
await openAgentRoute(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Show the file link.");
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
|
||||
await readFileLinkTooltip(page);
|
||||
await leaveFileLink(page);
|
||||
await readFileLinkTooltip(page);
|
||||
await openLinkedFile(page);
|
||||
await returnToAgent(page);
|
||||
await readFileLinkTooltip(page);
|
||||
await switchToFileTab(page);
|
||||
await returnToAgent(page);
|
||||
await readFileLinkTooltip(page);
|
||||
await leaveFileLink(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1415,14 +1415,12 @@ 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 }) => {
|
||||
const settings = JSON.parse(localStorage.getItem(settingsKey) ?? "{}");
|
||||
if (settings.codeFontSize !== undefined) {
|
||||
if (localStorage.getItem(settingsKey)) {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(
|
||||
settingsKey,
|
||||
JSON.stringify({
|
||||
...settings,
|
||||
theme: "dark",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
|
||||
@@ -99,16 +99,6 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
await expectSubagentRowVisible(page, child.id);
|
||||
});
|
||||
|
||||
await test.step("the parent box in a subagent's sheet closes it onto the parent", async () => {
|
||||
await page.getByTestId(`subagents-track-row-${child.id}`).click();
|
||||
const childSheet = sheet(page, child.title);
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
await expect(childSheet.getByTestId("composer-parent-agent-pill")).toHaveCount(0);
|
||||
await childSheet.getByTestId("parent-agent-callout-open").click();
|
||||
await expect(childSheet).toHaveCount(0);
|
||||
await expectOpenTabCount(page, 1);
|
||||
});
|
||||
|
||||
await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => {
|
||||
await page.getByTestId(`subagents-track-row-${child.id}`).click();
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
|
||||
@@ -229,143 +229,6 @@ test("captures the desktop import sheet and command-center entry", async ({ page
|
||||
});
|
||||
});
|
||||
|
||||
test("large import lists keep mounted rows bounded and remain scrollable", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const fixtureDirectory = path.join(claudeConfigDirectory, "projects", "virtualization-fixture");
|
||||
await mkdir(fixtureDirectory, { recursive: true });
|
||||
try {
|
||||
const newest = Date.now();
|
||||
for (let index = 0; index < 200; index += 1) {
|
||||
const id = `virtualized-${String(index).padStart(3, "0")}`;
|
||||
const file = path.join(fixtureDirectory, `${id}.jsonl`);
|
||||
await writeFile(
|
||||
file,
|
||||
`${JSON.stringify({
|
||||
type: "user",
|
||||
sessionId: id,
|
||||
cwd: index % 2 === 0 ? scenario.projectRoot : scenario.worktreeDirectory,
|
||||
message: {
|
||||
role: "user",
|
||||
content: `Virtualized session ${index}${index % 3 === 0 ? " with a longer preview that wraps onto a second line in a compact sheet" : ""}`,
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
const timestamp = new Date(newest - index * 10);
|
||||
await utimes(file, timestamp, timestamp);
|
||||
}
|
||||
for (const width of [1280, 390]) {
|
||||
const flow = new ImportSessionFlow(page);
|
||||
await flow.openWorkspace(scenario.project.workspaceId, { width, height: 844 });
|
||||
await flow.openGlobally();
|
||||
const rows = page.locator('[data-testid^="import-session-session-"]');
|
||||
const scrollToEnd = async (end = true) => {
|
||||
// Scroll the body's real overflow element. Virtual rows need not exist yet.
|
||||
await rows.first().evaluate((row, atEnd) => {
|
||||
let element = row.parentElement;
|
||||
while (element && !["auto", "scroll"].includes(getComputedStyle(element).overflowY)) {
|
||||
element = element.parentElement;
|
||||
}
|
||||
if (!element) throw new Error("Import list has no scroll owner");
|
||||
element.scrollTop = atEnd ? element.scrollHeight : 0;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
}, end);
|
||||
};
|
||||
for (let pageIndex = 0; pageIndex < 3; pageIndex += 1) {
|
||||
const loadMore = page.getByTestId("import-session-load-more");
|
||||
await expect(async () => {
|
||||
await scrollToEnd();
|
||||
await expect(loadMore).toBeInViewport({ timeout: 500 });
|
||||
}).toPass({ timeout: 10_000 });
|
||||
await loadMore.click();
|
||||
if (pageIndex < 2) await expect(loadMore).toBeEnabled();
|
||||
}
|
||||
await expect(async () => {
|
||||
await scrollToEnd();
|
||||
await expect(
|
||||
page.getByTestId("import-session-session-claude-virtualized-199"),
|
||||
).toBeInViewport({ timeout: 500 });
|
||||
}).toPass({ timeout: 10_000 });
|
||||
await expect.poll(() => rows.count()).toBeLessThan(100);
|
||||
await page.screenshot({ path: testInfo.outputPath(`virtualized-${width}.png`) });
|
||||
if (width === 1280) {
|
||||
const row = (index: number) =>
|
||||
page.getByTestId(
|
||||
`import-session-session-claude-virtualized-${String(index).padStart(3, "0")}`,
|
||||
);
|
||||
await expect(async () => {
|
||||
await scrollToEnd(false);
|
||||
await expect(row(0)).toBeInViewport({ timeout: 500 });
|
||||
}).toPass({ timeout: 10_000 });
|
||||
await row(0).focus();
|
||||
for (let index = 1; index <= 30; index += 1) {
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(row(index)).toBeFocused();
|
||||
}
|
||||
for (let index = 29; index >= 15; index -= 1) {
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(row(index)).toBeFocused();
|
||||
}
|
||||
const list = page.getByTestId("import-session-list");
|
||||
const scrollWithKey = async (event: "scroll" | "scrollend", key: string) => {
|
||||
const listener = await list.evaluateHandle((node, eventName) => {
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
node.addEventListener(eventName, () => resolve(), { once: true });
|
||||
});
|
||||
return { promise };
|
||||
}, event);
|
||||
try {
|
||||
await page.keyboard.press(key);
|
||||
await listener.evaluate((value) => value.promise);
|
||||
} finally {
|
||||
await listener.dispose();
|
||||
}
|
||||
};
|
||||
const beforePageDown = await list.evaluate((node) => node.scrollTop);
|
||||
await scrollWithKey("scroll", "PageDown");
|
||||
await expect
|
||||
.poll(() => list.evaluate((node) => node.scrollTop))
|
||||
.toBeGreaterThan(beforePageDown);
|
||||
await expect(row(15)).toBeFocused();
|
||||
await page.keyboard.press("End");
|
||||
await expect(row(15)).toBeFocused();
|
||||
await expect(row(199)).toBeInViewport();
|
||||
// Home must remain at the first row after the native page scroll ends.
|
||||
await scrollWithKey("scrollend", "Home");
|
||||
await expect(row(0)).toBeInViewport();
|
||||
await expect(row(15)).toBeFocused();
|
||||
|
||||
await page.keyboard.press("End");
|
||||
await expect(row(199)).toBeInViewport();
|
||||
await row(199).focus();
|
||||
await scrollWithKey("scroll", "PageUp");
|
||||
await page.keyboard.press("Home");
|
||||
await expect(row(0)).toBeInViewport();
|
||||
await scrollWithKey("scrollend", "End");
|
||||
await expect(row(199)).toBeInViewport();
|
||||
await expect(row(199)).toBeFocused();
|
||||
await testInfo.attach("keyboard-scroll-owner", {
|
||||
body: JSON.stringify(
|
||||
await list.evaluate((node) => ({
|
||||
focusedRow: document.activeElement?.getAttribute("data-testid"),
|
||||
scrollTop: node.scrollTop,
|
||||
scrollHeight: node.scrollHeight,
|
||||
clientHeight: node.clientHeight,
|
||||
})),
|
||||
),
|
||||
contentType: "application/json",
|
||||
});
|
||||
await page.screenshot({ path: testInfo.outputPath("virtualized-keyboard-end.png") });
|
||||
await page.keyboard.press("Home");
|
||||
await expect(row(0)).toBeInViewport();
|
||||
}
|
||||
await flow.close();
|
||||
}
|
||||
} finally {
|
||||
await rm(fixtureDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
const outputPath = testInfo.outputPath(name);
|
||||
await page.screenshot({ path: outputPath, fullPage: true });
|
||||
|
||||
@@ -136,8 +136,7 @@ test.describe("real provider subagent timelines", () => {
|
||||
panel.getByText("Start chatting with this agent...", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
// A provider subagent has no composer; its callout is the way back to the parent.
|
||||
await page.getByTestId("parent-agent-callout-open").click();
|
||||
await page.getByTestId(`workspace-tab-agent_${handle.agentId}`).first().click();
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({ hasText: "ROOT_DONE" }).last(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fork } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { expect, type BrowserContext, type Locator, type Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { z } from "zod";
|
||||
import { test } from "../support/fixtures";
|
||||
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
|
||||
@@ -34,218 +34,8 @@ const ROLLOUT_QUESTION = "Which rollout should we use?";
|
||||
const SUCCESS_QUESTION = "What success criteria should we use?";
|
||||
const REPO_URL_QUESTION = "What is the GitHub private repo URL to push to?";
|
||||
const COMMIT_MESSAGE_QUESTION = "What should the first commit message be?";
|
||||
const LINKED_QUESTION = "Sign in at https://example.com/login, then follow the setup guide. Done?";
|
||||
|
||||
function selectedText(page: Page) {
|
||||
return page.evaluate(() => window.getSelection()?.toString().trim());
|
||||
}
|
||||
|
||||
const CHECKS_QUESTION = "Which checks should run?";
|
||||
const DONE_DESCRIPTION = "Logs are at https://example.com/logs if sign-in fails.";
|
||||
const LINT_DESCRIPTION = "Rules: https://example.com/lint";
|
||||
|
||||
async function openLinkedQuestions(page: Page, context: BrowserContext) {
|
||||
await context.route("https://example.com/**", (route) =>
|
||||
route.fulfill({ status: 200, body: "ok" }),
|
||||
);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "question-option-links-",
|
||||
title: "Question option links e2e",
|
||||
initialPrompt: "Emit synthetic questions with links.",
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await waitForQuestionPrompt(page, 120_000);
|
||||
return { session, card: page.getByTestId("question-form-card").first() };
|
||||
}
|
||||
|
||||
async function dragAcross(page: Page, target: Locator) {
|
||||
const bounds = await target.boundingBox();
|
||||
if (!bounds) throw new Error("Text is not visible");
|
||||
const y = bounds.y + bounds.height / 2;
|
||||
await page.mouse.move(bounds.x + 1, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.x + bounds.width - 1, y, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function clickLinkAndExpectPage(context: BrowserContext, link: Locator, url: string) {
|
||||
const popup = context.waitForEvent("page");
|
||||
await link.click();
|
||||
await expect.poll(async () => (await popup).url()).toBe(url);
|
||||
}
|
||||
|
||||
async function expectChecked(option: Locator, checked: boolean) {
|
||||
await expect(option).toHaveAttribute("aria-checked", String(checked));
|
||||
}
|
||||
|
||||
test.describe("Question option text", () => {
|
||||
test("links in an option label or description open without answering", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const { session, card } = await openLinkedQuestions(page, context);
|
||||
try {
|
||||
const done = card.getByRole("radio", { name: "Done" });
|
||||
const notYet = card.getByRole("radio", { name: "Not yet" });
|
||||
|
||||
await clickLinkAndExpectPage(
|
||||
context,
|
||||
notYet.getByRole("link", { name: "status" }),
|
||||
"https://example.com/status",
|
||||
);
|
||||
await clickLinkAndExpectPage(
|
||||
context,
|
||||
done.getByRole("link", { name: "https://example.com/logs" }),
|
||||
"https://example.com/logs",
|
||||
);
|
||||
|
||||
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
|
||||
await expectChecked(done, false);
|
||||
await expectChecked(notYet, false);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("selecting description text never answers, and the label still does", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const { session, card } = await openLinkedQuestions(page, context);
|
||||
try {
|
||||
const done = card.getByRole("radio", { name: "Done" });
|
||||
const description = done.getByTestId("question-form-option-description");
|
||||
|
||||
await dragAcross(page, description);
|
||||
await expect.poll(() => selectedText(page)).toBe(DONE_DESCRIPTION);
|
||||
await expectChecked(done, false);
|
||||
|
||||
await description.getByText("Logs are at", { exact: false }).dblclick({
|
||||
position: { x: 4, y: 4 },
|
||||
});
|
||||
await expect.poll(() => selectedText(page)).toBe("Logs");
|
||||
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
|
||||
await expectChecked(done, false);
|
||||
|
||||
await done.getByText("Done", { exact: true }).click();
|
||||
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
|
||||
await openQuestion(page, { index: 1, total: 2 });
|
||||
await expectChecked(done, true);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("double-clicking a single-select label answers once", async ({ page, context }) => {
|
||||
test.setTimeout(180_000);
|
||||
const { session, card } = await openLinkedQuestions(page, context);
|
||||
try {
|
||||
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).dblclick();
|
||||
|
||||
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
|
||||
await expectChecked(card.getByRole("checkbox", { name: "Lint" }), false);
|
||||
await expectChecked(card.getByRole("checkbox", { name: "Typecheck" }), false);
|
||||
await openQuestion(page, { index: 1, total: 2 });
|
||||
await expectChecked(card.getByRole("radio", { name: "Done" }), true);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("multi-select description links and selection leave the option unchanged", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const { session, card } = await openLinkedQuestions(page, context);
|
||||
try {
|
||||
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).click();
|
||||
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
|
||||
|
||||
const lint = card.getByRole("checkbox", { name: "Lint" });
|
||||
const typecheck = card.getByRole("checkbox", { name: "Typecheck" });
|
||||
const description = lint.getByTestId("question-form-option-description");
|
||||
|
||||
await clickLinkAndExpectPage(
|
||||
context,
|
||||
description.getByRole("link", { name: "https://example.com/lint" }),
|
||||
"https://example.com/lint",
|
||||
);
|
||||
await expectChecked(lint, false);
|
||||
|
||||
await dragAcross(page, description);
|
||||
await expect.poll(() => selectedText(page)).toBe(LINT_DESCRIPTION);
|
||||
await expectChecked(lint, false);
|
||||
|
||||
await description.getByText("Rules:", { exact: false }).dblclick({
|
||||
position: { x: 4, y: 4 },
|
||||
});
|
||||
await expect.poll(() => selectedText(page)).toBe("Rules");
|
||||
await expectChecked(lint, false);
|
||||
|
||||
await typecheck.getByText("Typecheck", { exact: true }).dblclick();
|
||||
await expectChecked(typecheck, true);
|
||||
await lint.getByText("Lint", { exact: true }).click();
|
||||
await expectChecked(lint, true);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Question prompt pagination", () => {
|
||||
test("question links open without answering, and the question text is selectable", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await context.route("https://example.com/**", (route) =>
|
||||
route.fulfill({ status: 200, body: "ok" }),
|
||||
);
|
||||
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "question-links-",
|
||||
title: "Question links e2e",
|
||||
initialPrompt: "Emit synthetic questions with links.",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await waitForQuestionPrompt(page, 120_000);
|
||||
|
||||
const card = page.getByTestId("question-form-card").first();
|
||||
const question = card.getByTestId("question-form-current-question");
|
||||
await expect(question).toHaveText(LINKED_QUESTION);
|
||||
await expect(question.getByRole("link", { name: "https://example.com/login" })).toBeVisible();
|
||||
await expect(question.getByRole("link", { name: "the setup guide" })).toBeVisible();
|
||||
|
||||
const bounds = await question.boundingBox();
|
||||
if (!bounds) throw new Error("Question text is not visible");
|
||||
const y = bounds.y + bounds.height / 2;
|
||||
await page.mouse.move(bounds.x + 1, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.x + bounds.width - 1, y, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(() => selectedText(page)).toBe(LINKED_QUESTION);
|
||||
|
||||
const popup = context.waitForEvent("page");
|
||||
await question.getByRole("link", { name: "the setup guide" }).click();
|
||||
await expect.poll(async () => (await popup).url()).toBe("https://example.com/setup");
|
||||
|
||||
await expect(page.getByTestId("question-form-card")).toHaveCount(1);
|
||||
for (const option of ["Done", "Not yet"]) {
|
||||
await expect(card.getByRole("radio", { name: option })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows one question at a time with numbered navigation", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
|
||||
@@ -43,8 +43,6 @@ import {
|
||||
} from "../support/helpers/settings";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
async function openWorkspace(
|
||||
page: import("@playwright/test").Page,
|
||||
@@ -55,74 +53,6 @@ async function openWorkspace(
|
||||
}
|
||||
|
||||
test.describe("Settings sidebar navigation", () => {
|
||||
test("opening Settings closes the composer model selector until it is opened again", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "settings-model-selector-" });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
|
||||
const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
|
||||
const popover = page.getByTestId("combobox-desktop-container");
|
||||
await selector.click();
|
||||
await expect(popover).toBeVisible();
|
||||
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+,`);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await page.screenshot({ path: testInfo.outputPath("settings-after-model-selector.png") });
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(selector).toBeVisible();
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await selector.click();
|
||||
await expect(popover).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(popover).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("opening Settings closes an existing chat's model selector until it is opened again", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "settings-existing-model-",
|
||||
title: "Settings model selector",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
|
||||
const popover = page.getByTestId("combobox-desktop-container");
|
||||
await selector.click();
|
||||
await expect(popover).toBeVisible();
|
||||
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+,`);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("settings-after-existing-model-selector.png"),
|
||||
});
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(selector).toBeVisible();
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await selector.click();
|
||||
await expect(popover).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(popover).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
@@ -217,42 +147,6 @@ test.describe("Settings sidebar navigation", () => {
|
||||
test.describe("Settings — compact master-detail", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("the compact tab switcher reopens after Settings hides its workspace", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "settings-tab-switcher-",
|
||||
title: "Compact tab switcher",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
const switcher = page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true });
|
||||
const newAgent = page.getByTestId("workspace-tab-switcher-new-agent");
|
||||
await switcher.click();
|
||||
await expect(newAgent).toBeInViewport();
|
||||
|
||||
// Navigate without dismissing the sheet through its backdrop first.
|
||||
await page
|
||||
.getByTestId("menu-button")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.dispatchEvent("click");
|
||||
await page.getByTestId("sidebar-settings").first().dispatchEvent("click");
|
||||
await expectAppRoute(page, buildSettingsRoute());
|
||||
await expect(newAgent).toHaveCount(0);
|
||||
|
||||
await closeCompactSettings(page);
|
||||
await expect(switcher).toBeVisible();
|
||||
await expect(newAgent).toHaveCount(0);
|
||||
await switcher.click();
|
||||
await expect(newAgent).toBeInViewport();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opens compact app and host details and returns through the settings list", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -125,29 +125,6 @@ test("opening an already-tabbed subagent reveals its left tab without a duplicat
|
||||
}
|
||||
});
|
||||
|
||||
test("a subagent's parent pill returns to its parent", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "subagent-parent-pill-" });
|
||||
try {
|
||||
const pair = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Refactor auth flow",
|
||||
childTitle: "Write session tests",
|
||||
});
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, { workspaceId: pair.workspaceId, agentId: pair.child.id });
|
||||
|
||||
const pill = page.getByTestId("composer-parent-agent-pill");
|
||||
await expect(pill).toHaveAccessibleName("Back to Refactor auth flow");
|
||||
await pill.click();
|
||||
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${pair.parent.id}`)).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a preferred chat file link still opens beside its right-pane agent", async ({ page }) => {
|
||||
const target = "linked.ts:3";
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -143,7 +143,7 @@ async function configureOverviewToolCalls(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
|
||||
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({ chatDisplay: "collapseToolCalls" }),
|
||||
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||
);
|
||||
});
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -180,11 +180,6 @@ 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,
|
||||
|
||||
@@ -27,7 +27,7 @@ export class ImportSessionFlow {
|
||||
await expectMobileAgentSidebarVisible(this.page);
|
||||
}
|
||||
await openGlobalNewWorkspaceComposer(this.page);
|
||||
const button = this.page.getByTestId("new-workspace-import-session").filter({ visible: true });
|
||||
const button = this.page.getByTestId("new-workspace-import-session");
|
||||
await expect(button).toHaveAccessibleName("Import session");
|
||||
await expect(button).toBeInViewport();
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export class ImportSessionFlow {
|
||||
await this.openFromNewWorkspace();
|
||||
}
|
||||
async openFromNewWorkspace() {
|
||||
await this.page.getByTestId("new-workspace-import-session").filter({ visible: true }).click();
|
||||
await this.page.getByTestId("new-workspace-import-session").click();
|
||||
await this.expectSheetReady();
|
||||
}
|
||||
async openFromWorkspaceHeader() {
|
||||
@@ -86,29 +86,7 @@ export class ImportSessionFlow {
|
||||
expect((bounds?.x ?? width) + (bounds?.width ?? 1)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
async revealSession(id: string) {
|
||||
const target = this.page.getByTestId(rowTestId(id));
|
||||
if ((await target.count()) === 0) {
|
||||
await this.scrollList(false);
|
||||
await expect(async () => {
|
||||
if ((await target.count()) === 0) await this.scrollList(true);
|
||||
await expect(target).toHaveCount(1, { timeout: 100 });
|
||||
}).toPass({ timeout: 10_000 });
|
||||
}
|
||||
await target.scrollIntoViewIfNeeded();
|
||||
await expect(target).toBeInViewport();
|
||||
}
|
||||
private async scrollList(forward: boolean) {
|
||||
await this.page
|
||||
.locator('[data-testid^="import-session-session-"]')
|
||||
.first()
|
||||
.evaluate((row, advance) => {
|
||||
let element = row.parentElement;
|
||||
while (element && !["auto", "scroll"].includes(getComputedStyle(element).overflowY))
|
||||
element = element.parentElement;
|
||||
if (!element) throw new Error("Import list has no scroll owner");
|
||||
element.scrollTop = advance ? element.scrollTop + element.clientHeight * 0.8 : 0;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
}, forward);
|
||||
await this.page.getByTestId(rowTestId(id)).scrollIntoViewIfNeeded();
|
||||
}
|
||||
async search(query: string) {
|
||||
await this.page.getByTestId("import-session-search").fill(query);
|
||||
@@ -122,8 +100,7 @@ export class ImportSessionFlow {
|
||||
}
|
||||
async loadMore() {
|
||||
await this.page.getByTestId("import-session-load-more").click();
|
||||
await this.revealSession("fixture-root-20");
|
||||
await expect(this.page.getByText("Root session 20", { exact: true })).toBeInViewport();
|
||||
await expect(this.page.getByText("Root session 20", { exact: true })).toBeVisible();
|
||||
await expect(this.page.getByTestId("import-session-load-more")).toHaveCount(0);
|
||||
}
|
||||
async retryProvider(provider: string, label: string) {
|
||||
@@ -134,7 +111,6 @@ export class ImportSessionFlow {
|
||||
await expect(this.page.getByRole("progressbar")).toHaveCount(0);
|
||||
}
|
||||
async importSession(id: string) {
|
||||
await this.revealSession(id);
|
||||
await this.page.getByTestId(rowTestId(id)).click();
|
||||
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
@@ -162,7 +138,7 @@ export class ImportSessionFlow {
|
||||
);
|
||||
}
|
||||
async close() {
|
||||
await this.page.getByRole("button", { name: "Close", exact: true }).last().click();
|
||||
await this.page.keyboard.press("Escape");
|
||||
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0);
|
||||
}
|
||||
async expectCommandCenterMatch() {
|
||||
|
||||
@@ -116,7 +116,7 @@ export async function withTimelinePlugin(
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
|
||||
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||
);
|
||||
});
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -63,9 +63,8 @@ export async function seedSidebarFooterPreferences(
|
||||
): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ key, sidebarFooterItems }) => {
|
||||
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
|
||||
if (settings.sidebarFooterItems) return;
|
||||
localStorage.setItem(key, JSON.stringify({ ...settings, sidebarFooterItems }));
|
||||
if (localStorage.getItem(key)) return;
|
||||
localStorage.setItem(key, JSON.stringify({ sidebarFooterItems }));
|
||||
},
|
||||
{ key: APP_SETTINGS_KEY, sidebarFooterItems: preferences },
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -24,7 +24,6 @@ function viewport(root: HTMLElement, capture: Capture) {
|
||||
const handle: StreamViewportHandle = {
|
||||
scrollToBottom() {},
|
||||
prepareForViewportChange() {},
|
||||
holdReadingPosition() {},
|
||||
scrollToMessage(_messageId, occurrence) {
|
||||
if (occurrence) capture.occurrence = occurrence;
|
||||
},
|
||||
|
||||
@@ -265,7 +265,6 @@ describe("useChatOutline", () => {
|
||||
const viewport: StreamViewportHandle = {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
holdReadingPosition: vi.fn(),
|
||||
scrollToMessage,
|
||||
};
|
||||
const viewportRef = { current: viewport };
|
||||
@@ -317,7 +316,6 @@ describe("useChatOutline", () => {
|
||||
current: {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
holdReadingPosition: vi.fn(),
|
||||
scrollToMessage,
|
||||
},
|
||||
};
|
||||
@@ -373,7 +371,6 @@ describe("useChatOutline", () => {
|
||||
const viewport: StreamViewportHandle = {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
holdReadingPosition: vi.fn(),
|
||||
scrollToMessage,
|
||||
};
|
||||
const livePrompt = {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/tim
|
||||
import type { InstalledPlugin } from "@/plugins/types";
|
||||
import {
|
||||
applyStreamEvent,
|
||||
getStreamItemMessageId,
|
||||
hydrateStreamState,
|
||||
type StreamItem,
|
||||
type ToolCallItem,
|
||||
@@ -14,7 +13,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 } from "./presentation";
|
||||
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
|
||||
|
||||
const runtime = {
|
||||
paseo: {},
|
||||
@@ -98,11 +97,7 @@ function pluginData(items: StreamItem[]): unknown[] {
|
||||
return items.flatMap((item) => (item.kind === "plugin" ? [item.data] : []));
|
||||
}
|
||||
|
||||
const presentationOptions = {
|
||||
display: "collapseToolCalls" as const,
|
||||
expandedTurnHostIds: new Set<string>(),
|
||||
isTurnActive: true,
|
||||
};
|
||||
const presentationOptions = { level: "overview" as const, isTurnActive: true };
|
||||
|
||||
function streamHarness(transform?: TimelineItemTransform) {
|
||||
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ChatDisplay } from "@/hooks/use-settings";
|
||||
import type { TimelineItemTransform } from "@/plugins/timeline/model";
|
||||
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
|
||||
import {
|
||||
@@ -6,32 +5,23 @@ 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;
|
||||
display: ChatDisplay;
|
||||
/** User messages whose collapsed turn the reader opened. */
|
||||
expandedTurnHostIds: ReadonlySet<string>;
|
||||
level: ToolCallDetailLevel;
|
||||
isTurnActive: boolean;
|
||||
}
|
||||
|
||||
export interface StreamPresentation extends ToolCallDetailProjection {
|
||||
turns: Omit<TurnCollapse, "tail">;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
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>();
|
||||
@@ -57,47 +47,12 @@ 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];
|
||||
}
|
||||
|
||||
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";
|
||||
return (input: PresentationInput) => {
|
||||
// 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>(
|
||||
@@ -111,20 +66,17 @@ export function createStreamPresentation() {
|
||||
const blocks = presentRows(item);
|
||||
head.push(...blocks);
|
||||
}
|
||||
if (preparedTail !== historyRows || preparedLevel !== level) {
|
||||
preparedHistory = prepareToolCallHistory(level, historyRows);
|
||||
if (preparedTail !== historyRows || preparedLevel !== input.level) {
|
||||
preparedHistory = prepareToolCallHistory(input.level, historyRows);
|
||||
preparedTail = historyRows;
|
||||
preparedLevel = level;
|
||||
preparedLevel = input.level;
|
||||
}
|
||||
return collapse(
|
||||
projectToolCallDetailLevel({
|
||||
level,
|
||||
tail: historyRows,
|
||||
head,
|
||||
preparedHistory,
|
||||
isTurnActive: input.isTurnActive,
|
||||
}),
|
||||
input,
|
||||
);
|
||||
return projectToolCallDetailLevel({
|
||||
level: input.level,
|
||||
tail: historyRows,
|
||||
head,
|
||||
preparedHistory,
|
||||
isTurnActive: input.isTurnActive,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -361,7 +361,6 @@ 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 { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import 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,6 +44,7 @@ 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 {
|
||||
@@ -1173,11 +1174,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
scheduleStickToBottom();
|
||||
},
|
||||
holdReadingPosition: () => {
|
||||
cancelPendingStickToBottom();
|
||||
setFollowOutput(false);
|
||||
readingAnchor.scroll(scrollContainerRef.current?.scrollTop ?? 0);
|
||||
},
|
||||
scrollToMessage,
|
||||
};
|
||||
viewportRef.current = handle;
|
||||
@@ -1190,7 +1186,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}, [
|
||||
cancelPendingStickToBottom,
|
||||
forceStickToBottom,
|
||||
readingAnchor,
|
||||
scheduleStickToBottom,
|
||||
scrollToMessage,
|
||||
viewportRef,
|
||||
|
||||
@@ -55,8 +55,6 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,122 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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,7 +1,8 @@
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import type { Virtualizer } from "@tanstack/react-virtual";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getStreamItemMessageId } from "./presentation";
|
||||
import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle";
|
||||
import type { ScrollToMessageOccurrence } from "./strategy";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import 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.
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
type InlinePathTarget,
|
||||
} from "@/components/message";
|
||||
import { PlanCard } from "@/components/plan-card";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import 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,10 +62,8 @@ 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 } from "./presentation";
|
||||
import { createStreamPresentation, getStreamItemMessageId } 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";
|
||||
@@ -150,16 +148,9 @@ 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;
|
||||
@@ -180,7 +171,7 @@ function renderStreamItemWithTurnFooter(input: {
|
||||
/>
|
||||
) : null;
|
||||
const content = (
|
||||
<StreamItemWrapper gapBelow={input.layoutItem.gapBelow} turnIndent={input.turnIndent}>
|
||||
<StreamItemWrapper itemId={input.layoutItem.item.id} gapBelow={input.layoutItem.gapBelow}>
|
||||
{input.content}
|
||||
</StreamItemWrapper>
|
||||
);
|
||||
@@ -342,7 +333,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
|
||||
const chatDisplay = useSettings((settings) => settings.chatDisplay);
|
||||
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
|
||||
const chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled);
|
||||
const contentMaxWidth = useSettings(resolveContentMaxWidth);
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
@@ -366,7 +357,6 @@ 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 ?? "";
|
||||
@@ -431,7 +421,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
setIsNearBottom(true);
|
||||
setExpandedInlineToolCallIds(new Set());
|
||||
setExpandedToolCallGroupIds(new Set());
|
||||
setExpandedTurnHostIds(new Set());
|
||||
}, [agentId]);
|
||||
|
||||
const isActive = useRetainedPanelActive();
|
||||
@@ -523,8 +512,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
tail: effectiveStreamItems,
|
||||
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
|
||||
transform: transformTimelineItem,
|
||||
display: chatDisplay,
|
||||
expandedTurnHostIds,
|
||||
level: toolCallDetailLevel,
|
||||
isTurnActive,
|
||||
}),
|
||||
[
|
||||
@@ -532,8 +520,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
effectiveStreamItems,
|
||||
effectiveStreamHead,
|
||||
transformTimelineItem,
|
||||
chatDisplay,
|
||||
expandedTurnHostIds,
|
||||
toolCallDetailLevel,
|
||||
isTurnActive,
|
||||
],
|
||||
);
|
||||
@@ -664,43 +651,6 @@ 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);
|
||||
@@ -715,8 +665,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
const renderUserMessageItem = useCallback(
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
|
||||
const collapsedTurn = getCollapsedTurn(item.id);
|
||||
const message = (
|
||||
return (
|
||||
<UserMessage
|
||||
serverId={resolvedServerId}
|
||||
agentId={agentId}
|
||||
@@ -735,25 +684,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
}
|
||||
/>
|
||||
);
|
||||
if (!collapsedTurn) {
|
||||
return message;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{message}
|
||||
<TurnSummaryLine turn={collapsedTurn} onExpandedChange={setTurnExpanded} />
|
||||
</>
|
||||
);
|
||||
},
|
||||
[
|
||||
context.capabilities,
|
||||
agentId,
|
||||
client,
|
||||
getCollapsedTurn,
|
||||
pendingClientMessageIds,
|
||||
resolvedServerId,
|
||||
setTurnExpanded,
|
||||
],
|
||||
[context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId],
|
||||
);
|
||||
|
||||
const renderAssistantMessageItem = useCallback(
|
||||
@@ -961,14 +893,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return renderStreamItemWithTurnFooter({
|
||||
content,
|
||||
layoutItem,
|
||||
turnIndent: getTurnIndent(layoutItem),
|
||||
strategy: streamRenderStrategy,
|
||||
supportsTimelineCursor: supportsAgentForkContextCursor,
|
||||
onForkAssistantTurn: readOnly ? undefined : handleForkAssistantTurn,
|
||||
});
|
||||
},
|
||||
[
|
||||
getTurnIndent,
|
||||
handleForkAssistantTurn,
|
||||
readOnly,
|
||||
renderStreamItemContent,
|
||||
@@ -1130,25 +1060,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const streamScrollEnabled =
|
||||
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
|
||||
expandedInlineToolCallIds.size === 0;
|
||||
const { nestedItemIds } = presentation.turns;
|
||||
const historyRowRevision = useMemo(
|
||||
() => ({
|
||||
contentById: presentation.historyGroupUpdatesByHostId,
|
||||
displayStateById: {
|
||||
has: (id: string) =>
|
||||
expandedToolCallGroupIds.has(id) ||
|
||||
expandedTurnHostIds.has(id) ||
|
||||
nestedItemIds.has(id),
|
||||
},
|
||||
displayStateById: expandedToolCallGroupIds,
|
||||
globalDisplayState: isMobile,
|
||||
}),
|
||||
[
|
||||
expandedToolCallGroupIds,
|
||||
expandedTurnHostIds,
|
||||
isMobile,
|
||||
nestedItemIds,
|
||||
presentation.historyGroupUpdatesByHostId,
|
||||
],
|
||||
[expandedToolCallGroupIds, isMobile, presentation.historyGroupUpdatesByHostId],
|
||||
);
|
||||
|
||||
const findItems = useMemo(
|
||||
@@ -1162,7 +1080,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
epoch={timelineEpoch}
|
||||
items={findItems}
|
||||
viewportRef={viewportRef}
|
||||
revealLoadedMessage={revealLoadedMessage}
|
||||
revealLoadedMessage={revealLoadedHistory}
|
||||
visibleMessageIds={mountedMessageIds}
|
||||
>
|
||||
<ToolCallSheetProvider>
|
||||
@@ -1845,41 +1763,15 @@ const permissionStyles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
interface StreamItemWrapperProps {
|
||||
itemId: string;
|
||||
gapBelow: number;
|
||||
turnIndent: TurnIndent;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function StreamItemWrapper({ gapBelow, turnIndent, children }: StreamItemWrapperProps) {
|
||||
function StreamItemWrapper({ gapBelow, children }: StreamItemWrapperProps) {
|
||||
const wrapperStyle = useMemo(
|
||||
() => [
|
||||
stylesheet.streamItemWrapper,
|
||||
{ marginBottom: turnIndent === "nested" || turnIndent === "openedHost" ? 0 : gapBelow },
|
||||
],
|
||||
[gapBelow, turnIndent],
|
||||
() => [stylesheet.streamItemWrapper, { marginBottom: gapBelow }],
|
||||
[gapBelow],
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
return <View style={wrapperStyle}>{children}</View>;
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import type { ImportSessionListComponent } from "./import-session-list.types";
|
||||
|
||||
// Native keeps the sheet-owned ScrollView until its gestures and keyboard are verified.
|
||||
export const ImportSessionList: ImportSessionListComponent | null = null;
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
|
||||
export interface ImportSessionListProps<Item> {
|
||||
data: ReadonlyArray<Item>;
|
||||
keyExtractor: (item: Item) => string;
|
||||
renderItem: (item: Item) => ReactNode;
|
||||
header: ReactElement | null;
|
||||
footer: ReactElement | null;
|
||||
}
|
||||
|
||||
export type ImportSessionListComponent = <Item>(
|
||||
props: ImportSessionListProps<Item>,
|
||||
) => ReactElement;
|
||||
@@ -1,141 +0,0 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type FocusEvent,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import { View, type LayoutChangeEvent } from "react-native";
|
||||
import { defaultRangeExtractor, useVirtualizer, type Range } from "@tanstack/react-virtual";
|
||||
import type { ImportSessionListProps } from "./import-session-list.types";
|
||||
|
||||
const SCROLLER_STYLE: CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
overscrollBehavior: "contain",
|
||||
};
|
||||
const CONTENT_STYLE: CSSProperties = { width: "100%", position: "relative" };
|
||||
|
||||
export function ImportSessionList<Item>({
|
||||
data,
|
||||
keyExtractor,
|
||||
renderItem,
|
||||
header,
|
||||
footer,
|
||||
}: ImportSessionListProps<Item>) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [headerHeight, setHeaderHeight] = useState(0);
|
||||
const [focusedKey, setFocusedKey] = useState<string | null>(null);
|
||||
const indexByKey = useMemo(
|
||||
() => new Map(data.map((item, index) => [keyExtractor(item), index])),
|
||||
[data, keyExtractor],
|
||||
);
|
||||
const focusedIndex = focusedKey === null ? undefined : indexByKey.get(focusedKey);
|
||||
const rangeExtractor = useCallback(
|
||||
(range: Range) => {
|
||||
const visible = defaultRangeExtractor(range);
|
||||
if (focusedIndex === undefined) return visible;
|
||||
// Keep focus and its Tab neighbours mounted when PageDown/End moves the window.
|
||||
const focused = [focusedIndex - 1, focusedIndex, focusedIndex + 1].filter(
|
||||
(index) => index >= 0 && index < data.length,
|
||||
);
|
||||
return [...new Set([...visible, ...focused])].sort((left, right) => left - right);
|
||||
},
|
||||
[data.length, focusedIndex],
|
||||
);
|
||||
const handleFocus = useCallback((event: FocusEvent<HTMLDivElement>) => {
|
||||
const row = event.target.closest<HTMLElement>("[data-session-key]");
|
||||
setFocusedKey(row?.dataset.sessionKey ?? null);
|
||||
}, []);
|
||||
const handleBlur = useCallback((event: FocusEvent<HTMLDivElement>) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) setFocusedKey(null);
|
||||
}, []);
|
||||
const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setHeaderHeight(event.nativeEvent.layout.height);
|
||||
}, []);
|
||||
const getScrollElement = useCallback(() => scrollRef.current, []);
|
||||
const getItemKey = useCallback(
|
||||
(index: number) => keyExtractor(data[index]!),
|
||||
[data, keyExtractor],
|
||||
);
|
||||
const estimateSize = useCallback(() => 88, []);
|
||||
const virtualizer = useVirtualizer({
|
||||
count: data.length,
|
||||
getScrollElement,
|
||||
getItemKey,
|
||||
estimateSize,
|
||||
scrollMargin: headerHeight,
|
||||
overscan: 5,
|
||||
rangeExtractor,
|
||||
});
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
const target = event.target;
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
!(target instanceof HTMLElement) ||
|
||||
!event.currentTarget.contains(target) ||
|
||||
target.isContentEditable ||
|
||||
target.closest("input, textarea, select")
|
||||
)
|
||||
return;
|
||||
if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
// Reconcile the landing position as variable-height rows are measured.
|
||||
virtualizer.scrollToIndex(data.length - 1, { align: "end" });
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
virtualizer.scrollToOffset(0);
|
||||
} else if (event.key === "PageDown" || event.key === "PageUp") {
|
||||
event.preventDefault();
|
||||
// Native page-scroll animations can keep moving after Home or End.
|
||||
const direction = event.key === "PageDown" ? 1 : -1;
|
||||
virtualizer.scrollBy(direction * event.currentTarget.clientHeight);
|
||||
}
|
||||
},
|
||||
[data.length, virtualizer],
|
||||
);
|
||||
const virtualRows = virtualizer.getVirtualItems();
|
||||
const totalSize = virtualizer.getTotalSize();
|
||||
const contentStyle = useMemo(() => ({ ...CONTENT_STYLE, height: totalSize }), [totalSize]);
|
||||
const rowStyle = useCallback(
|
||||
(offset: number): CSSProperties => ({
|
||||
...CONTENT_STYLE,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
transform: `translateY(${offset}px)`,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={SCROLLER_STYLE}
|
||||
data-testid="import-session-list"
|
||||
onFocusCapture={handleFocus}
|
||||
onBlurCapture={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<View onLayout={handleHeaderLayout}>{header}</View>
|
||||
<div style={contentStyle}>
|
||||
{virtualRows.map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
data-index={row.index}
|
||||
data-session-key={row.key}
|
||||
ref={virtualizer.measureElement}
|
||||
style={rowStyle(row.start - headerHeight)}
|
||||
>
|
||||
{renderItem(data[row.index]!)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>{footer}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
|
||||
import { keepPreviousData, useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -11,7 +11,6 @@ import { ChevronDown, Inbox, Layers, RotateCw } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ImportSessionList } from "@/components/import-session-list";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { useProviderIcon, useProviderIcons } from "@/components/provider-icons";
|
||||
@@ -372,33 +371,7 @@ function ImportSessionActivityTime({ date }: { date: string }) {
|
||||
return <Text style={styles.rowMeta}>{label}</Text>;
|
||||
}
|
||||
|
||||
function getSessionRowKey(entry: FetchRecentProviderSessionEntry): string {
|
||||
return `${entry.providerId}:${entry.providerHandleId}`;
|
||||
}
|
||||
|
||||
function hasListHeader(
|
||||
input: SheetStatusMessagesProps & {
|
||||
showFilter: boolean;
|
||||
hasProviderErrors: boolean;
|
||||
},
|
||||
): boolean {
|
||||
return (
|
||||
input.showFilter ||
|
||||
!input.isClientReady ||
|
||||
input.isSnapshotUnsupported ||
|
||||
input.hasNoImportableProviders ||
|
||||
(input.isLoadingSessions && !input.hasRows) ||
|
||||
input.importErrored ||
|
||||
input.hasProviderErrors
|
||||
);
|
||||
}
|
||||
|
||||
function SessionRows({
|
||||
visible,
|
||||
onClose,
|
||||
header,
|
||||
listHeader,
|
||||
listFooter,
|
||||
serverId,
|
||||
entries,
|
||||
disabled,
|
||||
@@ -406,11 +379,6 @@ function SessionRows({
|
||||
resolveFolder,
|
||||
onImportSession,
|
||||
}: {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
header: SheetHeader;
|
||||
listHeader: ReactNode;
|
||||
listFooter: ReactNode;
|
||||
serverId: string | null;
|
||||
entries: ReadonlyArray<FetchRecentProviderSessionEntry>;
|
||||
disabled: boolean;
|
||||
@@ -418,69 +386,20 @@ function SessionRows({
|
||||
resolveFolder: (entry: FetchRecentProviderSessionEntry) => string | null;
|
||||
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
|
||||
}) {
|
||||
// Keep short results content-sized; only long lists need a bounded viewport.
|
||||
const virtualized = ImportSessionList !== null && entries.length > 8;
|
||||
const renderSessionRow = useCallback(
|
||||
(entry: FetchRecentProviderSessionEntry) => (
|
||||
<ImportSessionSheetRow
|
||||
key={getSessionRowKey(entry)}
|
||||
serverId={serverId}
|
||||
entry={entry}
|
||||
disabled={disabled}
|
||||
importing={importingSessionKey === getSessionRowKey(entry)}
|
||||
folder={resolveFolder(entry)}
|
||||
onImportSession={onImportSession}
|
||||
/>
|
||||
),
|
||||
[serverId, disabled, importingSessionKey, resolveFolder, onImportSession],
|
||||
);
|
||||
const renderItem = useCallback(
|
||||
(item: FetchRecentProviderSessionEntry) => (
|
||||
<View style={styles.listRow}>{renderSessionRow(item)}</View>
|
||||
),
|
||||
[renderSessionRow],
|
||||
);
|
||||
const virtualHeader = useMemo(
|
||||
() => (listHeader ? <View style={styles.listHeader}>{listHeader}</View> : null),
|
||||
[listHeader],
|
||||
);
|
||||
const virtualFooter = useMemo(
|
||||
() => <View style={styles.listFooter}>{listFooter}</View>,
|
||||
[listFooter],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
header={header}
|
||||
testID="import-session-sheet"
|
||||
desktopMaxWidth={560}
|
||||
desktopHeight={virtualized ? "85%" : undefined}
|
||||
snapPoints={IMPORT_SHEET_SNAP_POINTS}
|
||||
scrollable={!virtualized}
|
||||
contentStyle={virtualized ? styles.listBody : undefined}
|
||||
>
|
||||
{ImportSessionList && virtualized ? (
|
||||
<View style={styles.list}>
|
||||
<ImportSessionList
|
||||
data={entries}
|
||||
keyExtractor={getSessionRowKey}
|
||||
renderItem={renderItem}
|
||||
header={virtualHeader}
|
||||
footer={virtualFooter}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{listHeader}
|
||||
{entries.length > 0 ? (
|
||||
<View style={styles.smallList}>{entries.map(renderSessionRow)}</View>
|
||||
) : null}
|
||||
{listFooter}
|
||||
</>
|
||||
)}
|
||||
</AdaptiveModalSheet>
|
||||
<View style={styles.list}>
|
||||
{entries.map((entry) => (
|
||||
<ImportSessionSheetRow
|
||||
key={`${entry.providerId}:${entry.providerHandleId}`}
|
||||
serverId={serverId}
|
||||
entry={entry}
|
||||
disabled={disabled}
|
||||
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
|
||||
folder={resolveFolder(entry)}
|
||||
onImportSession={onImportSession}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -807,124 +726,85 @@ export function ImportSessionSheet({
|
||||
});
|
||||
const showFilter = filterProviders.length > 1;
|
||||
const showLoadMore = hasMoreSessions(queries, pageLimit);
|
||||
const showListHeader = hasListHeader({
|
||||
showFilter,
|
||||
isClientReady: Boolean(client),
|
||||
isSnapshotUnsupported,
|
||||
hasNoImportableProviders,
|
||||
isLoadingSessions,
|
||||
hasRows: visibleEntries.length > 0,
|
||||
importErrored: importMutation.isError,
|
||||
hasProviderErrors: providerErrorRows.length > 0,
|
||||
});
|
||||
const listHeader = useMemo(
|
||||
() =>
|
||||
showListHeader ? (
|
||||
<View style={styles.listHeaderContent}>
|
||||
{showFilter ? (
|
||||
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
|
||||
<Pressable
|
||||
onPress={handleFilterOpen}
|
||||
style={filterTriggerStyle}
|
||||
testID="import-session-filter-trigger"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Filter: ${selectedProviderLabel}`}
|
||||
>
|
||||
{selectedProvider === ALL_FILTER_VALUE ? (
|
||||
<Layers size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<SelectedProviderIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
<Text style={styles.filterTriggerText} numberOfLines={1}>
|
||||
{selectedProviderLabel}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={filterComboboxOptions}
|
||||
value={selectedProvider}
|
||||
onSelect={handleFilterSelect}
|
||||
renderOption={renderFilterOption}
|
||||
searchable={false}
|
||||
title="Filter by provider"
|
||||
open={isFilterOpen}
|
||||
onOpenChange={setIsFilterOpen}
|
||||
anchorRef={filterAnchorRef}
|
||||
desktopPlacement="bottom-start"
|
||||
desktopPreventInitialFlash
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
<SheetStatusMessages
|
||||
isClientReady={Boolean(client)}
|
||||
isSnapshotUnsupported={isSnapshotUnsupported}
|
||||
hasNoImportableProviders={hasNoImportableProviders}
|
||||
isLoadingSessions={isLoadingSessions}
|
||||
hasRows={visibleEntries.length > 0}
|
||||
importErrored={importMutation.isError}
|
||||
/>
|
||||
{providerErrorRows.length > 0 ? (
|
||||
<ProviderErrorBanner rows={providerErrorRows} onRetry={handleRetryProvider} />
|
||||
) : null}
|
||||
</View>
|
||||
) : null,
|
||||
[
|
||||
showListHeader,
|
||||
showFilter,
|
||||
filterTriggerStyle,
|
||||
handleFilterOpen,
|
||||
selectedProvider,
|
||||
theme.colors.foregroundMuted,
|
||||
SelectedProviderIcon,
|
||||
selectedProviderLabel,
|
||||
filterComboboxOptions,
|
||||
handleFilterSelect,
|
||||
renderFilterOption,
|
||||
isFilterOpen,
|
||||
client,
|
||||
isSnapshotUnsupported,
|
||||
hasNoImportableProviders,
|
||||
isLoadingSessions,
|
||||
visibleEntries.length,
|
||||
importMutation.isError,
|
||||
providerErrorRows,
|
||||
handleRetryProvider,
|
||||
],
|
||||
);
|
||||
const listFooter = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{showLoadMore ? (
|
||||
<View style={styles.footer}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onPress={handleLoadMore}
|
||||
disabled={isRefreshing}
|
||||
testID="import-session-load-more"
|
||||
>
|
||||
{t("importSession.actions.loadMore")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
{showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : null}
|
||||
</>
|
||||
),
|
||||
[showLoadMore, handleLoadMore, isRefreshing, t, showEmptyState, emptyStateTitle],
|
||||
);
|
||||
|
||||
return (
|
||||
<SessionRows
|
||||
<AdaptiveModalSheet
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
header={header}
|
||||
listHeader={listHeader}
|
||||
listFooter={listFooter}
|
||||
serverId={serverId}
|
||||
entries={visibleEntries}
|
||||
disabled={importMutation.isPending}
|
||||
importingSessionKey={importingSessionKey}
|
||||
resolveFolder={resolveFolder}
|
||||
onImportSession={handleImportSession}
|
||||
/>
|
||||
testID="import-session-sheet"
|
||||
desktopMaxWidth={560}
|
||||
snapPoints={IMPORT_SHEET_SNAP_POINTS}
|
||||
>
|
||||
{showFilter ? (
|
||||
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
|
||||
<Pressable
|
||||
onPress={handleFilterOpen}
|
||||
style={filterTriggerStyle}
|
||||
testID="import-session-filter-trigger"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Filter: ${selectedProviderLabel}`}
|
||||
>
|
||||
{selectedProvider === ALL_FILTER_VALUE ? (
|
||||
<Layers size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<SelectedProviderIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
<Text style={styles.filterTriggerText} numberOfLines={1}>
|
||||
{selectedProviderLabel}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={filterComboboxOptions}
|
||||
value={selectedProvider}
|
||||
onSelect={handleFilterSelect}
|
||||
renderOption={renderFilterOption}
|
||||
searchable={false}
|
||||
title="Filter by provider"
|
||||
open={isFilterOpen}
|
||||
onOpenChange={setIsFilterOpen}
|
||||
anchorRef={filterAnchorRef}
|
||||
desktopPlacement="bottom-start"
|
||||
desktopPreventInitialFlash
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
<SheetStatusMessages
|
||||
isClientReady={Boolean(client)}
|
||||
isSnapshotUnsupported={isSnapshotUnsupported}
|
||||
hasNoImportableProviders={hasNoImportableProviders}
|
||||
isLoadingSessions={isLoadingSessions}
|
||||
hasRows={visibleEntries.length > 0}
|
||||
importErrored={importMutation.isError}
|
||||
/>
|
||||
{providerErrorRows.length > 0 ? (
|
||||
<ProviderErrorBanner rows={providerErrorRows} onRetry={handleRetryProvider} />
|
||||
) : null}
|
||||
{visibleEntries.length > 0 ? (
|
||||
<SessionRows
|
||||
serverId={serverId}
|
||||
entries={visibleEntries}
|
||||
disabled={importMutation.isPending}
|
||||
importingSessionKey={importingSessionKey}
|
||||
resolveFolder={resolveFolder}
|
||||
onImportSession={handleImportSession}
|
||||
/>
|
||||
) : null}
|
||||
{showLoadMore ? (
|
||||
<View style={styles.footer}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onPress={handleLoadMore}
|
||||
disabled={isRefreshing}
|
||||
testID="import-session-load-more"
|
||||
>
|
||||
{t("importSession.actions.loadMore")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
{showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : null}
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -973,31 +853,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
listBody: {
|
||||
gap: 0,
|
||||
},
|
||||
list: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
marginHorizontal: -theme.spacing[2],
|
||||
},
|
||||
listHeader: {
|
||||
marginHorizontal: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
listHeaderContent: {
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
smallList: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
listFooter: {
|
||||
marginHorizontal: theme.spacing[2],
|
||||
},
|
||||
listRow: {
|
||||
marginHorizontal: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[1],
|
||||
},
|
||||
footer: {
|
||||
paddingTop: theme.spacing[2],
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -249,35 +249,6 @@ describe("bare URL sentence punctuation", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["http://127.0.0.1:8059/?token=t-1", ""],
|
||||
["http://example.com:8080/a", "."],
|
||||
["http://localhost:8059/?token=t-1", "."],
|
||||
["http://localhost:8059/?token=t-1", ")"],
|
||||
["http://user@127.0.0.1:8059/?token=t-1", '"'],
|
||||
["http://user:pass@127.0.0.1:8059/?token=t-1", ""],
|
||||
["http://localhost", ":"],
|
||||
["http://[::1]:8059/?token=t-1", "'"],
|
||||
["https://github.com/o/r/compare/main...user:repo:fix/branch", ""],
|
||||
["https://example.com/a:b/c", ":"],
|
||||
])("links %s whole and leaves %j outside", async (url, punctuation) => {
|
||||
const prepared = prepareMarkdownSource(`See ${url}${punctuation} now`);
|
||||
const tree = recognizeTextLinks(
|
||||
await parseMarkdown(prepared.render(), { latexMath: false }),
|
||||
prepared.linkRecognition.text,
|
||||
prepared.linkRecognition.inlineCode,
|
||||
);
|
||||
expect(tree.children?.[0]?.children).toEqual([
|
||||
{ type: "Text", content: "See " },
|
||||
{
|
||||
type: "Link",
|
||||
attributes: expect.objectContaining({ url }),
|
||||
children: [{ type: "Text", content: url }],
|
||||
},
|
||||
{ type: "Text", content: `${punctuation} now` },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["[Authored](https://example.com/a.)", "<https://example.com/a.>"])(
|
||||
"preserves punctuation in explicit destinations: %s",
|
||||
async (source) => {
|
||||
|
||||
@@ -23,29 +23,14 @@ const ThemedMarkdown = withUnistyles(EnrichedMarkdownText);
|
||||
const MD4C_FLAGS = { latexMath: false, hardSoftBreaks: true, admonitions: true };
|
||||
|
||||
// Reading the breakpoint here makes Unistyles restyle the text when the layout crosses it.
|
||||
const markdownStyle =
|
||||
(compact: boolean, muted = false) =>
|
||||
(theme: Theme, rt: { breakpoint?: string }) =>
|
||||
createMarkdownStyles(theme, {
|
||||
compact,
|
||||
muted,
|
||||
blockGap: resolveDensity(rt, density.blockGap),
|
||||
});
|
||||
const markdownStyle = (compact: boolean) => (theme: Theme, rt: { breakpoint?: string }) =>
|
||||
createMarkdownStyles(theme, { compact, blockGap: resolveDensity(rt, density.blockGap) });
|
||||
const regularStyle = markdownStyle(false);
|
||||
const compactStyle = markdownStyle(true);
|
||||
const compactMutedStyle = markdownStyle(true, true);
|
||||
|
||||
function pickMarkdownStyle(compact: boolean, muted: boolean) {
|
||||
if (!compact) return regularStyle;
|
||||
return muted ? compactMutedStyle : compactStyle;
|
||||
}
|
||||
|
||||
export interface MarkdownRendererProps extends MarkdownImageContext {
|
||||
text: string;
|
||||
compact?: boolean;
|
||||
/** Muted text colour. Compact surfaces only. */
|
||||
muted?: boolean;
|
||||
selectable?: boolean;
|
||||
streaming?: boolean;
|
||||
onLinkPress?: (url: string) => unknown;
|
||||
}
|
||||
@@ -71,8 +56,6 @@ export const MarkdownRenderer = memo(function MarkdownRenderer(props: MarkdownRe
|
||||
const MarkdownPart = memo(function MarkdownPart({
|
||||
text,
|
||||
compact = false,
|
||||
muted = false,
|
||||
selectable = true,
|
||||
streaming = false,
|
||||
onLinkPress,
|
||||
...imageContext
|
||||
@@ -115,9 +98,9 @@ const MarkdownPart = memo(function MarkdownPart({
|
||||
<ThemedMarkdown
|
||||
key={`${segment.kind}:${segment.id}`}
|
||||
markdown={segment.source}
|
||||
uniProps={pickMarkdownStyle(compact, muted)}
|
||||
uniProps={compact ? compactStyle : regularStyle}
|
||||
flavor="github"
|
||||
selectable={selectable}
|
||||
selectable
|
||||
md4cFlags={MD4C_FLAGS}
|
||||
linkRecognition={linkRecognition}
|
||||
enableTaskListItemToggle={false}
|
||||
|
||||
@@ -27,17 +27,11 @@ const PROSE_PATH = String.raw`(?:${PATH_PREFIX}${HAS_PATH_LETTER}${SEPARATED_PAT
|
||||
function createFilePattern(path: string): RegExp {
|
||||
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
|
||||
return new RegExp(
|
||||
String.raw`(?:${BARE_URL}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
|
||||
String.raw`(?:https?://[^\s<>]+|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
|
||||
"g",
|
||||
);
|
||||
}
|
||||
|
||||
// Matches bare URLs that MD4C does not autolink, like single-label or bracketed hosts.
|
||||
// Quotes and unbalanced closing parentheses end the URL. Trailing sentence punctuation stays outside.
|
||||
const URL_CHARACTER = String.raw`[^\s<>()"'\x60]`;
|
||||
const URL_GROUP = String.raw`\(${URL_CHARACTER}*\)`;
|
||||
const BARE_URL = String.raw`https?://(?:${URL_CHARACTER}|${URL_GROUP})*(?:[^\s<>()"'\x60.,;:!?]|${URL_GROUP})`;
|
||||
|
||||
const FILE_PATTERN = createFilePattern(PROSE_PATH);
|
||||
function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
|
||||
// The engine accepts regexes on both platforms. Classify complete code spans
|
||||
|
||||
@@ -7,11 +7,7 @@ const WEB_CONTAINER = { userSelect: "text", WebkitUserSelect: "text" } as const;
|
||||
|
||||
export function createMarkdownStyles(
|
||||
theme: Theme,
|
||||
{
|
||||
compact = false,
|
||||
muted = false,
|
||||
blockGap,
|
||||
}: { compact?: boolean; muted?: boolean; blockGap: number },
|
||||
{ compact = false, blockGap }: { compact?: boolean; blockGap: number },
|
||||
): { markdownStyle: MarkdownStyle; selectionColor: string; containerStyle?: typeof WEB_CONTAINER } {
|
||||
const fontSize = compact ? theme.fontSize.base : theme.fontSize.content;
|
||||
// Every block is followed by the same gap; the library drops it after the last block.
|
||||
@@ -20,7 +16,7 @@ export function createMarkdownStyles(
|
||||
fontSize,
|
||||
fontFamily: theme.fontFamily.ui,
|
||||
lineHeight: Math.round(fontSize * 1.4),
|
||||
color: muted ? theme.colors.foregroundMuted : theme.colors.foreground,
|
||||
color: theme.colors.foreground,
|
||||
...block,
|
||||
};
|
||||
const heading = (scale: number) => ({
|
||||
|
||||
@@ -971,10 +971,6 @@ 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,
|
||||
},
|
||||
@@ -2052,10 +2048,7 @@ export const ExpandableBadge = memo(function ExpandableBadge({
|
||||
expandableBadgeStylesheet.pressable,
|
||||
isPressed && isInteractive ? expandableBadgeStylesheet.pressablePressed : null,
|
||||
isExpanded && expandableBadgeStylesheet.pressableExpanded,
|
||||
isExpanded &&
|
||||
(borderlessWhenExpanded
|
||||
? expandableBadgeStylesheet.pressableExpandedDetached
|
||||
: expandableBadgeStylesheet.pressableExpandedAttached),
|
||||
isExpanded && !borderlessWhenExpanded && expandableBadgeStylesheet.pressableExpandedAttached,
|
||||
],
|
||||
[borderlessWhenExpanded, isExpanded, isInteractive, isPressed],
|
||||
);
|
||||
|
||||
@@ -10,12 +10,6 @@ import { QuestionFormCard } from "./question-form-card";
|
||||
// Load translations so controls expose their real accessible names.
|
||||
void testI18n;
|
||||
|
||||
// The Markdown engine does not load in this harness; question and option text are covered by
|
||||
// e2e/browser/question-prompt-pagination.spec.ts. Plain text keeps these tests on the answers.
|
||||
vi.mock("@/components/markdown/renderer", () => ({
|
||||
MarkdownRenderer: ({ text }: { text: string }) => <span>{text}</span>,
|
||||
}));
|
||||
|
||||
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useState, useCallback, useMemo, useRef, type RefObject } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Check, X } from "lucide-react-native";
|
||||
@@ -18,7 +12,6 @@ import { isWeb } from "@/constants/platform";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input/types";
|
||||
import { ScrollableCodeSurface } from "@/components/ui/scrollable-code-surface";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import {
|
||||
areQuestionsAnswered,
|
||||
buildQuestionFormAnswers,
|
||||
@@ -43,20 +36,6 @@ const IS_WEB = isWeb;
|
||||
// drag to the preview's nested scroll, so lines past a cap would be unreachable.
|
||||
const OPTION_PREVIEW_MAX_HEIGHT = IS_WEB ? 240 : undefined;
|
||||
|
||||
// Web clicks that must not answer: a link in the label, anything in the description, and the
|
||||
// second click of a double-click, which would otherwise land on the next question's options.
|
||||
function isNonAnsweringClick(event: GestureResponderEvent, description: View | null): boolean {
|
||||
if (!IS_WEB) return false;
|
||||
const click = event.nativeEvent as unknown as MouseEvent;
|
||||
if (click.detail > 1) return true;
|
||||
const target = click.target as Element | null;
|
||||
const descriptionElement = description as unknown as Element | null;
|
||||
return Boolean(target?.closest("a") || descriptionElement?.contains(target));
|
||||
}
|
||||
|
||||
// The description is selectable text, so a touch on it belongs to the text, not the option.
|
||||
const claimResponder = () => true;
|
||||
|
||||
function getQuestionInputPlaceholder({
|
||||
question,
|
||||
answerPlaceholder,
|
||||
@@ -92,14 +71,9 @@ function QuestionOptionRow({
|
||||
}: QuestionOptionRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const descriptionRef = useRef<View>(null);
|
||||
const handlePress = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
if (isNonAnsweringClick(event, descriptionRef.current)) return;
|
||||
onToggle(qIndex, optIndex, multiSelect);
|
||||
},
|
||||
[onToggle, qIndex, optIndex, multiSelect],
|
||||
);
|
||||
const handlePress = useCallback(() => {
|
||||
onToggle(qIndex, optIndex, multiSelect);
|
||||
}, [onToggle, qIndex, optIndex, multiSelect]);
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
@@ -112,6 +86,17 @@ function QuestionOptionRow({
|
||||
[isSelected, theme.colors.surface2],
|
||||
);
|
||||
|
||||
const optionLabelStyle = useMemo(
|
||||
() => [
|
||||
styles.optionLabel,
|
||||
{ color: isSelected ? theme.colors.foreground : theme.colors.foregroundMuted },
|
||||
],
|
||||
[isSelected, theme.colors.foreground, theme.colors.foregroundMuted],
|
||||
);
|
||||
const optionDescriptionStyle = useMemo(
|
||||
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
const accessibilityState = useMemo(() => ({ checked: isSelected }), [isSelected]);
|
||||
|
||||
// Static left-side control: square for multi-select, circle for single-select.
|
||||
@@ -150,15 +135,9 @@ function QuestionOptionRow({
|
||||
{isSelected && !multiSelect ? <View style={radioDotStyle} /> : null}
|
||||
</View>
|
||||
<View style={styles.optionTextBlock}>
|
||||
<MarkdownRenderer text={option.label} compact muted={!isSelected} selectable={false} />
|
||||
<Text style={optionLabelStyle}>{option.label}</Text>
|
||||
{option.description ? (
|
||||
<View
|
||||
ref={descriptionRef}
|
||||
testID="question-form-option-description"
|
||||
onStartShouldSetResponder={claimResponder}
|
||||
>
|
||||
<MarkdownRenderer text={option.description} compact muted />
|
||||
</View>
|
||||
<Text style={optionDescriptionStyle}>{option.description}</Text>
|
||||
) : null}
|
||||
{option.preview ? (
|
||||
// Shown for every option at once so they can be compared before picking.
|
||||
@@ -543,6 +522,10 @@ export function QuestionFormCard({
|
||||
],
|
||||
[theme.colors.surface1, theme.colors.border],
|
||||
);
|
||||
const questionTextStyle = useMemo(
|
||||
() => [styles.questionText, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
);
|
||||
// Single-select radios need a group; checkboxes are valid standalone.
|
||||
const optionsGroupAccessibility = useMemo(
|
||||
() =>
|
||||
@@ -587,9 +570,9 @@ export function QuestionFormCard({
|
||||
onSelect={handleSelectQuestion}
|
||||
/>
|
||||
<View style={styles.questionHeader}>
|
||||
<View testID="question-form-current-question" style={styles.questionText}>
|
||||
<MarkdownRenderer text={questions[resolvedActiveQuestionIndex].question} compact />
|
||||
</View>
|
||||
<Text testID="question-form-current-question" style={questionTextStyle}>
|
||||
{activeQuestion?.question}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{activeQuestion ? (
|
||||
@@ -692,6 +675,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
questionText: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 22,
|
||||
},
|
||||
optionsWrap: {
|
||||
gap: theme.spacing[1],
|
||||
@@ -737,6 +723,15 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
optionLabel: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 22,
|
||||
},
|
||||
optionDescription: {
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 20,
|
||||
},
|
||||
optionPreviewText: {
|
||||
lineHeight: 20,
|
||||
...(isWeb ? { whiteSpace: "pre-wrap", overflowWrap: "break-word" } : null),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { createWorkspaceVisibilityPolicy } from "@/components/sidebar/sidebar-group-visibility";
|
||||
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages";
|
||||
@@ -486,6 +487,20 @@ describe("sidebar group visibility", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("treats placements without a hydrated entry as Done", () => {
|
||||
const items = entries([...Array(8).fill("done"), "running", "done"]);
|
||||
const hydrated = new Map(items.slice(0, 9).map((item) => [item.workspaceKey, item]));
|
||||
for (const entriesByKey of [new Map(), hydrated]) {
|
||||
const { result } = renderHook(() =>
|
||||
useLimitedSidebarGroup(items, createWorkspaceVisibilityPolicy(entriesByKey)),
|
||||
);
|
||||
expect(result.current.canToggle).toBe(true);
|
||||
expect(names(result.current.visibleItems)).toEqual(
|
||||
entriesByKey.size === 0 ? names(items.slice(0, 7)) : [...names(items.slice(0, 7)), "row-9"],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("shows eight active rows once with no redundant toggle", () => {
|
||||
const items = entries([
|
||||
"running",
|
||||
|
||||
@@ -99,6 +99,7 @@ import {
|
||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
|
||||
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
|
||||
import { createWorkspaceVisibilityPolicy } from "@/components/sidebar/sidebar-group-visibility";
|
||||
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
||||
import {
|
||||
SidebarWorkspaceRowFrame,
|
||||
@@ -1593,11 +1594,7 @@ function ProjectBlock({
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
}) {
|
||||
const visibilityPolicy = useMemo(
|
||||
() => ({
|
||||
kind: "workspaces" as const,
|
||||
getStatusBucket: (workspace: SidebarWorkspacePlacement) =>
|
||||
workspaceEntriesByKey.get(workspace.workspaceKey)!.statusBucket,
|
||||
}),
|
||||
() => createWorkspaceVisibilityPolicy<SidebarWorkspacePlacement>(workspaceEntriesByKey),
|
||||
[workspaceEntriesByKey],
|
||||
);
|
||||
const {
|
||||
|
||||
@@ -4,6 +4,18 @@ export type SidebarGroupVisibilityPolicy<T> =
|
||||
| { kind: "pinned" }
|
||||
| { kind: "workspaces"; getStatusBucket: (item: T) => SidebarStateBucket };
|
||||
|
||||
// A placement can outrun its entry: a retained sidebar that is inactive (compact layout with the
|
||||
// sidebar closed) or still waiting for sessions keeps an empty entry map. A missing entry renders
|
||||
// no row, so it counts as Done and claims no slot past the limit.
|
||||
export function createWorkspaceVisibilityPolicy<T extends { workspaceKey: string }>(
|
||||
entriesByKey: ReadonlyMap<string, { statusBucket: SidebarStateBucket }>,
|
||||
): SidebarGroupVisibilityPolicy<T> {
|
||||
return {
|
||||
kind: "workspaces",
|
||||
getStatusBucket: (item) => entriesByKey.get(item.workspaceKey)?.statusBucket ?? "done",
|
||||
};
|
||||
}
|
||||
|
||||
export function selectSidebarGroupVisibility<T>(
|
||||
items: readonly T[],
|
||||
expanded: boolean,
|
||||
|
||||
@@ -28,7 +28,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
@@ -911,9 +910,13 @@ function resolveInitialActiveIndex(
|
||||
return selectedIndex >= 0 ? selectedIndex : fallbackIndex;
|
||||
}
|
||||
|
||||
type BottomSheetVisibility = ReturnType<typeof useIsolatedBottomSheetVisibility>;
|
||||
|
||||
interface MobileBodyProps {
|
||||
isOpen: boolean;
|
||||
bottomSheetRef: BottomSheetVisibility["sheetRef"];
|
||||
snapPoints: string[];
|
||||
handleSheetChange: BottomSheetVisibility["handleSheetChange"];
|
||||
handleSheetDismiss: BottomSheetVisibility["handleSheetDismiss"];
|
||||
handleIndicatorStyle: { backgroundColor: string };
|
||||
titleColor: string;
|
||||
title: string;
|
||||
@@ -941,22 +944,7 @@ interface MobileBodyProps {
|
||||
safeAreaBottom: number;
|
||||
}
|
||||
|
||||
function MobileComboboxBody(props: MobileBodyProps): ReactElement | null {
|
||||
const active = useRetainedPanelActive();
|
||||
if (!active) return null;
|
||||
// The visibility tracker must leave the tree with its sheet.
|
||||
return <MobileComboboxSheet {...props} />;
|
||||
}
|
||||
|
||||
function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
|
||||
const {
|
||||
sheetRef: bottomSheetRef,
|
||||
handleSheetChange,
|
||||
handleSheetDismiss,
|
||||
} = useIsolatedBottomSheetVisibility({
|
||||
visible: props.isOpen,
|
||||
onClose: props.onClose,
|
||||
});
|
||||
function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
||||
const comboboxTitleStyle = useMemo(
|
||||
() => [styles.comboboxTitle, { color: props.titleColor }],
|
||||
[props.titleColor],
|
||||
@@ -991,13 +979,13 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
|
||||
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={bottomSheetRef}
|
||||
ref={props.bottomSheetRef}
|
||||
contextBridge={null}
|
||||
snapPoints={props.snapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
onChange={props.handleSheetChange}
|
||||
onDismiss={props.handleSheetDismiss}
|
||||
backdropOpacity={0.45}
|
||||
enablePanDownToClose
|
||||
backgroundComponent={ComboboxSheetBackground}
|
||||
@@ -1180,8 +1168,7 @@ function DesktopComboboxOptionsBody(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null {
|
||||
const active = useRetainedPanelActive();
|
||||
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
|
||||
const handleDesktopKey = props.handleDesktopKey;
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
@@ -1203,9 +1190,6 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null {
|
||||
[props.refs, setWebOverlayScope],
|
||||
);
|
||||
|
||||
// Remove the portal before a retained chat can freeze its closing update.
|
||||
if (!active) return null;
|
||||
|
||||
const overlay = (
|
||||
<OverlayLayerProvider layer={props.overlayLayer}>
|
||||
<View
|
||||
@@ -1325,7 +1309,6 @@ export function Combobox({
|
||||
const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch");
|
||||
const resolvedTitle = title ?? t("common.actions.select");
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const active = useRetainedPanelActive();
|
||||
const floatingLayer = useOverlayLayer("floating");
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const titleColor = theme.colors.foreground;
|
||||
@@ -1365,10 +1348,6 @@ export function Combobox({
|
||||
bumpSearchResetKey();
|
||||
}, [setOpen, setSearchQueryWithCallback]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active && isOpen) handleClose();
|
||||
}, [active, isOpen, handleClose]);
|
||||
|
||||
useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey);
|
||||
|
||||
const collisionPadding = useMemo(computeCollisionPadding, []);
|
||||
@@ -1434,6 +1413,16 @@ export function Combobox({
|
||||
referenceWidth,
|
||||
});
|
||||
|
||||
const {
|
||||
sheetRef: bottomSheetRef,
|
||||
handleSheetChange,
|
||||
handleSheetDismiss,
|
||||
} = useIsolatedBottomSheetVisibility({
|
||||
visible: isOpen,
|
||||
isEnabled: isMobile,
|
||||
onClose: handleClose,
|
||||
});
|
||||
|
||||
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
|
||||
const sanitizedSearchValue = searchQuery.trim();
|
||||
const showCustomOption = useMemo(
|
||||
@@ -1583,8 +1572,10 @@ export function Combobox({
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileComboboxBody
|
||||
isOpen={isOpen}
|
||||
bottomSheetRef={bottomSheetRef}
|
||||
snapPoints={snapPoints}
|
||||
handleSheetChange={handleSheetChange}
|
||||
handleSheetDismiss={handleSheetDismiss}
|
||||
handleIndicatorStyle={handleIndicatorStyle}
|
||||
titleColor={titleColor}
|
||||
title={resolvedTitle}
|
||||
|
||||
@@ -27,7 +27,6 @@ import { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { FloatingSurface } from "@/components/ui/floating";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
|
||||
|
||||
@@ -257,15 +256,8 @@ export function Tooltip({
|
||||
});
|
||||
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const active = useRetainedPanelActive();
|
||||
const opensOnPress = isNative || isCompact;
|
||||
const enabled = active && (opensOnPress ? enabledOnMobile : enabledOnDesktop);
|
||||
|
||||
// Remove the portal in the inactive commit, before the retained chat freezes.
|
||||
// Closing also prevents the tooltip from returning when its panel is shown again.
|
||||
useEffect(() => {
|
||||
if (!active && isOpen) setIsOpen(false);
|
||||
}, [active, isOpen, setIsOpen]);
|
||||
const enabled = opensOnPress ? enabledOnMobile : enabledOnDesktop;
|
||||
|
||||
const value = useMemo<TooltipContextValue>(
|
||||
() => ({
|
||||
@@ -337,10 +329,6 @@ export function TooltipTrigger({
|
||||
};
|
||||
}, [clearOpenTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx.enabled) clearOpenTimer();
|
||||
}, [ctx.enabled, clearOpenTimer]);
|
||||
|
||||
const handleHoverIn = useCallback(
|
||||
(e?: unknown) => {
|
||||
if (isCallable(onHoverIn)) onHoverIn(e);
|
||||
|
||||
@@ -276,11 +276,7 @@ export function ComposerTrackRow({
|
||||
* knockout for marks that sit on top of an icon, and nothing sits under this one, so it comes off
|
||||
* too — leaving every mark's visible edge on the same rail whatever state it is in.
|
||||
*/
|
||||
export function ComposerTrackMark({
|
||||
bucket,
|
||||
}: {
|
||||
bucket: SidebarStateBucket | null;
|
||||
}): ReactElement | null {
|
||||
function ComposerTrackMark({ bucket }: { bucket: SidebarStateBucket | null }): ReactElement | null {
|
||||
if (!bucket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
saveAppSettings as saveAppSettingsPure,
|
||||
type AppSettings,
|
||||
type AppSettingsUpdate,
|
||||
type ChatDisplay,
|
||||
type OpenInSidePanePreferences,
|
||||
type PullRequestOpenLocation,
|
||||
type DesktopSettingsBridge,
|
||||
@@ -85,7 +84,6 @@ export {
|
||||
export type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
ChatDisplay,
|
||||
AppLanguage,
|
||||
OpenInSidePanePreferences,
|
||||
PullRequestOpenLocation,
|
||||
|
||||
@@ -185,7 +185,6 @@ 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,
|
||||
);
|
||||
@@ -455,7 +454,6 @@ 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,
|
||||
@@ -594,7 +592,6 @@ describe("loadSettingsFromStorage", () => {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
theme: "light",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
chatDisplay: "full",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -639,7 +636,6 @@ describe("loadSettingsFromStorage", () => {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
theme: "light",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
chatDisplay: "full",
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
@@ -661,7 +657,6 @@ describe("loadSettingsFromStorage", () => {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
theme: "light",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
chatDisplay: "full",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -737,7 +732,7 @@ describe("saveAppSettings", () => {
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
theme: "light",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
chatDisplay: "collapseToolCalls",
|
||||
toolCallDetailLevel: "overview",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -792,45 +787,27 @@ 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.chatDisplay).toBe("full");
|
||||
expect(result.toolCallDetailLevel).toBe("detailed");
|
||||
});
|
||||
|
||||
it("migrates the enabled compact tool call preference to collapsed tool calls", async () => {
|
||||
it("migrates the enabled compact tool call preference to overview", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ compactToolCalls: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe("collapseToolCalls");
|
||||
expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview");
|
||||
});
|
||||
|
||||
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) => {
|
||||
it("clears settings with an unrecognized tool call detail level", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: legacy }),
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "unknown" }),
|
||||
}),
|
||||
});
|
||||
|
||||
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");
|
||||
expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("detailed");
|
||||
});
|
||||
|
||||
it("migrates a switched-off checks row item to the hidden checks display", async () => {
|
||||
|
||||
@@ -40,8 +40,7 @@ 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";
|
||||
/** How much of an agent's work the chat shows between the user's message and the reply. */
|
||||
export type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns";
|
||||
export type ToolCallDetailLevel = "overview" | "detailed";
|
||||
|
||||
const ThemePreferenceSchema = z.enum([
|
||||
...THEME_OPTIONS.map((option) => option.name),
|
||||
@@ -101,7 +100,7 @@ export interface AppSettings {
|
||||
/** How usage reads and which windows the sidebar summary shows. */
|
||||
usage: UsagePreferences;
|
||||
autoExpandReasoning: boolean;
|
||||
chatDisplay: ChatDisplay;
|
||||
toolCallDetailLevel: ToolCallDetailLevel;
|
||||
chatOutlineEnabled: boolean;
|
||||
vimKeybindings: boolean;
|
||||
filePanelAssociations: Record<string, string>;
|
||||
@@ -157,7 +156,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
sidebarFooterItems: [],
|
||||
usage: DEFAULT_USAGE_PREFERENCES,
|
||||
autoExpandReasoning: false,
|
||||
chatDisplay: "collapseTurns",
|
||||
toolCallDetailLevel: "detailed",
|
||||
chatOutlineEnabled: true,
|
||||
vimKeybindings: false,
|
||||
filePanelAssociations: {},
|
||||
@@ -200,7 +199,6 @@ const SidebarRowItemsSchema = z
|
||||
type StoredAppSettingsFallback = AppSettings & {
|
||||
uiFontSize?: number;
|
||||
compactToolCalls?: boolean;
|
||||
toolCallDetailLevel?: "overview" | "detailed";
|
||||
manageBuiltInDaemon?: boolean;
|
||||
releaseChannel?: ReleaseChannel;
|
||||
needsWrite: boolean;
|
||||
@@ -253,13 +251,11 @@ 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(undefined),
|
||||
.catch("detailed"),
|
||||
// COMPAT(compactToolCalls): migrated in v0.1.105, remove after 2027-01-12.
|
||||
compactToolCalls: z.boolean().optional().catch(undefined),
|
||||
chatOutlineEnabled: z.boolean().catch(true),
|
||||
@@ -309,10 +305,8 @@ const StoredAppSettingsSchema = z
|
||||
(isChecksHiddenByLegacyRowItem(stored.sidebarRowItems)
|
||||
? "none"
|
||||
: DEFAULT_SIDEBAR_CHECKS_DISPLAY);
|
||||
const groupsToolCalls = stored.toolCallDetailLevel
|
||||
? stored.toolCallDetailLevel === "overview"
|
||||
: stored.compactToolCalls === true;
|
||||
const chatDisplay = stored.chatDisplay ?? (groupsToolCalls ? "collapseToolCalls" : "full");
|
||||
const toolCallDetailLevel =
|
||||
stored.toolCallDetailLevel ?? (stored.compactToolCalls ? "overview" : "detailed");
|
||||
return {
|
||||
...stored,
|
||||
openInSidePane,
|
||||
@@ -327,7 +321,7 @@ const StoredAppSettingsSchema = z
|
||||
stored.sidebarRowItems.services ??
|
||||
(stored.sidebarRowItems.scripts === false ? false : DEFAULT_SIDEBAR_ROW_ITEMS.services),
|
||||
},
|
||||
chatDisplay,
|
||||
toolCallDetailLevel,
|
||||
needsWrite,
|
||||
};
|
||||
})
|
||||
@@ -455,7 +449,6 @@ export function normalizeAppSettings(value: unknown): AppSettings {
|
||||
manageBuiltInDaemon: _manageBuiltInDaemon,
|
||||
releaseChannel: _releaseChannel,
|
||||
compactToolCalls: _compactToolCalls,
|
||||
toolCallDetailLevel: _toolCallDetailLevel,
|
||||
uiFontSize: _uiFontSize,
|
||||
...settings
|
||||
} = StoredAppSettingsSchema.parse(value);
|
||||
|
||||
@@ -1908,13 +1908,6 @@ export const ar: TranslationResources = {
|
||||
backdrop: "خلفية القائمة",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "العودة إلى الوكيل الأصلي",
|
||||
backTo: "العودة إلى {{label}}",
|
||||
parent: "الأصل",
|
||||
parentAgent: "الوكيل الأصلي",
|
||||
openParent: "فتح الوكيل الأصلي",
|
||||
},
|
||||
title: "الوكلاء الفرعيون",
|
||||
pillLabelOne: "وكيل فرعي واحد",
|
||||
pillLabelMany: "{{count}} وكلاء فرعيين",
|
||||
@@ -2015,12 +2008,6 @@ export const ar: TranslationResources = {
|
||||
input: "مدخل",
|
||||
output: "الإخراج",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "كتب {{count}} رسالة",
|
||||
other: "كتب {{count}} رسائل",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "حرّر {{count}} ملفًا",
|
||||
@@ -2325,13 +2312,12 @@ export const ar: TranslationResources = {
|
||||
label: "عرض التفكير دائماً",
|
||||
description: "إظهار تفكير الوكيل وخطوات الاستدلال بشكل كامل بشكل افتراضي",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "عرض المحادثة",
|
||||
description: "مقدار عمل الوكيل الذي تعرضه المحادثة",
|
||||
toolCallDetail: {
|
||||
label: "عرض استدعاءات الأدوات",
|
||||
description: "كيفية ظهور استدعاءات الأدوات في المخطط الزمني",
|
||||
options: {
|
||||
full: "كامل",
|
||||
collapseToolCalls: "طيّ استدعاءات الأدوات",
|
||||
collapseTurns: "طيّ الأدوار",
|
||||
overview: "ملخص",
|
||||
detailed: "التفاصيل الكاملة",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -1933,13 +1933,6 @@ export const en = {
|
||||
backdrop: "Menu backdrop",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Back to parent",
|
||||
backTo: "Back to {{label}}",
|
||||
parent: "Parent",
|
||||
parentAgent: "Parent agent",
|
||||
openParent: "Open parent",
|
||||
},
|
||||
title: "Subagents",
|
||||
pillLabelOne: "1 subagent",
|
||||
pillLabelMany: "{{count}} subagents",
|
||||
@@ -2040,12 +2033,6 @@ export const en = {
|
||||
input: "Input",
|
||||
output: "Output",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "wrote {{count}} message",
|
||||
other: "wrote {{count}} messages",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "edited {{count}} file",
|
||||
@@ -2413,13 +2400,12 @@ export const en = {
|
||||
label: "Always expand reasoning",
|
||||
description: "Show agent thinking and chain-of-thought blocks fully expanded by default",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "Chat display",
|
||||
description: "How much of an agent's work the chat shows",
|
||||
toolCallDetail: {
|
||||
label: "Tool call display",
|
||||
description: "How tool calls appear in the timeline",
|
||||
options: {
|
||||
full: "Full",
|
||||
collapseToolCalls: "Collapse tool calls",
|
||||
collapseTurns: "Collapse turns",
|
||||
overview: "Summary",
|
||||
detailed: "Full detail",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -1956,13 +1956,6 @@ export const es: TranslationResources = {
|
||||
backdrop: "Fondo del menú",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Volver al padre",
|
||||
backTo: "Volver a {{label}}",
|
||||
parent: "Padre",
|
||||
parentAgent: "Agente padre",
|
||||
openParent: "Abrir padre",
|
||||
},
|
||||
title: "Subagentes",
|
||||
pillLabelOne: "1 subagente",
|
||||
pillLabelMany: "{{count}} subagentes",
|
||||
@@ -2063,12 +2056,6 @@ 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",
|
||||
@@ -2341,13 +2328,12 @@ export const es: TranslationResources = {
|
||||
description:
|
||||
"Mostrar los bloques de pensamiento y razonamiento del agente totalmente expandidos de forma predeterminada",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "Visualización del chat",
|
||||
description: "Cuánto del trabajo del agente muestra el chat",
|
||||
toolCallDetail: {
|
||||
label: "Visualización de llamadas a herramientas",
|
||||
description: "Cómo aparecen las llamadas a herramientas en la cronología",
|
||||
options: {
|
||||
full: "Completo",
|
||||
collapseToolCalls: "Contraer llamadas a herramientas",
|
||||
collapseTurns: "Contraer turnos",
|
||||
overview: "Resumen",
|
||||
detailed: "Detalle completo",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -1954,13 +1954,6 @@ export const fr: TranslationResources = {
|
||||
backdrop: "Arrière-plan du menu",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Retour au parent",
|
||||
backTo: "Retour à {{label}}",
|
||||
parent: "Parent",
|
||||
parentAgent: "Agent parent",
|
||||
openParent: "Ouvrir le parent",
|
||||
},
|
||||
title: "Sous-agents",
|
||||
pillLabelOne: "1 sous-agent",
|
||||
pillLabelMany: "{{count}} sous-agents",
|
||||
@@ -2062,12 +2055,6 @@ 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é",
|
||||
@@ -2339,13 +2326,12 @@ export const fr: TranslationResources = {
|
||||
label: "Toujours afficher le raisonnement",
|
||||
description: "Afficher par défaut le raisonnement de l’agent entièrement déplié",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "Affichage du chat",
|
||||
description: "La part du travail de l’agent affichée dans le chat",
|
||||
toolCallDetail: {
|
||||
label: "Affichage des appels d’outils",
|
||||
description: "Comment les appels d’outils apparaissent dans la chronologie",
|
||||
options: {
|
||||
full: "Complet",
|
||||
collapseToolCalls: "Réduire les appels d’outils",
|
||||
collapseTurns: "Réduire les tours",
|
||||
overview: "Résumé",
|
||||
detailed: "Détails complets",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -1927,13 +1927,6 @@ export const ja: TranslationResources = {
|
||||
backdrop: "メニューの背景",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "親に戻る",
|
||||
backTo: "{{label}} に戻る",
|
||||
parent: "親",
|
||||
parentAgent: "親エージェント",
|
||||
openParent: "親を開く",
|
||||
},
|
||||
title: "サブエージェント",
|
||||
pillLabelOne: "サブエージェント 1 件",
|
||||
pillLabelMany: "サブエージェント {{count}} 件",
|
||||
@@ -2035,12 +2028,6 @@ export const ja: TranslationResources = {
|
||||
input: "入力",
|
||||
output: "出力",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "{{count}}件のメッセージを送信",
|
||||
other: "{{count}}件のメッセージを送信",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "{{count}}個のファイルを編集",
|
||||
@@ -2309,13 +2296,12 @@ export const ja: TranslationResources = {
|
||||
label: "常に思考プロセスを展開",
|
||||
description: "デフォルトでAIのエージェント思考・推論ブロックを完全に展開して表示します",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "チャットの表示",
|
||||
description: "チャットに表示するエージェントの作業量",
|
||||
toolCallDetail: {
|
||||
label: "ツール呼び出しの表示",
|
||||
description: "タイムラインでのツール呼び出しの表示方法",
|
||||
options: {
|
||||
full: "すべて",
|
||||
collapseToolCalls: "ツール呼び出しを折りたたむ",
|
||||
collapseTurns: "ターンを折りたたむ",
|
||||
overview: "要約",
|
||||
detailed: "すべての詳細",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -1918,13 +1918,6 @@ export const ko: TranslationResources = {
|
||||
backdrop: "메뉴 배경",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "상위 에이전트로 돌아가기",
|
||||
backTo: "{{label}}(으)로 돌아가기",
|
||||
parent: "상위",
|
||||
parentAgent: "상위 에이전트",
|
||||
openParent: "상위 에이전트 열기",
|
||||
},
|
||||
title: "하위 에이전트",
|
||||
pillLabelOne: "하위 에이전트 1개",
|
||||
pillLabelMany: "하위 에이전트 {{count}}개",
|
||||
@@ -2025,12 +2018,6 @@ export const ko: TranslationResources = {
|
||||
input: "입력",
|
||||
output: "출력",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "{{count}} 메시지를 작성했습니다.",
|
||||
other: "{{count}} 메시지를 작성했습니다.",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "{{count}} 파일을 편집했습니다.",
|
||||
@@ -2300,13 +2287,12 @@ export const ko: TranslationResources = {
|
||||
label: "추론 항상 펼치기",
|
||||
description: "에이전트의 사고 및 추론 블록을 기본적으로 모두 펼쳐 표시합니다.",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "채팅 표시",
|
||||
description: "채팅에 표시할 에이전트 작업의 양",
|
||||
toolCallDetail: {
|
||||
label: "도구 호출 표시",
|
||||
description: "타임라인에 도구 호출이 표시되는 방식",
|
||||
options: {
|
||||
full: "전체",
|
||||
collapseToolCalls: "도구 호출 접기",
|
||||
collapseTurns: "턴 접기",
|
||||
overview: "요약",
|
||||
detailed: "전체 세부정보",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -1941,13 +1941,6 @@ export const ptBR: TranslationResources = {
|
||||
backdrop: "Fundo do menu",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Voltar ao pai",
|
||||
backTo: "Voltar para {{label}}",
|
||||
parent: "Pai",
|
||||
parentAgent: "Agente pai",
|
||||
openParent: "Abrir pai",
|
||||
},
|
||||
title: "Subagentes",
|
||||
pillLabelOne: "1 subagente",
|
||||
pillLabelMany: "{{count}} subagentes",
|
||||
@@ -2048,12 +2041,6 @@ 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",
|
||||
@@ -2324,13 +2311,12 @@ export const ptBR: TranslationResources = {
|
||||
description:
|
||||
"Mostrar os blocos de pensamento e raciocínio do agente totalmente expandidos por padrão",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "Exibição do chat",
|
||||
description: "Quanto do trabalho do agente o chat mostra",
|
||||
toolCallDetail: {
|
||||
label: "Exibição de chamadas de ferramentas",
|
||||
description: "Como as chamadas de ferramentas aparecem na linha do tempo",
|
||||
options: {
|
||||
full: "Completo",
|
||||
collapseToolCalls: "Recolher chamadas de ferramentas",
|
||||
collapseTurns: "Recolher turnos",
|
||||
overview: "Resumo",
|
||||
detailed: "Detalhes completos",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -1940,13 +1940,6 @@ export const ru: TranslationResources = {
|
||||
backdrop: "Фон меню",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Назад к родителю",
|
||||
backTo: "Назад к {{label}}",
|
||||
parent: "Родитель",
|
||||
parentAgent: "Родительский агент",
|
||||
openParent: "Открыть родителя",
|
||||
},
|
||||
title: "Субагенты",
|
||||
pillLabelOne: "1 субагент",
|
||||
pillLabelMany: "Субагентов: {{count}}",
|
||||
@@ -2048,12 +2041,6 @@ export const ru: TranslationResources = {
|
||||
input: "Входные данные",
|
||||
output: "Выходные данные",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "написано {{count}} сообщение",
|
||||
other: "написаны сообщения ({{count}})",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "изменён {{count}} файл",
|
||||
@@ -2326,13 +2313,12 @@ export const ru: TranslationResources = {
|
||||
description:
|
||||
"По умолчанию полностью разворачивать блоки размышлений и хода рассуждений агента",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "Отображение чата",
|
||||
description: "Какую часть работы агента показывает чат",
|
||||
toolCallDetail: {
|
||||
label: "Отображение вызовов инструментов",
|
||||
description: "Как вызовы инструментов отображаются на временной шкале",
|
||||
options: {
|
||||
full: "Полностью",
|
||||
collapseToolCalls: "Сворачивать вызовы инструментов",
|
||||
collapseTurns: "Сворачивать ходы",
|
||||
overview: "Сводка",
|
||||
detailed: "Все подробности",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -1886,13 +1886,6 @@ export const zhCN: TranslationResources = {
|
||||
backdrop: "菜单背景",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "返回父代理",
|
||||
backTo: "返回 {{label}}",
|
||||
parent: "父代理",
|
||||
parentAgent: "父代理",
|
||||
openParent: "打开父代理",
|
||||
},
|
||||
title: "Subagent",
|
||||
pillLabelOne: "1 个 subagent",
|
||||
pillLabelMany: "{{count}} 个 subagent",
|
||||
@@ -1993,12 +1986,6 @@ export const zhCN: TranslationResources = {
|
||||
input: "输入",
|
||||
output: "输出",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "写了 {{count}} 条消息",
|
||||
other: "写了 {{count}} 条消息",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "编辑了 {{count}} 个文件",
|
||||
@@ -2264,13 +2251,12 @@ export const zhCN: TranslationResources = {
|
||||
label: "始终展开推理过程",
|
||||
description: "默认情况下完全展开 AI 的思考和推理过程",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "聊天显示",
|
||||
description: "聊天中显示多少智能体的工作",
|
||||
toolCallDetail: {
|
||||
label: "工具调用显示",
|
||||
description: "工具调用在时间线中的显示方式",
|
||||
options: {
|
||||
full: "完整",
|
||||
collapseToolCalls: "折叠工具调用",
|
||||
collapseTurns: "折叠轮次",
|
||||
overview: "摘要",
|
||||
detailed: "完整详情",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -80,7 +80,6 @@ import {
|
||||
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
||||
import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
|
||||
import { AgentTracks, hasAgentTracks } from "@/panels/agent-tracks";
|
||||
import { ParentAgentCallout, useParentAgentId } from "@/subagents/parent";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
@@ -1110,11 +1109,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
});
|
||||
const hasPluginComposerPills = useHasPluginComposerPills(serverId, workspaceId, agentId);
|
||||
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
|
||||
const parentAgentId = useParentAgentId(serverId, agentId);
|
||||
// With a composer the parent is a pill above it; without one, the box takes the composer's place.
|
||||
const parentPillAgentId = showsComposer ? parentAgentId : null;
|
||||
const hasVisibleAgentTracks = hasAgentTracks({
|
||||
parentAgentId: parentPillAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus: archiveFinishedSubagents.status,
|
||||
@@ -1166,9 +1161,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
composerState,
|
||||
],
|
||||
);
|
||||
const parentCallout = parentAgentId ? (
|
||||
<ParentAgentCallout serverId={serverId} parentAgentId={parentAgentId} />
|
||||
) : null;
|
||||
const composerSection = showsComposer ? (
|
||||
<RenderProfile id={`AgentComposerSection:${agentId}`}>
|
||||
<AgentComposerSection
|
||||
@@ -1187,9 +1179,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
onMessageSent={handleMessageSent}
|
||||
/>
|
||||
</RenderProfile>
|
||||
) : (
|
||||
parentCallout
|
||||
);
|
||||
) : null;
|
||||
const streamContent = (
|
||||
<View style={animatedStaticStyles.content}>
|
||||
{showsComposer ? null : <SheetAgentFacts serverId={serverId} agentId={agentId} />}
|
||||
@@ -1213,7 +1203,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={agentId}
|
||||
parentAgentId={parentPillAgentId}
|
||||
subagentRows={subagentRows}
|
||||
tasks={tasks}
|
||||
archiveFinishedStatus={archiveFinishedSubagents.status}
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
useSubagentOpensInSheet,
|
||||
type SubagentRow,
|
||||
} from "@/subagents";
|
||||
import { ParentAgentPill } from "@/subagents/parent";
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
import type { TodoEntry } from "@/types/stream";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
@@ -35,7 +34,6 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
parentAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
@@ -45,7 +43,6 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
parentAgentId: string | null;
|
||||
subagentRows: SubagentRow[];
|
||||
tasks: TodoEntry[] | undefined;
|
||||
archiveFinishedStatus: ArchiveFinishedStatus;
|
||||
@@ -91,12 +88,8 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
[openSubagentTarget, serverId, workspaceId],
|
||||
);
|
||||
const handleOpenProviderSubagent = useCallback(
|
||||
(ownerAgentId: string, subagentId: string) => {
|
||||
openSubagentTarget({
|
||||
kind: "provider_subagent",
|
||||
parentAgentId: ownerAgentId,
|
||||
subagentId,
|
||||
});
|
||||
(parentAgentId: string, subagentId: string) => {
|
||||
openSubagentTarget({ kind: "provider_subagent", parentAgentId, subagentId });
|
||||
},
|
||||
[openSubagentTarget],
|
||||
);
|
||||
@@ -115,7 +108,6 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
if (
|
||||
!hasWorkspaceDiffStat &&
|
||||
!hasAgentTracks({
|
||||
parentAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
@@ -127,7 +119,6 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
|
||||
return (
|
||||
<ComposerTrackBar>
|
||||
{parentAgentId ? <ParentAgentPill serverId={serverId} parentAgentId={parentAgentId} /> : null}
|
||||
<AgentTaskList tasks={tasks} />
|
||||
<SubagentsTrack
|
||||
serverId={serverId}
|
||||
@@ -156,20 +147,17 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
});
|
||||
|
||||
export function hasAgentTracks({
|
||||
parentAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
hasPluginComposerPills = false,
|
||||
}: {
|
||||
parentAgentId: string | null;
|
||||
subagentRows: readonly SubagentRow[];
|
||||
tasks: readonly TodoEntry[] | undefined;
|
||||
archiveFinishedStatus: ArchiveFinishedStatus;
|
||||
hasPluginComposerPills?: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
parentAgentId !== null ||
|
||||
subagentRows.length > 0 ||
|
||||
Boolean(tasks?.length) ||
|
||||
archiveFinishedStatus.kind !== "idle" ||
|
||||
|
||||
@@ -16,7 +16,6 @@ import { usePaneContext } from "@/panels/pane-context";
|
||||
import { PaneSubtitle } from "@/panels/pane-subtitle";
|
||||
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { ParentAgentCallout } from "@/subagents/parent";
|
||||
import { type SubagentRow, useSubagentsForParent } from "@/subagents/select";
|
||||
import { useSubagentOpensInSheet } from "@/subagents/opens-in-sheet";
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
@@ -254,37 +253,33 @@ function ProviderSubagentPanel() {
|
||||
return (
|
||||
<View style={styles.container} testID="provider-subagent-panel">
|
||||
{subtitle ? <PaneSubtitle text={subtitle} testID="provider-subagent-pane-subtitle" /> : null}
|
||||
<View style={styles.stream}>
|
||||
<AgentStreamView
|
||||
agentId={streamId}
|
||||
serverId={serverId}
|
||||
context={streamContext}
|
||||
streamItems={timeline?.tail ?? EMPTY_STREAM_ITEMS}
|
||||
streamHead={timeline?.head ?? EMPTY_STREAM_ITEMS}
|
||||
turnPresentation={turnPresentation}
|
||||
pendingPermissions={EMPTY_PERMISSIONS}
|
||||
isAuthoritativeHistoryReady
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
readOnly
|
||||
historyPagination={historyPagination}
|
||||
bottomOverlayTailClearance={childTrackClearance.tail}
|
||||
bottomOverlayControlClearance={childTrackClearance.controls}
|
||||
/>
|
||||
<ProviderSubagentChildTrack
|
||||
serverId={serverId}
|
||||
rows={childRows}
|
||||
onOpenProviderSubagent={openProviderChild}
|
||||
opensInSheet={opensInSheet}
|
||||
/>
|
||||
</View>
|
||||
<ParentAgentCallout serverId={serverId} parentAgentId={target.parentAgentId} />
|
||||
<AgentStreamView
|
||||
agentId={streamId}
|
||||
serverId={serverId}
|
||||
context={streamContext}
|
||||
streamItems={timeline?.tail ?? EMPTY_STREAM_ITEMS}
|
||||
streamHead={timeline?.head ?? EMPTY_STREAM_ITEMS}
|
||||
turnPresentation={turnPresentation}
|
||||
pendingPermissions={EMPTY_PERMISSIONS}
|
||||
isAuthoritativeHistoryReady
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
readOnly
|
||||
historyPagination={historyPagination}
|
||||
bottomOverlayTailClearance={childTrackClearance.tail}
|
||||
bottomOverlayControlClearance={childTrackClearance.controls}
|
||||
/>
|
||||
<ProviderSubagentChildTrack
|
||||
serverId={serverId}
|
||||
rows={childRows}
|
||||
onOpenProviderSubagent={openProviderChild}
|
||||
opensInSheet={opensInSheet}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: { flex: 1, minHeight: 0 },
|
||||
stream: { flex: 1, minHeight: 0 },
|
||||
unsupported: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
|
||||
unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" },
|
||||
}));
|
||||
|
||||
@@ -8,25 +8,29 @@ import {
|
||||
SettingsSwitch,
|
||||
} from "@/components/settings";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useAppSettings, type ChatDisplay } from "@/hooks/use-settings";
|
||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
|
||||
const CHAT_DISPLAYS: readonly ChatDisplay[] = ["full", "collapseToolCalls", "collapseTurns"];
|
||||
const TOOL_CALL_DETAIL_LEVELS: readonly AppSettings["toolCallDetailLevel"][] = [
|
||||
"detailed",
|
||||
"overview",
|
||||
];
|
||||
|
||||
export function ChatSection() {
|
||||
const { t } = useTranslation();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
|
||||
const chatDisplayOptions = useMemo(
|
||||
const toolCallDetailOptions = useMemo(
|
||||
() =>
|
||||
CHAT_DISPLAYS.map((value) => ({
|
||||
TOOL_CALL_DETAIL_LEVELS.map((value) => ({
|
||||
value,
|
||||
label: t(`settings.general.chatDisplay.options.${value}`),
|
||||
label: t(`settings.general.toolCallDetail.options.${value}`),
|
||||
})),
|
||||
[t],
|
||||
);
|
||||
|
||||
const changeChatDisplay = useCallback(
|
||||
(chatDisplay: ChatDisplay) => void updateSettings({ chatDisplay }),
|
||||
const changeToolCallDetailLevel = useCallback(
|
||||
(toolCallDetailLevel: AppSettings["toolCallDetailLevel"]) =>
|
||||
void updateSettings({ toolCallDetailLevel }),
|
||||
[updateSettings],
|
||||
);
|
||||
const changeAutoExpandReasoning = useCallback(
|
||||
@@ -49,11 +53,11 @@ export function ChatSection() {
|
||||
onValueChange={changeAutoExpandReasoning}
|
||||
/>
|
||||
<SettingsSelect
|
||||
label={t("settings.general.chatDisplay.label")}
|
||||
hint={t("settings.general.chatDisplay.description")}
|
||||
value={settings.chatDisplay}
|
||||
options={chatDisplayOptions}
|
||||
onValueChange={changeChatDisplay}
|
||||
label={t("settings.general.toolCallDetail.label")}
|
||||
hint={t("settings.general.toolCallDetail.description")}
|
||||
value={settings.toolCallDetailLevel}
|
||||
options={toolCallDetailOptions}
|
||||
onValueChange={changeToolCallDetailLevel}
|
||||
/>
|
||||
{isNative ? null : (
|
||||
<SettingsSwitch
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CornerUpLeft } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ComposerTrackMark } from "@/composer/tracks";
|
||||
import { FOOTER_HEIGHT } from "@/constants/layout";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useParentAgent, type ParentAgent } from "./use-parent-agent";
|
||||
|
||||
function ParentIcon({
|
||||
icon: Icon,
|
||||
size,
|
||||
color = "",
|
||||
}: {
|
||||
icon: ParentAgent["icon"];
|
||||
size: number;
|
||||
color?: string;
|
||||
}) {
|
||||
return <Icon size={size} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedParentIcon = withUnistyles(ParentIcon);
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
/** Stands in the composer's place on a subagent shown without one: who the parent is, and the way back. */
|
||||
export function ParentAgentCallout({
|
||||
serverId,
|
||||
parentAgentId,
|
||||
}: {
|
||||
serverId: string;
|
||||
parentAgentId: string;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const parent = useParentAgent(serverId, parentAgentId);
|
||||
|
||||
return (
|
||||
<View style={styles.rail}>
|
||||
<View style={styles.callout} testID="parent-agent-callout">
|
||||
<View style={styles.avatar}>
|
||||
<ThemedParentIcon icon={parent.icon} size={18} uniProps={foregroundColorMapping} />
|
||||
</View>
|
||||
<View style={styles.body}>
|
||||
<Text style={styles.eyebrow}>{t("subagents.parent.parent")}</Text>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{parent.label ?? t("subagents.parent.parentAgent")}
|
||||
</Text>
|
||||
<ComposerTrackMark bucket={parent.statusBucket} />
|
||||
</View>
|
||||
</View>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
leftIcon={CornerUpLeft}
|
||||
onPress={parent.open}
|
||||
testID="parent-agent-callout-open"
|
||||
>
|
||||
{t("subagents.parent.openParent")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
rail: {
|
||||
minHeight: FOOTER_HEIGHT,
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
callout: {
|
||||
width: "100%",
|
||||
maxWidth: theme.contentMaxWidth,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
padding: theme.spacing[3],
|
||||
paddingRight: { xs: theme.spacing[3], md: theme.spacing[4] },
|
||||
},
|
||||
avatar: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[0.5],
|
||||
},
|
||||
eyebrow: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
},
|
||||
title: {
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
@@ -1,3 +0,0 @@
|
||||
export { ParentAgentCallout } from "./callout";
|
||||
export { ParentAgentPill } from "./pill";
|
||||
export { useParentAgentId } from "./use-parent-agent";
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { Text } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronLeft } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { composerPillStyles } from "@/composer/pill-styles";
|
||||
import { ComposerTrackMark } from "@/composer/tracks";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useParentAgent } from "./use-parent-agent";
|
||||
|
||||
const ThemedChevronLeft = withUnistyles(ChevronLeft);
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
/**
|
||||
* The way back from a subagent to the agent that started it. It leads the pill rail and names
|
||||
* the parent rather than saying "parent": in a stack of subagents the title is what tells you
|
||||
* where you will land.
|
||||
*/
|
||||
export function ParentAgentPill({
|
||||
serverId,
|
||||
parentAgentId,
|
||||
}: {
|
||||
serverId: string;
|
||||
parentAgentId: string;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const parent = useParentAgent(serverId, parentAgentId);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
||||
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
||||
const bodyStyle = useMemo(
|
||||
() => [composerPillStyles.body, styles.body, isHovered && composerPillStyles.bodyActive],
|
||||
[isHovered],
|
||||
);
|
||||
const labelStyle = useMemo(
|
||||
() => [composerPillStyles.label, styles.label, isHovered && composerPillStyles.labelActive],
|
||||
[isHovered],
|
||||
);
|
||||
const label = parent.label ?? t("subagents.parent.backToParent");
|
||||
|
||||
return (
|
||||
<PressHighlight
|
||||
testID="composer-parent-agent-pill"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("subagents.parent.backTo", { label })}
|
||||
onPress={parent.open}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
style={bodyStyle}
|
||||
highlightStyle={composerPillStyles.bodyActive}
|
||||
>
|
||||
<ThemedChevronLeft
|
||||
size={14}
|
||||
uniProps={isHovered ? foregroundColorMapping : mutedColorMapping}
|
||||
/>
|
||||
<ComposerTrackMark bucket={parent.statusBucket} />
|
||||
<Text style={labelStyle} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</PressHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// The chevron's box is wider than its stroke, so the pill pulls it toward the edge to keep the
|
||||
// visible glyph on the same inset as other pills' leading marks.
|
||||
body: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[1.5],
|
||||
paddingLeft: theme.spacing[2],
|
||||
},
|
||||
label: {
|
||||
flexShrink: 1,
|
||||
maxWidth: { xs: 140, md: 240 },
|
||||
},
|
||||
}));
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAgentDescriptor } from "@/agent-presentation";
|
||||
import type { PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
|
||||
export interface ParentAgent {
|
||||
/** The parent's title, or null while it has none — callers fall back to a generic name. */
|
||||
label: string | null;
|
||||
icon: PanelDescriptor["icon"];
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
open: () => void;
|
||||
}
|
||||
|
||||
/** The parent of a managed agent, or null for a root or detached agent. */
|
||||
export function useParentAgentId(serverId: string, agentId: string): string | null {
|
||||
return useSessionStore((state) => {
|
||||
const session = state.sessions[serverId];
|
||||
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
|
||||
return agent?.parentAgentId ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
/** The parent as its own tab presents it, and the way back to it. */
|
||||
export function useParentAgent(serverId: string, parentAgentId: string): ParentAgent {
|
||||
const descriptor = useAgentDescriptor({ kind: "agent", agentId: parentAgentId }, { serverId });
|
||||
// The parent may live in another workspace; navigateToAgent opens it wherever it is.
|
||||
const open = useCallback(() => {
|
||||
navigateToAgent({ serverId, agentId: parentAgentId });
|
||||
}, [parentAgentId, serverId]);
|
||||
return {
|
||||
label: descriptor.label || null,
|
||||
icon: descriptor.icon,
|
||||
statusBucket: descriptor.statusBucket,
|
||||
open,
|
||||
};
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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,5 +1,4 @@
|
||||
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_";
|
||||
@@ -29,18 +28,19 @@ function isSearchCall(name: string): boolean {
|
||||
return DIRECT_SEARCH_TOOL_SUFFIX_PATTERN.test(name);
|
||||
}
|
||||
|
||||
/** Counts tool calls by what they did, for any summary of a stretch of agent work. */
|
||||
export function summarizeToolCalls(calls: readonly ToolCallItem[]): OverviewSummary {
|
||||
export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup {
|
||||
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 calls) {
|
||||
for (const call of run.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 summarizeToolCalls(calls: readonly ToolCallItem[]): OverviewSumm
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
const summary = {
|
||||
editedFileCount: editedFiles.size,
|
||||
commandCount,
|
||||
readFileCount: readFiles.size,
|
||||
@@ -64,17 +64,10 @@ export function summarizeToolCalls(calls: readonly ToolCallItem[]): OverviewSumm
|
||||
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: summarizeToolCalls(run.calls),
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React, { memo, useCallback, useRef, type ReactNode } from "react";
|
||||
import React, { memo, useCallback, useMemo, 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 { useOverviewSummaryLabel } from "./label";
|
||||
import type { OverviewToolCallGroup } from "./model";
|
||||
import { type OverviewSummary, type OverviewToolCallGroup } from "./model";
|
||||
import { OverviewToolCallGroupSheet } from "./sheet";
|
||||
|
||||
interface OverviewGroupProps {
|
||||
@@ -19,6 +18,41 @@ 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,
|
||||
@@ -28,7 +62,7 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView
|
||||
}: OverviewGroupProps) {
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const aggregateSummary = useOverviewSummaryLabel(group.summary);
|
||||
const aggregateSummary = useOverviewSummary(group.summary);
|
||||
const scrollToLatest = useCallback(() => {
|
||||
scrollRef.current?.scrollToEnd({ animated: false });
|
||||
}, []);
|
||||
@@ -48,7 +82,7 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView
|
||||
showsVerticalScrollIndicator
|
||||
onContentSizeChange={scrollToLatest}
|
||||
>
|
||||
<IndentRail>{children}</IndentRail>
|
||||
{children}
|
||||
</ScrollView>
|
||||
),
|
||||
[children, scrollToLatest],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { ToolCallDetailLevel } from "@/hooks/use-settings/storage";
|
||||
import {
|
||||
groupLiveToolCalls,
|
||||
prepareGroupedHistory,
|
||||
@@ -7,8 +8,7 @@ import {
|
||||
} from "./grouping";
|
||||
import { buildOverviewGroup, type OverviewToolCallGroup } from "./overview/model";
|
||||
|
||||
/** Whether consecutive tool calls show one by one or as one summary row. */
|
||||
export type ToolCallDetailLevel = "overview" | "detailed";
|
||||
export type { ToolCallDetailLevel } from "@/hooks/use-settings/storage";
|
||||
export type ToolCallDetailGroup = OverviewToolCallGroup;
|
||||
|
||||
export interface PreparedToolCallHistory {
|
||||
|
||||
@@ -771,12 +771,6 @@ 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";
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
// The package ships JSX in .js files, which the dependency optimizer cannot parse.
|
||||
export async function setStringAsync(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
export async function getStringAsync(): Promise<string> {
|
||||
return "";
|
||||
}
|
||||
export async function hasStringAsync(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
@@ -111,8 +111,3 @@ export const Upload = StubIcon;
|
||||
export const Wrench = StubIcon;
|
||||
export const X = StubIcon;
|
||||
export const XCircle = StubIcon;
|
||||
export const Code = StubIcon;
|
||||
export const Maximize2 = StubIcon;
|
||||
export const Workflow = StubIcon;
|
||||
export const ZoomIn = StubIcon;
|
||||
export const ZoomOut = StubIcon;
|
||||
|
||||
@@ -19,20 +19,6 @@ const resolvePackageEntry = (packageName: string) => {
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
{
|
||||
name: "markdown-wasm-commonjs-export",
|
||||
transform(source, id) {
|
||||
// The ESM package contains a CommonJS WASM factory. Metro handles the
|
||||
// interop; Vite needs an ESM export instead of the CommonJS trailer.
|
||||
if (!id.endsWith("/lib/module/web/wasm/md4c.js")) return;
|
||||
const trailer = source.lastIndexOf("\nif (typeof exports === 'object'");
|
||||
if (trailer < 0) throw new Error("Markdown WASM CommonJS trailer was not found");
|
||||
return {
|
||||
code: `${source.slice(0, trailer)}\nexport default createMd4cModule;`,
|
||||
map: null,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "compile-usage-components",
|
||||
enforce: "pre",
|
||||
@@ -165,7 +151,6 @@ export default defineConfig({
|
||||
"react-native-gesture-handler > invariant",
|
||||
],
|
||||
exclude: [
|
||||
"react-native-enriched-markdown",
|
||||
"react-native-reanimated",
|
||||
"react-native-gesture-handler",
|
||||
"react-native-keyboard-controller",
|
||||
@@ -269,10 +254,6 @@ export default defineConfig({
|
||||
find: /^react-native-reanimated\/scripts\/validate-worklets-version$/,
|
||||
replacement: path.resolve(__dirname, "test-stubs/reanimated-validate-worklets-version.ts"),
|
||||
},
|
||||
{
|
||||
find: /^expo-clipboard$/,
|
||||
replacement: path.resolve(__dirname, "test-stubs/expo-clipboard.ts"),
|
||||
},
|
||||
{
|
||||
find: /^expo-linking$/,
|
||||
replacement: path.resolve(__dirname, "test-stubs/expo-linking.ts"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -28,9 +28,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.13.0",
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"@getpaseo/server": "0.13.0",
|
||||
"@getpaseo/client": "0.13.1",
|
||||
"@getpaseo/protocol": "0.13.1",
|
||||
"@getpaseo/server": "0.13.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -47,8 +47,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"@getpaseo/relay": "0.13.0",
|
||||
"@getpaseo/protocol": "0.13.1",
|
||||
"@getpaseo/relay": "0.13.1",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { execFile, spawnSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
const run = promisify(execFile);
|
||||
const hasSsh = !spawnSync("ssh", ["-V"]).error;
|
||||
|
||||
test.skipIf(!hasSsh)(
|
||||
"an SSH peer abort during a pending write closes the tunnel without crashing its caller",
|
||||
async () => {
|
||||
// Isolate the caller so an unhandled stream error fails this assertion instead
|
||||
// of crashing the test worker. Both the TCP peer and OpenSSH are real.
|
||||
const { stdout } = await run(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
`
|
||||
import { createServer, createConnection } from 'node:net';
|
||||
import { once } from 'node:events';
|
||||
import { createSshTunnel } from ${JSON.stringify(new URL("./ssh-tunnel.ts", import.meta.url).href)};
|
||||
|
||||
const remote = createServer(socket => socket.destroy());
|
||||
remote.listen(0, '127.0.0.1');
|
||||
await once(remote, 'listening');
|
||||
const tunnel = await createSshTunnel({
|
||||
host: '127.0.0.1', sshPort: remote.address().port, daemonPort: 6767,
|
||||
});
|
||||
const [host, port] = tunnel.endpoint.split(':');
|
||||
const client = createConnection({ host, port: Number(port) });
|
||||
client.on('error', () => undefined);
|
||||
try {
|
||||
const closed = new Promise(resolve => client.once('close', resolve));
|
||||
await once(client, 'connect');
|
||||
client.write(Buffer.alloc(8 * 1024 * 1024, 65));
|
||||
await closed;
|
||||
console.log(JSON.stringify({ closed: client.destroyed, failure: tunnel.failureDetail() }));
|
||||
} finally {
|
||||
client.destroy();
|
||||
tunnel.close();
|
||||
await new Promise(resolve => remote.close(resolve));
|
||||
}
|
||||
`,
|
||||
],
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
expect(JSON.parse(stdout)).toEqual({
|
||||
closed: true,
|
||||
failure: expect.any(String),
|
||||
});
|
||||
expect(JSON.parse(stdout).failure).not.toBe("");
|
||||
},
|
||||
);
|
||||
@@ -54,12 +54,10 @@ export function createSshTunnel(target: SshTransportTarget): Promise<SshTunnel>
|
||||
child.stderr.on("data", (chunk: Buffer | string) => {
|
||||
stderr = `${stderr}${chunk.toString()}`.slice(-SSH_STDERR_LIMIT);
|
||||
});
|
||||
const fail = (error: Error) => {
|
||||
child.on("error", (error) => {
|
||||
failure = error.message;
|
||||
acceptedSocket.destroy(error);
|
||||
};
|
||||
child.on("error", fail);
|
||||
child.stdin.on("error", fail);
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
if (code !== 0 || signal) failure = formatSshFailure(stderr, code, signal);
|
||||
acceptedSocket.destroy(failure ? new Error(failure) : undefined);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"description": "Paseo plugin SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -72,8 +72,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.13.0",
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"@getpaseo/client": "0.13.1",
|
||||
"@getpaseo/protocol": "0.13.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.2.3",
|
||||
@@ -83,8 +83,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.13.0",
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"@getpaseo/client": "0.13.1",
|
||||
"@getpaseo/protocol": "0.13.1",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -107,11 +107,11 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.13.0",
|
||||
"@getpaseo/highlight": "0.13.0",
|
||||
"@getpaseo/plugin": "0.13.0",
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"@getpaseo/relay": "0.13.0",
|
||||
"@getpaseo/client": "0.13.1",
|
||||
"@getpaseo/highlight": "0.13.1",
|
||||
"@getpaseo/plugin": "0.13.1",
|
||||
"@getpaseo/protocol": "0.13.1",
|
||||
"@getpaseo/relay": "0.13.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import fs, { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import pino from "pino";
|
||||
@@ -7,9 +7,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import { claudeProjectDirSync } from "./project-dir.js";
|
||||
import { withClaudeReplayHistory } from "./transcript-history.js";
|
||||
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import type { ClaudeQueryFactory } from "./query.js";
|
||||
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
|
||||
|
||||
/**
|
||||
* A resumed Claude agent whose transcript is missing or unreadable opens with an empty timeline.
|
||||
@@ -32,18 +29,19 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
let configDir: string;
|
||||
let records: LogRecord[];
|
||||
|
||||
async function resume(queryFactory?: ClaudeQueryFactory): Promise<AgentSession> {
|
||||
async function resume(): Promise<void> {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: pino({ level: "info" }, { write: (line: string) => records.push(JSON.parse(line)) }),
|
||||
queryFactory:
|
||||
queryFactory ??
|
||||
(() => {
|
||||
throw new Error("history load must not start a query");
|
||||
}),
|
||||
resolveBinary: async () => process.execPath,
|
||||
queryFactory: vi.fn(() => {
|
||||
throw new Error("history load must not start a query");
|
||||
}),
|
||||
resolveVersion: async () => "2.1.220",
|
||||
});
|
||||
return client.resumeSession({ provider: "claude", sessionId: SESSION_ID }, { cwd });
|
||||
const session = await client.resumeSession(
|
||||
{ provider: "claude", sessionId: SESSION_ID },
|
||||
{ cwd },
|
||||
);
|
||||
await session.close();
|
||||
}
|
||||
|
||||
function transcriptPath(): string {
|
||||
@@ -60,218 +58,12 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.each(["file_path", "files"])(
|
||||
"replays %s without probing targets and preserves live existence checks",
|
||||
async (field) => {
|
||||
const existingPath = path.join(cwd, "existing.txt");
|
||||
const missingPath = path.join(cwd, "missing.txt");
|
||||
writeFileSync(existingPath, "existing");
|
||||
const targets = [existingPath, missingPath];
|
||||
const inputs = targets.map((target) => ({
|
||||
[field]: field === "files" ? [target] : target,
|
||||
}));
|
||||
const toolBlocks = inputs.map((input, index) => ({
|
||||
type: "tool_use",
|
||||
id: `change-${index}`,
|
||||
name: "FileChange",
|
||||
input,
|
||||
}));
|
||||
const resultBlocks = toolBlocks.map((block) => ({
|
||||
type: "tool_result",
|
||||
tool_use_id: block.id,
|
||||
content: "done",
|
||||
}));
|
||||
const transcript = [
|
||||
{ type: "user", message: { content: "Change files" } },
|
||||
{ type: "assistant", message: { content: toolBlocks } },
|
||||
{ type: "user", message: { content: resultBlocks } },
|
||||
{ type: "assistant", message: { content: "Finished" } },
|
||||
];
|
||||
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
|
||||
writeFileSync(transcriptPath(), transcript.map((entry) => JSON.stringify(entry)).join("\n"));
|
||||
|
||||
// Observe the real filesystem calls; neither existing nor missing targets are mocked.
|
||||
const exists = vi.spyOn(fs, "existsSync");
|
||||
const liveMessages = [
|
||||
{ type: "assistant", message: { content: toolBlocks } },
|
||||
{ type: "user", message: { content: resultBlocks } },
|
||||
{ type: "result", subtype: "success", total_cost_usd: 0 },
|
||||
];
|
||||
const queryFactory = vi.fn();
|
||||
queryFactory.mockReturnValue({
|
||||
async next() {
|
||||
const value = liveMessages.shift();
|
||||
return value ? { done: false, value } : { done: true, value: undefined };
|
||||
},
|
||||
close() {},
|
||||
async return() {
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
async supportedCommands() {
|
||||
return [];
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
});
|
||||
const session = await resume(queryFactory);
|
||||
try {
|
||||
const history: AgentStreamEvent[] = [];
|
||||
for await (const event of session.streamHistory()) history.push(event);
|
||||
expect(exists.mock.calls.filter(([target]) => targets.includes(String(target)))).toEqual(
|
||||
[],
|
||||
);
|
||||
const running = inputs.map((input, index) => ({
|
||||
type: "tool_call",
|
||||
callId: `change-${index}`,
|
||||
name: "FileChange",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "unknown", input, output: null },
|
||||
}));
|
||||
const completed = inputs.map((input, index) => ({
|
||||
type: "tool_call",
|
||||
callId: `change-${index}`,
|
||||
name: "FileChange",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input,
|
||||
output: {
|
||||
output: "done",
|
||||
files: [{ path: index === 0 ? "existing.txt" : "missing.txt", kind: "update" }],
|
||||
},
|
||||
},
|
||||
}));
|
||||
expect(history).toEqual(
|
||||
[
|
||||
{ type: "user_message", text: "Change files" },
|
||||
...running,
|
||||
...completed,
|
||||
{ type: "assistant_message", text: "Finished" },
|
||||
].map((item) => ({ type: "timeline", provider: "claude", timestamp: undefined, item })),
|
||||
);
|
||||
|
||||
exists.mockClear();
|
||||
const live = await collectSessionTurnEvents(session, "Change files again");
|
||||
expect(exists.mock.calls.filter(([target]) => targets.includes(String(target)))).toEqual([
|
||||
[existingPath],
|
||||
[missingPath],
|
||||
]);
|
||||
const liveCompleted = live.flatMap((event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.status === "completed"
|
||||
? [event.item]
|
||||
: [],
|
||||
);
|
||||
const missing = completed[1];
|
||||
expect(liveCompleted).toEqual([
|
||||
completed[0],
|
||||
{
|
||||
...missing,
|
||||
detail: {
|
||||
...missing.detail,
|
||||
output: { output: "done", files: [{ path: "missing.txt", kind: "add" }] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["parent", "subagent"])(
|
||||
"replays %s Write and Edit calls without probing their targets",
|
||||
async (scope) => {
|
||||
const filePath = path.join(cwd, "unavailable", "file.txt");
|
||||
const content = ["Write", "Edit"].map((name) => ({
|
||||
type: "tool_use",
|
||||
id: name,
|
||||
name,
|
||||
input: { file_path: filePath },
|
||||
}));
|
||||
const entry = { type: "assistant", message: { content } };
|
||||
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
|
||||
if (scope === "subagent") {
|
||||
const directory = path.join(path.dirname(transcriptPath()), SESSION_ID, "subagents");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(directory, "agent-child.meta.json"),
|
||||
JSON.stringify({ toolUseId: "task" }),
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(directory, "agent-child.jsonl"),
|
||||
JSON.stringify({
|
||||
...entry,
|
||||
isSidechain: true,
|
||||
agentId: "child",
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
transcriptPath(),
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "task",
|
||||
name: "Task",
|
||||
input: { description: "Change files" },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
writeFileSync(transcriptPath(), JSON.stringify(entry));
|
||||
}
|
||||
|
||||
const exists = vi.spyOn(fs, "existsSync");
|
||||
const session = await resume();
|
||||
try {
|
||||
const items = [];
|
||||
for await (const event of session.streamHistory()) {
|
||||
if (scope === "parent" && event.type === "timeline") items.push(event.item);
|
||||
if (event.type === "provider_subagent" && event.event.type === "timeline") {
|
||||
items.push(event.event.item);
|
||||
}
|
||||
}
|
||||
expect(exists.mock.calls.filter(([target]) => target === filePath)).toEqual([]);
|
||||
expect(items).toEqual([
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "Write",
|
||||
name: "Write",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "write", filePath },
|
||||
},
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "Edit",
|
||||
name: "Edit",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "edit", filePath },
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("logs the resolved transcript path when the transcript does not exist", async () => {
|
||||
await (await resume()).close();
|
||||
await resume();
|
||||
|
||||
expect(records).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -287,7 +79,7 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
// A directory at the transcript path exists but cannot be read as a file.
|
||||
mkdirSync(transcriptPath(), { recursive: true });
|
||||
|
||||
await (await resume()).close();
|
||||
await resume();
|
||||
|
||||
expect(records).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -356,7 +148,7 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
chmodSync(path.join(configDir, "projects"), 0o000);
|
||||
|
||||
try {
|
||||
await (await resume()).close();
|
||||
await resume();
|
||||
} finally {
|
||||
chmodSync(path.join(configDir, "projects"), 0o755);
|
||||
}
|
||||
|
||||
@@ -2326,7 +2326,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
{ type: "provider_subagent" }
|
||||
>[] = [];
|
||||
private historyPending = false;
|
||||
private replayingHistory = false;
|
||||
private turnState: TurnState = "idle";
|
||||
private nextTurnOrdinal = 1;
|
||||
private cancelCurrentTurn: (() => void) | null = null;
|
||||
@@ -5254,7 +5253,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
private loadPersistedHistory(sessionId: string): void {
|
||||
let historyPath: string | null = null;
|
||||
this.replayingHistory = true;
|
||||
try {
|
||||
this.taskState.reset();
|
||||
historyPath = this.resolveHistoryPath(sessionId);
|
||||
@@ -5274,8 +5272,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
{ err: error, sessionId, historyPath },
|
||||
"Failed to load Claude history from transcript",
|
||||
);
|
||||
} finally {
|
||||
this.replayingHistory = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6043,11 +6039,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private detectFileKind(filePath: string): string {
|
||||
// A historical target may be on an unreachable mount; its current state says nothing
|
||||
// about whether the past operation created or updated it.
|
||||
if (this.replayingHistory) {
|
||||
return "update";
|
||||
}
|
||||
try {
|
||||
return fs.existsSync(filePath) ? "update" : "add";
|
||||
} catch {
|
||||
|
||||
@@ -295,35 +295,6 @@ function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRe
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/with\s+links/i.test(text)) {
|
||||
return {
|
||||
questions: [
|
||||
{
|
||||
question:
|
||||
"Sign in at https://example.com/login, then follow [the setup guide](https://example.com/setup). Done?",
|
||||
header: "signIn",
|
||||
options: [
|
||||
{
|
||||
label: "Done",
|
||||
description: "Logs are at https://example.com/logs if sign-in fails.",
|
||||
},
|
||||
{ label: "Not yet, see [status](https://example.com/status)" },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
question: "Which checks should run?",
|
||||
header: "checks",
|
||||
options: [
|
||||
{ label: "Lint", description: "Rules: https://example.com/lint" },
|
||||
{ label: "Typecheck" },
|
||||
],
|
||||
multiSelect: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (/free[-\s]?write|freeform|text[-\s]?only/i.test(text)) {
|
||||
return {
|
||||
questions: [
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import type { SessionInfo } from "@opencode/client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
|
||||
import { V2Harness } from "../test-utils/v2-harness.js";
|
||||
import { SessionChildren } from "./children.js";
|
||||
|
||||
function setup() {
|
||||
const harness = new V2Harness();
|
||||
const child: SessionInfo = {
|
||||
...harness.info,
|
||||
id: "child",
|
||||
parentID: "session",
|
||||
title: "Check the worker model",
|
||||
agent: "worker",
|
||||
model: { providerID: "test", id: "worker-model", variant: "xhigh" },
|
||||
tokens: { input: 10000, output: 3000, reasoning: 2000, cache: { read: 3000, write: 800 } },
|
||||
};
|
||||
harness.api.session.list = async (input) => ({
|
||||
data: input?.parentID === "session" ? [child] : [],
|
||||
cursor: {},
|
||||
});
|
||||
harness.api.session.get = async () => child;
|
||||
const events: AgentStreamEvent[] = [];
|
||||
const children = new SessionChildren({
|
||||
client: () => harness.api,
|
||||
id: "session",
|
||||
emit: (event) => events.push(event),
|
||||
reconcilePermissions: async () => {},
|
||||
});
|
||||
const upserts = () =>
|
||||
events.flatMap((event) =>
|
||||
event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [],
|
||||
);
|
||||
return { children, child, upserts };
|
||||
}
|
||||
|
||||
describe("OpenCode v2 child presentation", () => {
|
||||
test("restores agent, model, variant and all token categories in the child subtitle", async () => {
|
||||
const { children, upserts } = setup();
|
||||
await children.reconcile("session");
|
||||
expect(upserts()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "child",
|
||||
title: "Check the worker model",
|
||||
parentSubagentId: null,
|
||||
status: "completed",
|
||||
subtitle: "worker · worker-model · Extra High · 18.8k tokens",
|
||||
}),
|
||||
]);
|
||||
await children.reconcile("session");
|
||||
expect(upserts()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("publishes changed child facts on live events even when its timestamp is unchanged", async () => {
|
||||
const { children, child, upserts } = setup();
|
||||
await children.observe({ id: "created", created: 1, type: "session.created", data: child });
|
||||
child.model = { providerID: "test", id: "next-model", variant: "low" };
|
||||
child.tokens.output += 1200;
|
||||
await children.observe({
|
||||
id: "finished",
|
||||
created: 2,
|
||||
type: "session.execution.succeeded",
|
||||
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||
data: { sessionID: "child" },
|
||||
});
|
||||
expect(upserts().map((event) => event.subtitle)).toEqual([
|
||||
"worker · worker-model · Extra High · 18.8k tokens",
|
||||
"worker · next-model · Low · 20k tokens",
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps available agent information before a child has a model or token usage", async () => {
|
||||
const { children, child, upserts } = setup();
|
||||
delete child.model;
|
||||
child.tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } };
|
||||
await children.reconcile("session");
|
||||
expect(upserts()[0]).toMatchObject({ title: "Check the worker model", subtitle: "worker" });
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user