Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andlucas c532f32227 fix(app): preserve short terminal runs between accented glyphs
Short ASCII runs split by fallback glyphs can wrap or ellipsize in Android
Text when their layout width equals their cell reservation. Give text the
same extra layout room already used for isolated glyphs, while ordinary
run boxes keep clipping paint at their original cell boundaries.

Co-authored-by: lucas <lucas@withvus.ai>
2026-10-24 16:40:41 +00:00
paseo-bot[bot] 31f9657679 Merge commit 'a25a8ac676855c3f379d4f7ac89c0e6e42db5493' into bot/terminal-spacing-0e346cd5 2026-10-24 16:30:49 +00:00
Lucas a25a8ac676 Merge branch 'main' into fix/native-terminal-cjk-cursor-drift 2026-10-01 13:10:41 +09:00
lucas c08657d22a Keep native terminal text on its cells so the cursor stays after Korean input
The native grid renderer draws each style run as one Text, so glyphs advance
by their font's natural width while the cursor sits on the cell grid.

- Hangul, Han and kana fall back to a system font narrower than two cells
  (10.38pt vs 14.67pt at size 12), so every wide glyph pulled the rest of the
  run left. Ten syllables left the cursor about six columns past the text.
  Each script's advance is now measured once and its segment is letter-spaced
  to exactly two cells per glyph, without extra views.
- Cell width is snapped to the pixel grid (7.225pt -> 7.333pt), so a 49
  column ASCII line also ended about 0.7 cell short of the cursor. ASCII text
  now gets the matching letter spacing.
- Other non-ASCII glyphs (symbols, emoji, jamo) have no known advance, so
  they get their own box and may spill into the next cell instead of
  shifting the run.
2026-10-01 11:20:42 +09:00
159 changed files with 878 additions and 6278 deletions
+3 -49
View File
@@ -25,54 +25,8 @@ Reload releases the old runtime before resuming its durable session: an idle pro
still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the
replacement. Once closure succeeds, a failed resume leaves the durable agent closed and retryable.
Idle resumable runtimes are collected after one hour without activity, checked once a minute.
Collection releases the process and preserves the agent record, conversation, relationships, and
provider session. The next prompt resumes that session. Set `agents.idleRuntimeCleanup: false` in
host config to disable automatic collection. The host setting applies immediately; config-file edits
apply on reload. Disabling invalidates pending safety checks and close admission. A close already
started finishes. Re-enabling keeps the existing idle timestamps.
Foreground runs, permissions and responses, queued deliveries, lifecycle operations, and working
managed or native descendants prevent collection. Outstanding local or remote finish subscriptions
also protect their caller, including registration, recovery and notification dispatch acknowledgment.
An idle managed child can still own background work.
The daemon baseline always applies. An optional provider `canStopRuntime()` adds a veto: false,
errors and timeouts retain ownership, while true cannot override daemon blockers. Without a provider
check, the baseline decides collection after the same one-hour idle period. Once protected work
finishes, a fresh idle hour starts. There is no deadline that overrides a veto.
The `idle-runtime-cleanup/` module owns the clocks, eligibility policy and sweep lifetime.
AgentManager supplies captured runtime capabilities and owns atomic revalidation and shutdown.
Finish subscriptions expose caller protection through their public capability; the collector never
reads their watches or durable records.
The collector rechecks daemon state after asynchronous probes and event draining, in the existing
close/load lifecycle lane. A prompt admitted before closure prevents collection. A prompt arriving
after closure starts waits for release and resumes the saved session.
Provider checks follow the signals their runtimes expose:
- Claude uses its process-local background roster and pending completion notifications. A fresh
process starts empty. CLI 2.1.292 or newer is known to support the roster; older binaries must
demonstrate support by emitting one. Unknown versions stay resident until then.
- Codex queries background terminals for loaded threads, including native children, and protects
pending provider requests. App servers without the terminal-list RPC stay resident.
- OpenCode checks active executions and descendants. V1 checks PTYs; v2 also checks shell and
inbox inventories. The shared server remains leased while a session vetoes collection.
- Pi checks streaming, compaction, pending replies, and adapter-owned operations. Known native
subagent signals also protect their parent through the daemon's subagent store.
- OMP additionally requires `get_state.isSettled: true` and `hasPendingAsyncWork: false`.
These protect jobs with unsuppressed completion delivery, pending deliveries, queued result
injection, and admitted or scheduled continuations that can re-wake the session. Older OMP
runtimes without either field stay resident. These signals are not an inventory of arbitrary
processes.
- ACP checks adapter-owned foreground turns, permissions, buffered user messages, and running
ACP terminals, including quiet terminals. Exited or failed-to-spawn terminal records do not
prevent collection. This does not inspect processes launched internally by the provider.
These checks do not discover arbitrary detached processes or work hidden by third-party
extensions. A provider plugin adds its own veto through the [runtime safety contract](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
A provider runtime can still die on its own — crash, OOM kill, host suspend. Work the agent parked
inside that process dies with it: Claude Code's background Bash shells, `Monitor` watches, and
@@ -288,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.
-5
View File
@@ -498,11 +498,6 @@ Validate and apply it inside the provider; core does not know your option shape.
See [provider options](custom-providers.md#provider-options) for configuration and
merge semantics.
Providers add an optional veto to daemon baseline cleanup through the per-session
[runtime safety query](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
The daemon calls the same `AgentSession.canStopRuntime()` interface for built-ins and plugins;
the plugin bridge transports that query through the existing provider request/event protocol.
Agent refresh closes the current provider session and opens it again with current configuration and
persistence. Re-read credentials and provider-owned configuration on `session.open`; consume the
daemon launch from `connect` and the per-session env and MCP servers from `session.open`. There is
@@ -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);
@@ -1,81 +0,0 @@
import { expect, test, type Page } from "../support/fixtures";
import {
composerLocator,
expectComposerDraft,
expectComposerVisible,
submitMessage,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
function userMessages(page: Page) {
return page.getByTestId("user-message");
}
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
await submitMessage(page, text);
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
await expectComposerDraft(page, "");
}
async function pressInComposer(page: Page, key: string) {
await composerLocator(page).press(key);
}
test.describe("Composer recall", () => {
test("Up and Down walk sent messages from an empty composer", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-recall-",
title: "Composer recall",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await sendAndWaitForMessage(page, "fix the auth bug", 1);
await sendAndWaitForMessage(page, "add tests", 2);
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "fix the auth bug");
await pressInComposer(page, "ArrowDown");
await expectComposerDraft(page, "add tests");
await pressInComposer(page, "ArrowDown");
await expectComposerDraft(page, "");
await pressInComposer(page, "ArrowUp");
await typeIntoFocusedComposer(page, " for login");
await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "Enter");
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
await expect(userMessages(page).last()).toContainText("add tests for login");
} finally {
await session.cleanup();
}
});
test("a recalled slash message does not open the command picker", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-recall-slash-",
title: "Composer recall slash",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await sendAndWaitForMessage(page, "/tdd", 1);
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "/tdd");
await expect(popover).toHaveCount(0);
await typeIntoFocusedComposer(page, "x");
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
} finally {
await session.cleanup();
}
});
});
@@ -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);
@@ -1,7 +1,3 @@
import { readFile, mkdir, rename, rm } from "node:fs/promises";
import path from "node:path";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
@@ -29,8 +25,6 @@ import {
removeHostFromHostPage,
addDirectHostFromSettings,
expectHostOnlineWithoutError,
expectIdleAgentRelease,
setIdleAgentRelease,
} from "../support/helpers/settings";
test.describe("Settings host page", () => {
@@ -143,100 +137,6 @@ test.describe("Settings host page", () => {
}
});
test("idle agent release changes live on one host and stays saved without restarting", async ({
page,
}) => {
test.setTimeout(120_000);
const primaryServerId = getServerId();
const daemon = await startIsolatedHostDaemon("srv_e2e_idle_agent_release");
const client = await connectDaemonClient<DaemonClient>({
clientIdPrefix: "idle-release-settings",
port: daemon.port,
});
const originalPid = (await client.getDaemonStatus()).pid;
const expectLiveSavedSetting = async (enabled: boolean) => {
const saved = JSON.parse(await readFile(path.join(daemon.paseoHome, "config.json"), "utf8"));
expect(saved.agents.idleRuntimeCleanup).toBe(enabled);
expect((await client.getDaemonConfig()).config.idleRuntimeCleanup).toBe(enabled);
expect((await client.reloadDaemonConfig()).restartRequiredPaths).toEqual([]);
expect((await client.getDaemonStatus()).pid).toBe(originalPid);
};
const hosts = [
{
serverId: primaryServerId,
label: TEST_HOST_LABEL,
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
},
{
serverId: daemon.serverId,
label: "Idle release host",
endpoint: `127.0.0.1:${daemon.port}`,
},
];
const openHostAgentsInFreshApp = async (serverId: string) => {
await seedSavedSettingsHosts(page, hosts);
await page.reload();
await openSettings(page);
await selectSettingsHost(page, serverId);
await openHostSection(page, serverId, "agents");
};
try {
await test.step("idle agent release starts on and turns off", async () => {
await openHostAgentsInFreshApp(daemon.serverId);
await expectIdleAgentRelease(page, true);
await setIdleAgentRelease(page, false);
await expectLiveSavedSetting(false);
});
await test.step("a fresh app reads the saved setting from the same running host", async () => {
await openHostAgentsInFreshApp(daemon.serverId);
await expectIdleAgentRelease(page, false);
await expectLiveSavedSetting(false);
await setIdleAgentRelease(page, true);
await expectLiveSavedSetting(true);
await setIdleAgentRelease(page, false);
});
await test.step("other hosts keep their own setting", async () => {
await selectSettingsHost(page, primaryServerId);
await openHostSection(page, primaryServerId, "agents");
await expectIdleAgentRelease(page, true);
await expectLiveSavedSetting(false);
});
} finally {
await client.close();
await daemon.close();
}
});
test("a failed idle release save stays visible and retry applies it live", async ({ page }) => {
const daemon = await startIsolatedHostDaemon("srv_e2e_idle_release_failure");
const configPath = path.join(daemon.paseoHome, "config.json");
try {
await seedSavedSettingsHosts(page, [
{ serverId: daemon.serverId, label: "Failure host", endpoint: `127.0.0.1:${daemon.port}` },
]);
await page.reload();
await openSettings(page);
await openHostSection(page, daemon.serverId, "agents");
await expectIdleAgentRelease(page, true);
// A real filesystem failure, with the daemon and RPC path intact.
await rename(configPath, `${configPath}.saved`);
await mkdir(configPath);
await page.getByRole("switch", { name: "Release idle agents" }).click();
await expect(page.getByTestId("host-page-idle-runtime-cleanup-error")).toContainText(
"Failed to read",
);
await expectIdleAgentRelease(page, true);
await rm(configPath, { recursive: true });
await rename(`${configPath}.saved`, configPath);
await setIdleAgentRelease(page, false);
await expect(page.getByTestId("host-page-idle-runtime-cleanup-error")).toHaveCount(0);
} finally {
await daemon.close();
}
});
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
page,
}) => {
@@ -6,10 +6,7 @@ import {
addDirectHostFromSettings,
clickSettingsBackToWorkspace,
openSettingsHostSection,
openSettingsSection,
selectSettingsHost,
} from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import { seedWorkspace } from "../support/helpers/seed-client";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
@@ -47,14 +44,6 @@ test.describe("Settings host selection", () => {
await openSettings(page);
await openSettingsHostSection(page, remoteDaemon.serverId, "connections");
await selectSettingsHost(page, getServerId());
await openSettingsSection(page, "appearance");
await openSettingsHostSection(page, getServerId(), "connections");
await clickSettingsBackToWorkspace(page);
await openSettings(page);
await openSettingsHostSection(page, remoteDaemon.serverId, "connections");
} finally {
await remoteWorkspace.cleanup().catch(() => undefined);
await remoteDaemon.close().catch(() => undefined);
@@ -147,28 +147,6 @@ test.describe("Settings sidebar navigation", () => {
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
});
test("host choice survives app sections and host screen replacements", async ({ page }) => {
const primaryServerId = getServerId();
const secondaryServerId = "srv_e2e_settings_secondary";
const endpoint = `127.0.0.1:${getE2EDaemonPort()}`;
await seedSavedSettingsHosts(page, [
{ serverId: primaryServerId, label: "First horse", endpoint },
{ serverId: secondaryServerId, label: "Stable horse", endpoint },
]);
await gotoAppShell(page);
await openSettings(page);
await selectSettingsHost(page, secondaryServerId);
await openSettingsHostSection(page, secondaryServerId, "connections");
await openSettingsSection(page, "appearance");
await expectSettingsHostPickerLabel(page, "Stable horse");
await openSettingsSection(page, "general");
await expectSettingsHostPickerLabel(page, "Stable horse");
await openSettingsHostSection(page, secondaryServerId, "connections");
await selectSettingsHost(page, primaryServerId);
await openSettingsSection(page, "about");
await expectSettingsHostPickerLabel(page, "First horse");
});
test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => {
await gotoAppShell(page);
await verifyLegacyHostSettingsRedirect(page);
@@ -351,15 +329,6 @@ test.describe("Settings — compact master-detail", () => {
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
await openSettingsHostSection(page, secondaryServerId, "connections");
await goBackInSettings(page);
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
await openSettingsSection(page, "appearance");
await goBackInSettings(page);
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
await openSettingsHostSection(page, secondaryServerId, "connections");
await goBackInSettings(page);
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
});
test("removing the last active host returns to welcome after settings closes", async ({
@@ -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();
}
});
});
@@ -1,25 +0,0 @@
# Inline code formatting
This fixture renders the production `MarkdownRenderer` with plain, bold, italic,
combined emphasis, strikethrough and linked code, plus bold code in headings,
lists and quotes. Toggle regular/compact text. Inline code uses the theme’s
code-to-body size ratio at every heading level; fenced code keeps the code-size
token. Bold and links preserve the resulting size. Check the glyphs, not the background height: native backgrounds use
line metrics.
Use a development client rebuilt from this checkout. Follow the temporary entry
point and Metro workflow in [status-ring](../status-ring/README.md), importing
`MarkdownFormattingRegression` from `./e2e/mobile/markdown-formatting/native`.
Open the same Metro URL in a browser to compare web. Restore `index.ts` afterward.
The package patch contains `InlineCodeFormattingTest`, which exercises actual
Android spans in Robolectric native graphics mode, covering draw and measurement,
span order, code inside paragraph/heading sizes, bold/italic composition, and
links preserving code and emphasized prose fonts.
It runs in the existing `test:android` CI job. To run only this regression locally,
from `packages/app/android` after Expo prebuild:
```sh
./gradlew :react-native-enriched-markdown:testDebugUnitTest \
--tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2
```
@@ -1,52 +0,0 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { Button, ScrollView, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { MarkdownRenderer } from "@/components/markdown/renderer";
const styles = StyleSheet.create((theme) => ({
root: { flex: 1 },
scroll: { backgroundColor: theme.colors.surface0 },
content: { padding: 32, paddingTop: 60 },
title: { color: theme.colors.foreground },
}));
const queryClient = new QueryClient();
const source = [
"Plain: `MMMMiiii`",
"Bold: **`MMMMiiii`**",
"Italic: *`MMMMiiii`*",
"Both: ***`MMMMiiii`***",
"Strike: ~~`MMMMiiii`~~",
"Link: [`MMMMiiii`](https://example.com)",
"# Heading **`MMMMiiii`**",
"## Heading *`MMMMiiii`*",
"### Heading [`MMMMiiii`](https://example.com)",
"#### Heading ~~`MMMMiiii`~~",
"##### Heading ***`MMMMiiii`***",
"###### Heading `MMMMiiii`",
"- List **`MMMMiiii`**",
"> Quote **`MMMMiiii`**",
"Normal **bold** and *italic* prose.",
].join("\n\n");
export default function MarkdownFormattingRegression() {
const [compact, setCompact] = useState(false);
const toggleCompact = useCallback(() => setCompact((value) => !value), []);
return (
<QueryClientProvider client={queryClient}>
<GestureHandlerRootView style={styles.root}>
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
<Text style={styles.title}>Inline code formatting regression</Text>
<Button
title={compact ? "Use regular text" : "Use compact text"}
onPress={toggleCompact}
/>
<MarkdownRenderer text={source} compact={compact} />
</ScrollView>
</GestureHandlerRootView>
</QueryClientProvider>
);
}
-5
View File
@@ -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({
@@ -326,25 +326,6 @@ export async function expectHostInjectMcpCard(page: Page): Promise<void> {
await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
}
function idleAgentReleaseSwitch(page: Page) {
return page
.getByTestId("host-page-idle-runtime-cleanup-card")
.getByRole("switch", { name: "Release idle agents" });
}
export async function expectIdleAgentRelease(page: Page, enabled: boolean): Promise<void> {
const toggle = idleAgentReleaseSwitch(page);
await expect(toggle).toBeEnabled();
await expect(toggle).toHaveAttribute("aria-checked", String(enabled));
}
export async function setIdleAgentRelease(page: Page, enabled: boolean): Promise<void> {
const toggle = idleAgentReleaseSwitch(page);
await expect(toggle).toHaveAttribute("aria-checked", String(!enabled));
await toggle.click();
await expectIdleAgentRelease(page, enabled);
}
export async function openHostSection(
page: Page,
serverId: string,
@@ -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 -1
View File
@@ -40,7 +40,7 @@
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
"build:apk": "npm --prefix ../.. run build:apk",
"build:apk:arm64": "npm --prefix ../.. run build:apk:arm64",
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest :react-native-enriched-markdown:testReleaseUnitTest --tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
},
"dependencies": {
"@codemirror/commands": "6.10.4",
@@ -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: [] };
+16 -64
View File
@@ -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.
+16 -124
View File
@@ -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>;
}
-2
View File
@@ -72,7 +72,6 @@ import {
type StartupBlocker,
} from "@/navigation/host-runtime-bootstrap";
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
import { useSettingsRouteEntry } from "@/navigation/settings-selection";
import { ThemedStack } from "@/navigation/themed-stack";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { AgentNavigationListener } from "@/desktop/agent-navigation";
@@ -760,7 +759,6 @@ function OpenProjectListener() {
}
function AppWithSidebar({ children }: { children: ReactNode }) {
useSettingsRouteEntry();
const pathname = usePathname();
const hosts = useHosts();
const storeReady = useStoreReady();
@@ -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) => {
@@ -804,18 +775,3 @@ it("makes the web Markdown container selectable in Safari as well as unprefixed
WebkitUserSelect: "text",
});
});
it.each([false, true])(
"scales inline code from theme tokens with its surrounding block (compact: %s)",
(compact) => {
const theme = {
...darkTheme,
fontSize: { ...darkTheme.fontSize, code: 13, content: 17, base: 16 },
};
const { markdownStyle } = createMarkdownStyles(theme, { compact, blockGap: 16 });
const bodySize = compact ? theme.fontSize.base : theme.fontSize.content;
expect(markdownStyle.code).not.toHaveProperty("fontSize");
expect(markdownStyle.code).toHaveProperty("fontScale", theme.fontSize.code / bodySize);
expect(markdownStyle.codeBlock?.fontSize).toBe(theme.fontSize.code);
},
);
@@ -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) => ({
@@ -72,7 +68,7 @@ export function createMarkdownStyles(
},
code: {
fontFamily: theme.fontFamily.mono === "ui-monospace" && !isWeb ? "" : theme.fontFamily.mono,
fontScale: theme.fontSize.code / fontSize,
fontSize: theme.fontSize.code,
color: theme.colors.foreground,
backgroundColor: theme.colors.surface2,
borderColor: "transparent",
+1 -8
View File
@@ -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,7 +2,6 @@
* @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";
@@ -487,20 +486,6 @@ 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,7 +99,6 @@ 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,
@@ -1594,7 +1593,11 @@ function ProjectBlock({
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
const visibilityPolicy = useMemo(
() => createWorkspaceVisibilityPolicy<SidebarWorkspacePlacement>(workspaceEntriesByKey),
() => ({
kind: "workspaces" as const,
getStatusBucket: (workspace: SidebarWorkspacePlacement) =>
workspaceEntriesByKey.get(workspace.workspaceKey)!.statusBucket,
}),
[workspaceEntriesByKey],
);
const {
@@ -4,18 +4,6 @@ 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,
+1 -13
View File
@@ -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);
+7 -27
View File
@@ -55,11 +55,7 @@ import {
} from "@/composer/agent-controls";
import { ContextWindowMeter } from "@/components/context-window-meter";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import {
selectAgentSentMessages,
selectAgentTurnPresentation,
useSessionStore,
} from "@/stores/session-store";
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
import { useFilePicker } from "@/hooks/use-file-picker";
import { useFileDrop } from "@/components/file-drop/use-file-drop";
import type { DroppedItem } from "@/components/file-drop/types";
@@ -122,7 +118,6 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { submitAgentInput } from "@/composer/submit";
import { ComposerRecall } from "@/composer/recall";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { useAppSettings } from "@/hooks/use-settings";
@@ -1000,7 +995,6 @@ interface ComposerAutocompleteHandle {
function ComposerAutocompleteBinding({
text,
cursor,
recall,
configuration,
inputRef,
anchorRef,
@@ -1009,7 +1003,6 @@ function ComposerAutocompleteBinding({
}: {
text: ComposerTextSource;
cursor: StoreApi<number>;
recall: ComposerRecall;
configuration: Omit<
Parameters<typeof useAgentAutocomplete>[0],
"userInput" | "cursorIndex" | "onAutocompleteApplied"
@@ -1027,11 +1020,9 @@ function ComposerAutocompleteBinding({
cursorIndex: Math.min(cursorIndex, userInput.length),
onAutocompleteApplied: () => inputRef.current?.focus(),
});
// A recalled message is sent as is; its leading `/command` must not open the picker.
const isShown = show && !recall.isShowing(userInput);
const handleKeyPress = autocomplete.onKeyPress;
useImperativeHandle(ref, () => ({ onKeyPress: (event) => isShown && handleKeyPress(event) }), [
isShown,
useImperativeHandle(ref, () => ({ onKeyPress: (event) => show && handleKeyPress(event) }), [
show,
handleKeyPress,
]);
const selectOption = autocomplete.onSelectOption;
@@ -1041,7 +1032,7 @@ function ComposerAutocompleteBinding({
);
return (
<ComposerAutocomplete
visible={autocomplete.isVisible && isShown}
visible={autocomplete.isVisible && show}
anchorRef={anchorRef}
options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex}
@@ -1371,7 +1362,6 @@ function ComposerContentImpl({
);
useEffect(() => () => cursorPublication.cancel(), [cursorPublication]);
const autocompleteRef = useRef<ComposerAutocompleteHandle>(null);
const recall = useMemo(() => new ComposerRecall(), []);
const [isProcessing, setIsProcessing] = useState(false);
const [pendingFiles, setPendingFiles] = useState<PendingFileAttachment[]>([]);
const nextPendingFileId = useRef(0);
@@ -1991,19 +1981,10 @@ function ComposerContentImpl({
const hasSendableContent = hasText || selectedAttachments.length > 0;
// An open autocomplete owns the arrows; otherwise they may recall sent messages.
// Handle keyboard navigation for command autocomplete.
const handleCommandKeyPress = useCallback(
(event: ComposerKeyPressEvent) => {
if (autocompleteRef.current?.onKeyPress(event)) return true;
const recalledText = recall.handleKeyPress(event, () =>
selectAgentSentMessages(useSessionStore.getState().sessions[serverId], agentId),
);
if (recalledText === null) return false;
event.preventDefault();
replaceUserInput(recalledText);
return true;
},
[agentId, recall, replaceUserInput, serverId],
(event: ComposerKeyPressEvent) => autocompleteRef.current?.onKeyPress(event) ?? false,
[],
);
const cancelButtonStyle = useMemo(
@@ -2442,7 +2423,6 @@ function ComposerContentImpl({
<ComposerAutocompleteBinding
text={textSource}
cursor={cursor}
recall={recall}
inputRef={messageInputRef}
anchorRef={messageInputContainerRef}
show={mode.showAutocomplete && !isFullscreen}
+2 -6
View File
@@ -110,8 +110,6 @@ export interface ComposerInputSnapshot {
export interface ComposerKeyPressEvent {
key: string;
/** Shift, Alt, Ctrl, or Meta was held. */
hasModifier: boolean;
preventDefault: () => void;
input: ComposerInputSnapshot;
}
@@ -212,7 +210,6 @@ type WebTextInputKeyPressEvent = NativeSyntheticEvent<
metaKey?: boolean;
ctrlKey?: boolean;
shiftKey?: boolean;
altKey?: boolean;
// Web-only: present on DOM KeyboardEvent during IME composition (CJK input).
isComposing?: boolean;
keyCode?: number;
@@ -414,18 +411,17 @@ function handleDesktopKeyPressImpl(
): void {
if (isImeComposingKeyboardEvent(event.nativeEvent)) return;
const { shiftKey, metaKey, ctrlKey, altKey } = event.nativeEvent;
if (ctx.onKeyPressCallback) {
const handled = ctx.onKeyPressCallback({
key: event.nativeEvent.key,
hasModifier: Boolean(shiftKey || metaKey || ctrlKey || altKey),
preventDefault: () => event.preventDefault(),
input: ctx.input,
});
if (handled) return;
}
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
if (event.nativeEvent.key !== "Enter") return;
if (!ctx.submitOnEnter) return;
if (shiftKey) return;
-109
View File
@@ -1,109 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ComposerKeyPressEvent } from "@/composer/input/input";
import { ComposerRecall } from "@/composer/recall";
const SENT = ["add tests", "fix the auth bug"];
function press(
key: string,
text: string,
options: { caret?: number; selectionEnd?: number; hasModifier?: boolean } = {},
): ComposerKeyPressEvent {
const caret = options.caret ?? text.length;
return {
key,
hasModifier: options.hasModifier ?? false,
preventDefault: () => undefined,
input: { text, selection: { start: caret, end: options.selectionEnd ?? caret } },
};
}
/** Drives recall the way the composer does: each result becomes the composer text, caret at the end. */
function composerWithRecall(sent: readonly string[] = SENT) {
const recall = new ComposerRecall();
let text = "";
return {
recall,
get text() {
return text;
},
type(next: string) {
text = next;
},
press(key: string, options: { caret?: number; hasModifier?: boolean } = {}) {
const result = recall.handleKeyPress(press(key, text, options), () => sent);
if (result !== null) text = result;
return result;
},
};
}
describe("ComposerRecall", () => {
it("walks back through sent messages and forward to an empty composer", () => {
const composer = composerWithRecall();
expect(composer.press("ArrowUp")).toBe("add tests");
expect(composer.press("ArrowUp")).toBe("fix the auth bug");
expect(composer.press("ArrowDown")).toBe("add tests");
expect(composer.press("ArrowDown")).toBe("");
expect(composer.recall.isShowing(composer.text)).toBe(false);
});
it("leaves the caret to the input past the oldest message", () => {
const composer = composerWithRecall();
composer.press("ArrowUp");
composer.press("ArrowUp");
expect(composer.press("ArrowUp")).toBeNull();
expect(composer.text).toBe("fix the auth bug");
});
it("only starts from an empty composer", () => {
const composer = composerWithRecall();
composer.type("half written");
expect(composer.press("ArrowUp")).toBeNull();
expect(composer.press("ArrowDown")).toBeNull();
});
it("ignores Down from an empty composer and Up with no history", () => {
expect(composerWithRecall().press("ArrowDown")).toBeNull();
expect(composerWithRecall([]).press("ArrowUp")).toBeNull();
});
it("hands the arrows back to the caret once the recalled message is edited", () => {
const composer = composerWithRecall();
composer.press("ArrowUp");
composer.type("add tests for login");
expect(composer.recall.isShowing(composer.text)).toBe(false);
expect(composer.press("ArrowUp")).toBeNull();
expect(composer.press("ArrowDown")).toBeNull();
});
it("moves between lines of a multi-line message before changing message", () => {
const composer = composerWithRecall(["first line\nsecond line", "older"]);
composer.press("ArrowUp");
expect(composer.press("ArrowUp", { caret: "first line\nsec".length })).toBeNull();
expect(composer.press("ArrowUp", { caret: 3 })).toBe("older");
expect(composer.press("ArrowDown")).toBe("first line\nsecond line");
expect(composer.press("ArrowDown", { caret: 3 })).toBeNull();
expect(composer.press("ArrowDown")).toBe("");
});
it("ignores modified arrows, other keys, and a selection", () => {
const composer = composerWithRecall();
expect(composer.press("ArrowUp", { hasModifier: true })).toBeNull();
expect(composer.press("Enter")).toBeNull();
composer.press("ArrowUp");
const recall = composer.recall;
expect(
recall.handleKeyPress(
press("ArrowUp", "add tests", { caret: 0, selectionEnd: 3 }),
() => SENT,
),
).toBeNull();
});
});
-68
View File
@@ -1,68 +0,0 @@
import type { ComposerKeyPressEvent } from "@/composer/input/input";
interface RecalledMessage {
index: number;
text: string;
}
/**
* Walks the messages already sent to an agent from an empty composer, like a
* shell's Up/Down history. A message stays recalled only while the composer
* still holds its exact text; the first edit hands the arrows back to the caret.
*/
export class ComposerRecall {
private recalled: RecalledMessage | null = null;
/** True while the composer shows a recalled message the user has not edited. */
isShowing(text: string): boolean {
return this.recalled !== null && this.recalled.text === text;
}
/**
* Returns the text the composer should show for this key press, or null to
* leave the key to the input. `readSentMessages` is newest first and is only
* read for keys recall acts on.
*/
handleKeyPress(
event: ComposerKeyPressEvent,
readSentMessages: () => readonly string[],
): string | null {
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return null;
if (event.hasModifier) return null;
const { text, selection } = event.input;
if (selection.start !== selection.end) return null;
const recalled = this.isShowing(text) ? this.recalled : null;
if (!recalled) {
this.recalled = null;
if (event.key !== "ArrowUp" || text.length > 0) return null;
return this.show(readSentMessages(), 0);
}
if (event.key === "ArrowUp") {
if (!isCaretOnFirstLine(text, selection.start)) return null;
return this.show(readSentMessages(), recalled.index + 1);
}
if (!isCaretOnLastLine(text, selection.start)) return null;
if (recalled.index === 0) {
this.recalled = null;
return "";
}
return this.show(readSentMessages(), recalled.index - 1);
}
private show(messages: readonly string[], index: number): string | null {
const text = messages[index];
if (text === undefined) return null;
this.recalled = { index, text };
return text;
}
}
function isCaretOnFirstLine(text: string, caret: number): boolean {
return !text.slice(0, caret).includes("\n");
}
function isCaretOnLastLine(text: string, caret: number): boolean {
return !text.slice(caret).includes("\n");
}
+1 -5
View File
@@ -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 () => {
+7 -14
View File
@@ -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);
+5 -24
View File
@@ -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: {
@@ -2698,11 +2684,6 @@ export const ar: TranslationResources = {
},
agents: {
unavailable: "Connect to this host to manage agents",
idleRuntimeCleanup: {
title: "تحرير الوكلاء الخاملين",
hint: "تحرير الذاكرة بعد ساعة من الخمول.",
info: "يوقف عملية الوكيل مع الاحتفاظ بمحادثته. أرسل رسالة لاستئنافها.\n\nيمنع العمل النشط والإشعارات المعلّقة وفحوصات أمان المزوّد عملية التنظيف.",
},
},
workspaces: {
unavailable: "Connect to this host to manage workspaces",
+5 -24
View File
@@ -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: {
@@ -2789,11 +2775,6 @@ export const en = {
},
agents: {
unavailable: "Connect to this host to manage agents",
idleRuntimeCleanup: {
title: "Release idle agents",
hint: "Free up memory after an hour of inactivity.",
info: "Stops the agent's process while keeping its conversation. Send a message to resume.\n\nActive work, pending notifications, and provider safety checks prevent cleanup.",
},
},
workspaces: {
unavailable: "Connect to this host to manage workspaces",
+5 -24
View File
@@ -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: {
@@ -2718,11 +2704,6 @@ export const es: TranslationResources = {
},
agents: {
unavailable: "Connect to this host to manage agents",
idleRuntimeCleanup: {
title: "Liberar agentes inactivos",
hint: "Libera memoria tras una hora de inactividad.",
info: "Detiene el proceso del agente y conserva la conversación. Envía un mensaje para reanudarla.\n\nEl trabajo activo, las notificaciones pendientes y las comprobaciones de seguridad del proveedor impiden la limpieza.",
},
},
workspaces: {
unavailable: "Connect to this host to manage workspaces",
+5 -24
View File
@@ -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: {
@@ -2721,11 +2707,6 @@ export const fr: TranslationResources = {
},
agents: {
unavailable: "Connectez-vous à cet hôte pour gérer les agents",
idleRuntimeCleanup: {
title: "Libérer les agents inactifs",
hint: "Libère de la mémoire après une heure d’inactivité.",
info: "Arrête le processus de l’agent tout en conservant sa conversation. Envoyez un message pour la reprendre.\n\nLe travail en cours, les notifications en attente et les vérifications de sécurité du fournisseur empêchent le nettoyage.",
},
},
workspaces: {
unavailable: "Connectez-vous à cet hôte pour gérer les espaces de travail",
+5 -24
View File
@@ -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: {
@@ -2687,11 +2673,6 @@ export const ja: TranslationResources = {
},
agents: {
unavailable: "エージェントを管理するにはこのホストに接続してください",
idleRuntimeCleanup: {
title: "アイドル状態のエージェントを解放",
hint: "1 時間のアイドル状態の後にメモリを解放します。",
info: "会話を保持したままエージェントのプロセスを停止します。メッセージを送信すると再開します。\n\n実行中の作業、保留中の通知、プロバイダーの安全性チェックがある場合は解放しません。",
},
},
workspaces: {
unavailable: "ワークスペースを管理するにはこのホストに接続してください",
+5 -24
View File
@@ -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: {
@@ -2673,11 +2659,6 @@ export const ko: TranslationResources = {
},
agents: {
unavailable: "에이전트를 관리하려면 이 호스트에 연결하세요",
idleRuntimeCleanup: {
title: "유휴 에이전트 해제",
hint: "1시간 동안 활동이 없으면 메모리를 확보합니다.",
info: "대화를 유지하면서 에이전트 프로세스를 중지합니다. 메시지를 보내면 다시 이어집니다.\n\n진행 중인 작업, 대기 중인 알림, 프로바이더 안전 검사가 있으면 정리하지 않습니다.",
},
},
workspaces: {
unavailable: "워크스페이스를 관리하려면 이 호스트에 연결하세요",
+5 -24
View File
@@ -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: {
@@ -2700,11 +2686,6 @@ export const ptBR: TranslationResources = {
},
agents: {
unavailable: "Conecte-se a este host para gerenciar agentes",
idleRuntimeCleanup: {
title: "Liberar agentes ociosos",
hint: "Libera memória após uma hora de inatividade.",
info: "Encerra o processo do agente e mantém a conversa. Envie uma mensagem para retomar.\n\nTrabalho ativo, notificações pendentes e verificações de segurança do provedor impedem a limpeza.",
},
},
workspaces: {
unavailable: "Conecte-se a este host para gerenciar workspaces",
+5 -24
View File
@@ -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: {
@@ -2709,11 +2695,6 @@ export const ru: TranslationResources = {
},
agents: {
unavailable: "Подключитесь к этому хосту, чтобы управлять агентами",
idleRuntimeCleanup: {
title: "Освобождать неактивных агентов",
hint: "Освобождает память после часа бездействия.",
info: "Останавливает процесс агента, сохраняя разговор. Отправьте сообщение, чтобы продолжить.\n\nАктивная работа, ожидающие уведомления и проверки безопасности провайдера предотвращают очистку.",
},
},
workspaces: {
unavailable: "Подключитесь к этому хосту, чтобы управлять рабочими пространствами",
+5 -24
View File
@@ -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: {
@@ -2634,11 +2620,6 @@ export const zhCN: TranslationResources = {
},
agents: {
unavailable: "连接到这个 Host 以管理 Agent",
idleRuntimeCleanup: {
title: "释放空闲 Agent",
hint: "空闲一小时后释放内存。",
info: "停止 Agent 进程,但保留对话。发送消息即可继续。\n\n进行中的工作、待处理的通知和 Provider 安全检查会阻止清理。",
},
},
workspaces: {
unavailable: "连接到这个 Host 以管理 Workspace",
@@ -1,32 +0,0 @@
import { useLayoutEffect } from "react";
import { usePathname, useRootNavigationState } from "expo-router";
import { useStore } from "zustand";
import {
useIsLastWorkspaceSelectionHydrated,
useLastWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { createSettingsSelection } from "./model";
const selection = createSettingsSelection();
// Mount once above the sibling Settings routes so replacements and Back share
// one visit, and leaving Settings gives the next workspace entry a fresh default.
export function useSettingsRouteEntry(): void {
const pathname = usePathname();
const navigationState = useRootNavigationState();
const routeKey = navigationState?.routes[navigationState.index]?.key;
const workspace = useLastWorkspaceSelection();
const isWorkspaceHydrated = useIsLastWorkspaceSelectionHydrated();
const workspaceServerId = workspace?.serverId ?? null;
useLayoutEffect(() => {
if (isWorkspaceHydrated && routeKey) {
selection.enterRoute({ key: routeKey, pathname }, workspaceServerId);
}
}, [pathname, routeKey, workspaceServerId, isWorkspaceHydrated]);
}
export function useSettingsHostSelection(): string | null {
return useStore(selection.store, (state) => state.selectedServerId);
}
export const selectSettingsHost = selection.selectHost;
@@ -1,102 +0,0 @@
import { describe, expect, it } from "vitest";
import { createSettingsSelection } from "./model";
describe("Settings host selection", () => {
it("keeps picker choices across sections, replacement and Back", () => {
const settings = createSettingsSelection();
settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host");
expect(settings.store.getState().selectedServerId).toBe("workspace-host");
settings.selectHost("chosen-host");
settings.enterRoute(
{
key: "/settings/hosts/chosen-host/connections",
pathname: "/settings/hosts/chosen-host/connections",
},
"workspace-host",
);
settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host");
settings.enterRoute(
{ key: "/settings/appearance", pathname: "/settings/appearance" },
"workspace-host",
);
settings.enterRoute(
{ key: "/settings/general", pathname: "/settings/general" },
"workspace-host",
);
expect(settings.store.getState().selectedServerId).toBe("chosen-host");
});
it("applies explicit host links once, including project and plugin screens", () => {
const settings = createSettingsSelection();
settings.enterRoute(
{
key: "/settings/hosts/linked-host/projects/project",
pathname: "/settings/hosts/linked-host/projects/project",
},
"workspace-host",
);
expect(settings.store.getState().selectedServerId).toBe("linked-host");
settings.selectHost("chosen-host");
settings.enterRoute(
{
key: "/settings/hosts/linked-host/projects/project",
pathname: "/settings/hosts/linked-host/projects/project",
},
"workspace-host",
);
expect(settings.store.getState().selectedServerId).toBe("chosen-host");
settings.enterRoute(
{
key: "/settings/hosts/plugin-host/plugins/plugin/screen",
pathname: "/settings/hosts/plugin-host/plugins/plugin/screen",
},
"workspace-host",
);
expect(settings.store.getState().selectedServerId).toBe("plugin-host");
});
it("seeds each new visit from the workspace, including a retained settings route", () => {
const settings = createSettingsSelection();
settings.enterRoute(
{ key: "/settings/general", pathname: "/settings/general" },
"first-workspace-host",
);
settings.selectHost("chosen-host");
settings.enterRoute(
{
key: "/h/second-workspace-host/workspace/workspace",
pathname: "/h/second-workspace-host/workspace/workspace",
},
"second-workspace-host",
);
settings.enterRoute(
{ key: "/settings/general", pathname: "/settings/general" },
"second-workspace-host",
);
expect(settings.store.getState().selectedServerId).toBe("second-workspace-host");
});
it("does not replay a retained host route after a newer picker choice", () => {
const settings = createSettingsSelection();
const oldRoute = { key: "old-host-screen", pathname: "/settings/hosts/first/host" };
settings.enterRoute(oldRoute, "workspace-host");
settings.selectHost("second");
settings.enterRoute(
{ key: "new-host-screen", pathname: "/settings/hosts/second/host" },
"workspace-host",
);
settings.enterRoute(oldRoute, "workspace-host");
settings.enterRoute({ key: "app-screen", pathname: "/settings/appearance" }, "workspace-host");
expect(settings.store.getState().selectedServerId).toBe("second");
settings.enterRoute({ ...oldRoute, key: "explicit-new-entry" }, "workspace-host");
expect(settings.store.getState().selectedServerId).toBe("first");
});
it("does not reseed a visit when remembered workspace state changes", () => {
const settings = createSettingsSelection();
settings.enterRoute({ key: "/settings", pathname: "/settings" }, null);
settings.selectHost("chosen-host");
settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host");
expect(settings.store.getState().selectedServerId).toBe("chosen-host");
});
});
@@ -1,46 +0,0 @@
import { createStore } from "zustand/vanilla";
interface SettingsRouteEntry {
key: string;
pathname: string;
}
interface SettingsSelectionState {
selectedServerId: string | null;
}
export function createSettingsSelection() {
const store = createStore<SettingsSelectionState>(() => ({ selectedServerId: null }));
const enteredRoutes = new Map<string, string>();
let inSettings = false;
function enterRoute(
{ key, pathname }: SettingsRouteEntry,
workspaceServerId: string | null,
): void {
const isSettings = pathname === "/settings" || pathname.startsWith("/settings/");
if (!isSettings) {
inSettings = false;
enteredRoutes.clear();
return;
}
// A retained route returning through Back must not replay its old host intent.
if (enteredRoutes.get(key) === pathname) return;
const isNewVisit = !inSettings;
inSettings = true;
enteredRoutes.set(key, pathname);
const hostMatch = /^\/settings\/hosts\/([^/]+)(?:\/|$)/.exec(pathname);
if (hostMatch) {
store.setState({ selectedServerId: decodeURIComponent(hostMatch[1]) });
} else if (isNewVisit) {
store.setState({ selectedServerId: workspaceServerId });
}
}
function selectHost(serverId: string): void {
store.setState({ selectedServerId: serverId });
}
return { store, enterRoute, selectHost };
}
+1 -12
View File
@@ -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}
+2 -14
View File
@@ -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" },
}));
+17 -3
View File
@@ -133,7 +133,7 @@ import {
type HostSectionSlug,
type SettingsSectionSlug,
} from "@/utils/host-routes";
import { selectSettingsHost, useSettingsHostSelection } from "@/navigation/settings-selection";
import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { returnFromSettings, type SettingsView } from "@/navigation/settings-navigation";
import { isWeb } from "@/constants/platform";
@@ -1098,7 +1098,21 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const sortedHosts = useSortedHosts(hosts, localServerId);
const selectedSettingsHostServerId = useSettingsHostSelection();
const lastWorkspaceSelection = useLastWorkspaceSelection();
const routedSettingsHostServerId =
view.kind === "host" || view.kind === "project" || view.kind === "plugin"
? view.serverId
: null;
const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState<string | null>(
routedSettingsHostServerId ?? lastWorkspaceSelection?.serverId ?? null,
);
useFocusEffect(
useCallback(() => {
setSelectedSettingsHostServerId(
routedSettingsHostServerId ?? lastWorkspaceSelection?.serverId ?? null,
);
}, [lastWorkspaceSelection?.serverId, routedSettingsHostServerId]),
);
// The host the four sections scope to: the host on the active view, otherwise
// the picker choice, otherwise the connected local daemon, otherwise the first host.
@@ -1218,7 +1232,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
// host detail route, keep that detail section and swap only the host segment.
const handleSelectHost = useCallback(
(serverId: string) => {
selectSettingsHost(serverId);
setSelectedSettingsHostServerId(serverId);
if (view.kind === "project") {
const target = buildSettingsHostSectionRoute(serverId, "projects");
if (isCompactLayout) {
@@ -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
@@ -65,7 +65,6 @@ import { ICON_SIZE } from "@/styles/theme";
import type { Theme } from "@/styles/theme";
import { useProviderIcon } from "@/components/provider-icons";
import { BrowserToolsOptInCard } from "./browser-tools-card";
import { IdleRuntimeCleanupCard } from "./idle-runtime-cleanup-card";
import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle";
const ThemedRestart = withUnistyles(RotateCw);
@@ -292,7 +291,6 @@ export function HostAgentsPage({ serverId }: { serverId: string }) {
<InjectPaseoToolsCard serverId={serverId} />
<BrowserToolsOptInCard serverId={serverId} />
<AppendSystemPromptCard serverId={serverId} />
<IdleRuntimeCleanupCard serverId={serverId} />
</SettingsSection>
) : (
<View style={[settingsStyles.card, styles.emptyCard]}>
@@ -1,78 +0,0 @@
import { useCallback } from "react";
import { Text, View } from "react-native";
import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { SettingsInfoTip } from "@/components/settings/headings/settings-info-tip";
import { Switch } from "@/components/ui/switch";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { settingsStyles } from "@/styles/settings";
// Host-wide, persisted, and applied live by the daemon.
export function IdleRuntimeCleanupCard({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const isConnected = useHostRuntimeIsConnected(serverId);
const isSupported = useHostFeature(serverId, "idleRuntimeCleanupSetting");
const { config, patchConfig } = useDaemonConfig(serverId);
const mutation = useMutation({
mutationFn: async (next: boolean) => {
const result = await patchConfig({ idleRuntimeCleanup: next });
if (!result) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
return result;
},
});
const handleValueChange = useCallback(
(next: boolean) => {
mutation.mutate(next);
},
[mutation],
);
if (!isConnected || !isSupported || !config) return null;
return (
<View style={settingsStyles.card} testID="host-page-idle-runtime-cleanup-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<View style={styles.titleRow}>
<Text style={settingsStyles.rowTitle}>
{t("settings.host.agents.idleRuntimeCleanup.title")}
</Text>
<SettingsInfoTip
title={t("settings.host.agents.idleRuntimeCleanup.title")}
info={t("settings.host.agents.idleRuntimeCleanup.info")}
/>
</View>
<Text style={settingsStyles.rowHint}>
{t("settings.host.agents.idleRuntimeCleanup.hint")}
</Text>
{mutation.error ? (
<Text style={settingsStyles.rowError} testID="host-page-idle-runtime-cleanup-error">
{mutation.error.message}
</Text>
) : null}
</View>
<Switch
value={config.idleRuntimeCleanup !== false}
onValueChange={handleValueChange}
disabled={mutation.isPending}
accessibilityLabel={t("settings.host.agents.idleRuntimeCleanup.title")}
testID="host-page-idle-runtime-cleanup-switch"
/>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
}));
@@ -5,7 +5,6 @@ import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import {
normalizeWorkspaceDescriptor,
selectAgentSentMessages,
selectAgentTurnPresentation,
selectAgentTimelineState,
useSessionStore,
@@ -236,25 +235,6 @@ describe("agent timeline state", () => {
).toEqual({ status: "synced", items: [], range: null, older: "none", newer: "none" });
});
it("lists sent messages newest first, including the live head", () => {
initializeTestSession();
const sent = (id: string, text: string) => ({ ...submittedMessage(id), text });
useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", {
items: [sent("m1", "fix the auth bug"), sent("m2", "add tests"), sent("m3", "add tests")],
head: [sent("m4", " "), sent("m5", "refactor the wrong module")],
range: { epoch: "epoch-1", startSeq: 1, endSeq: 5 },
older: "none",
newer: false,
synchronized: true,
acknowledgedClientMessageIds: [],
});
expect(
selectAgentSentMessages(useSessionStore.getState().sessions["test-server"], "agent-1"),
).toEqual(["refactor the wrong module", "add tests", "fix the auth bug"]);
expect(selectAgentSentMessages(undefined, "agent-1")).toEqual([]);
});
it("preserves older availability when an applied response leaves it unchanged", () => {
initializeTestSession();
const store = useSessionStore.getState();
-20
View File
@@ -332,26 +332,6 @@ export function selectAgentTimelineState(
return items.length > 0 ? { status: "painted", items } : { status: "cold" };
}
/** Text of the loaded user messages, newest first, with back-to-back repeats collapsed. */
export function selectAgentSentMessages(
session: SessionState | undefined,
agentId: string,
): string[] {
if (!session) return [];
const items = [
...(session.agentStreamTail.get(agentId) ?? []),
...(session.agentStreamHead.get(agentId) ?? []),
];
const messages: string[] = [];
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (item.kind !== "user_message" || item.text.trim().length === 0) continue;
if (messages[messages.length - 1] === item.text) continue;
messages.push(item.text);
}
return messages;
}
export function selectAgentTurnPresentation(
session: SessionState | undefined,
agentId: string,
@@ -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,
};
}
@@ -5,6 +5,7 @@ import {
resolveTerminalGridMetricsMeasurement,
resolveMeasuredTerminalCellMetrics,
resolveTerminalCursorOffset,
resolveTerminalTextLetterSpacing,
} from "./terminal-grid-metrics";
describe("native terminal grid metrics", () => {
@@ -63,6 +64,39 @@ describe("native terminal grid metrics", () => {
});
});
it.each([1, 2, 2.75, 3, 3.5])(
"spaces font glyphs so text ends on the pixel-snapped cell grid at density %s",
(density) => {
const measurement = {
measuredTextWidth: 72.246,
measuredTextHeight: 16.2,
measureTextLength: 10,
roundToNearestPixel: (value: number) => Math.round(value * density) / density,
};
const { cellWidth } = resolveMeasuredTerminalCellMetrics(measurement);
const letterSpacing = resolveTerminalTextLetterSpacing({
...measurement,
cellWidth,
cellsPerGlyph: 1,
});
const glyphAdvance = measurement.measuredTextWidth / measurement.measureTextLength;
expect(80 * (glyphAdvance + letterSpacing)).toBeCloseTo(80 * cellWidth, 10);
},
);
it("spaces wide fallback glyphs to exactly two cells each", () => {
const cellWidth = 22 / 3;
const letterSpacing = resolveTerminalTextLetterSpacing({
measuredTextWidth: 103.8,
measureTextLength: 10,
cellWidth,
cellsPerGlyph: 2,
});
expect(24 * (10.38 + letterSpacing)).toBeCloseTo(48 * cellWidth, 10);
});
it("emits measured metrics once until the measurement changes", () => {
const measured = { cellWidth: 7.2, cellHeight: 16 };
@@ -44,6 +44,21 @@ export function resolveMeasuredTerminalCellMetrics(
};
}
export interface TerminalTextLetterSpacingInput {
measuredTextWidth: number;
measureTextLength: number;
cellWidth: number;
cellsPerGlyph: 1 | 2;
}
// Cells are snapped to the pixel grid but glyphs advance by their font's natural width, so a run
// of N glyphs ends N * (cells * cellWidth - advance) away from its cells and the cursor. The
// letter spacing makes up that difference per glyph.
export function resolveTerminalTextLetterSpacing(input: TerminalTextLetterSpacingInput): number {
const textLength = Math.max(1, input.measureTextLength);
return input.cellsPerGlyph * input.cellWidth - input.measuredTextWidth / textLength;
}
export function resolveTerminalGridMetricsMeasurement(
previous: TerminalGridCellMetrics | null,
next: TerminalGridCellMetrics,
@@ -25,14 +25,18 @@ import {
resolveTerminalCustomGlyphCellTransform,
resolveTerminalGridMetricsMeasurement,
resolveTerminalCursorOffset,
resolveTerminalTextLetterSpacing,
type TerminalGridCellMetrics,
} from "./terminal-grid-metrics";
import {
buildRows,
TERMINAL_WIDE_GLYPH_CLASSES,
type TerminalCustomGlyphCell,
type TerminalCustomGlyphRun,
type TerminalRowModel,
type TerminalRun,
type TerminalTextRun,
type TerminalWideGlyphClass,
} from "./terminal-row-model";
import type { TerminalSelectionRange } from "./terminal-selection";
@@ -40,12 +44,17 @@ const MEASURE_TEXT = "mmmmmmmmmm";
const DEFAULT_FONT_SIZE = 12;
const INITIAL_CELL_WIDTH_RATIO = 0.62;
const INITIAL_CELL_HEIGHT_RATIO = 1.35;
// Keep native line layout clear of the cell boundary: rounding and trailing letter spacing can
// otherwise wrap a short run. The run box clips ordinary text; isolated fallback glyphs can spill.
const TEXT_LAYOUT_OVERFLOW_CELLS = 2;
interface CellMetrics {
cellWidth: number;
cellHeight: number;
}
type TerminalWideGlyphStyles = Readonly<Record<TerminalWideGlyphClass, TextStyle>>;
interface TerminalGridViewport {
width: number;
height: number;
@@ -57,6 +66,8 @@ interface TerminalGridRowProps {
cellHeight: number;
fontFamily?: string;
fontSize: number;
letterSpacing: number;
wideGlyphStyles: TerminalWideGlyphStyles;
styleEpoch: string;
}
@@ -65,6 +76,7 @@ interface TerminalGridRunProps {
cellWidth: number;
cellHeight: number;
textStyle: StyleProp<TextStyle>;
wideGlyphStyles: TerminalWideGlyphStyles;
}
export interface TerminalGridViewProps {
@@ -99,21 +111,55 @@ function resolveVisibleCols(input: {
return Math.min(input.gridCols, Math.max(1, Math.floor(input.viewportWidth / input.cellWidth)));
}
function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGridRunProps) {
function renderTerminalTextSegments(
run: TerminalTextRun,
wideGlyphStyles: TerminalWideGlyphStyles,
) {
if (!run.segments.some((segment) => segment.wideGlyphClass)) {
return run.text;
}
let offset = 0;
return run.segments.map((segment) => {
const key = `${offset}:${segment.wideGlyphClass ?? "font"}`;
offset += segment.text.length;
return (
<Text
key={key}
style={segment.wideGlyphClass ? wideGlyphStyles[segment.wideGlyphClass] : undefined}
>
{segment.text}
</Text>
);
});
}
function TerminalGridRun({
run,
cellWidth,
cellHeight,
textStyle,
wideGlyphStyles,
}: TerminalGridRunProps) {
const isolated = run.renderKind === "text" && run.isolated;
const runStyle = useMemo<StyleProp<ViewStyle>>(
() => [
styles.run,
isolated ? styles.isolatedRun : styles.run,
{
backgroundColor: run.style.backgroundColor,
height: cellHeight,
width: run.cellCount * cellWidth,
},
],
[cellHeight, cellWidth, run.cellCount, run.style.backgroundColor],
[cellHeight, cellWidth, isolated, run.cellCount, run.style.backgroundColor],
);
const runTextStyle = useMemo<StyleProp<TextStyle>>(
() => [textStyle, run.style],
[run.style, textStyle],
() => [
textStyle,
run.style,
styles.runText,
{ width: (run.cellCount + TEXT_LAYOUT_OVERFLOW_CELLS) * cellWidth },
],
[cellWidth, run.cellCount, run.style, textStyle],
);
return (
@@ -121,8 +167,8 @@ function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGrid
{run.renderKind === "custom-glyph" ? (
<TerminalGridCustomGlyphRun run={run} cellWidth={cellWidth} cellHeight={cellHeight} />
) : (
<Text numberOfLines={1} style={runTextStyle}>
{run.text}
<Text numberOfLines={1} ellipsizeMode="clip" style={runTextStyle}>
{renderTerminalTextSegments(run, wideGlyphStyles)}
</Text>
)}
</View>
@@ -225,7 +271,8 @@ const MemoTerminalGridRun = memo(TerminalGridRun, (previous, next) => {
previous.run === next.run &&
previous.cellWidth === next.cellWidth &&
previous.cellHeight === next.cellHeight &&
previous.textStyle === next.textStyle
previous.textStyle === next.textStyle &&
previous.wideGlyphStyles === next.wideGlyphStyles
);
});
@@ -235,6 +282,8 @@ function TerminalGridRow({
cellHeight,
fontFamily,
fontSize,
letterSpacing,
wideGlyphStyles,
}: TerminalGridRowProps) {
const rowStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.row, { height: cellHeight }],
@@ -248,9 +297,10 @@ function TerminalGridRow({
lineHeight: cellHeight,
fontFamily,
fontSize,
letterSpacing,
},
],
[cellHeight, fontFamily, fontSize],
[cellHeight, fontFamily, fontSize, letterSpacing],
);
const accessibilityLabel = useMemo(
() =>
@@ -275,6 +325,7 @@ function TerminalGridRow({
cellWidth={cellWidth}
cellHeight={cellHeight}
textStyle={textStyle}
wideGlyphStyles={wideGlyphStyles}
/>
))}
</View>
@@ -288,10 +339,38 @@ const MemoTerminalGridRow = memo(TerminalGridRow, (previous, next) => {
previous.cellHeight === next.cellHeight &&
previous.fontFamily === next.fontFamily &&
previous.fontSize === next.fontSize &&
previous.letterSpacing === next.letterSpacing &&
previous.wideGlyphStyles === next.wideGlyphStyles &&
previous.styleEpoch === next.styleEpoch
);
});
interface TerminalWideGlyphMeasureProps {
glyphClass: TerminalWideGlyphClass;
sample: string;
style: StyleProp<TextStyle>;
onMeasure: (glyphClass: TerminalWideGlyphClass, measuredTextWidth: number) => void;
}
// Measures one wide script's advance in the terminal font's fallback, like MEASURE_TEXT does for
// the terminal font itself.
function TerminalWideGlyphMeasure({
glyphClass,
sample,
style,
onMeasure,
}: TerminalWideGlyphMeasureProps) {
const handleLayout = useCallback(
(event: LayoutChangeEvent) => onMeasure(glyphClass, event.nativeEvent.layout.width),
[glyphClass, onMeasure],
);
return (
<Text onLayout={handleLayout} pointerEvents="none" style={style}>
{sample.repeat(MEASURE_TEXT.length)}
</Text>
);
}
export function TerminalGridView({
state,
xtermTheme = DEFAULT_TERMINAL_THEME,
@@ -303,6 +382,10 @@ export function TerminalGridView({
}: TerminalGridViewProps) {
const [metrics, setMetrics] = useState<CellMetrics>(() => estimateCellMetrics(fontSize));
const measuredMetricsRef = useRef<TerminalGridCellMetrics | null>(null);
const [letterSpacing, setLetterSpacing] = useState(0);
const [wideGlyphWidths, setWideGlyphWidths] = useState<
Partial<Record<TerminalWideGlyphClass, number>>
>({});
const [viewport, setViewport] = useState<TerminalGridViewport | null>(null);
const resolvedFontFamily = useMemo(
() => resolveNativeTerminalFontFamily(fontFamily),
@@ -381,6 +464,14 @@ export function TerminalGridView({
measureTextLength: MEASURE_TEXT.length,
roundToNearestPixel: (value) => PixelRatio.roundToNearestPixel(value),
});
setLetterSpacing(
resolveTerminalTextLetterSpacing({
measuredTextWidth: event.nativeEvent.layout.width,
measureTextLength: MEASURE_TEXT.length,
cellWidth: nextMetrics.cellWidth,
cellsPerGlyph: 1,
}),
);
const changedMetrics = resolveTerminalGridMetricsMeasurement(
measuredMetricsRef.current,
nextMetrics,
@@ -395,6 +486,35 @@ export function TerminalGridView({
[onCellMetricsChange],
);
const handleWideGlyphMeasure = useCallback(
(glyphClass: TerminalWideGlyphClass, measuredTextWidth: number) => {
setWideGlyphWidths((current) =>
current[glyphClass] === measuredTextWidth
? current
: { ...current, [glyphClass]: measuredTextWidth },
);
},
[],
);
const wideGlyphStyles = useMemo(() => {
const entries = TERMINAL_WIDE_GLYPH_CLASSES.map((spec) => {
const measuredTextWidth = wideGlyphWidths[spec.name];
const spacingStyle: TextStyle = {
letterSpacing:
measuredTextWidth === undefined
? 0
: resolveTerminalTextLetterSpacing({
measuredTextWidth,
measureTextLength: MEASURE_TEXT.length,
cellWidth: metrics.cellWidth,
cellsPerGlyph: 2,
}),
};
return [spec.name, spacingStyle] as const;
});
return Object.fromEntries(entries) as TerminalWideGlyphStyles;
}, [metrics.cellWidth, wideGlyphWidths]);
const handleContainerLayout = useCallback((event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
setViewport((current) => {
@@ -411,6 +531,15 @@ export function TerminalGridView({
<Text onLayout={handleMeasure} pointerEvents="none" style={measureStyle}>
{MEASURE_TEXT}
</Text>
{TERMINAL_WIDE_GLYPH_CLASSES.map((spec) => (
<TerminalWideGlyphMeasure
key={spec.name}
glyphClass={spec.name}
sample={spec.sample}
style={measureStyle}
onMeasure={handleWideGlyphMeasure}
/>
))}
{rows.map((row) => (
<MemoTerminalGridRow
key={row.index}
@@ -419,6 +548,8 @@ export function TerminalGridView({
cellHeight={metrics.cellHeight}
fontFamily={resolvedFontFamily}
fontSize={fontSize}
letterSpacing={letterSpacing}
wideGlyphStyles={wideGlyphStyles}
styleEpoch={resolver.themeKey}
/>
))}
@@ -441,6 +572,17 @@ const styles = StyleSheet.create({
run: {
overflow: "hidden",
},
isolatedRun: {
overflow: "visible",
},
// The run box owns cell dimensions and background. Text gets extra layout room without
// changing those dimensions; only isolated fallback glyphs paint beyond the run box.
runText: {
backgroundColor: "transparent",
left: 0,
position: "absolute",
top: 0,
},
customGlyphRun: {
flex: 1,
position: "relative",
@@ -0,0 +1,33 @@
// @vitest-environment jsdom
import React from "react";
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { createNativeHeadlessTerminal } from "./headless-terminal-state";
import { TerminalGridView } from "./terminal-grid-view.native";
afterEach(cleanup);
describe("native terminal grid text layout", () => {
it("gives short runs between accented glyphs layout room without widening their cells", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 1, cols: 30 });
try {
await terminal.write("café naïve|END");
const view = render(<TerminalGridView state={terminal.getViewportState()} />);
const text = view.getByText(" na", { normalizer: (value) => value });
const run = text.parentElement!;
const accentRun = view.getByText("é").parentElement!;
const cellWidth = parseFloat(getComputedStyle(accentRun).width);
expect(parseFloat(getComputedStyle(run).width)).toBe(3 * cellWidth);
// Native Text wraps or ellipsizes when rounding or trailing letter spacing puts this
// leading-space run just past its layout width. Layout has room; the cell box clips paint.
expect(parseFloat(getComputedStyle(text).width)).toBeGreaterThan(3 * cellWidth);
expect(getComputedStyle(text).position).toBe("absolute");
expect(getComputedStyle(run).overflowX).toBe("hidden");
expect(view.getByTestId("terminal-row-0").getAttribute("aria-label")).toBe("café naïve|END");
} finally {
terminal.dispose();
}
});
});
@@ -37,7 +37,95 @@ describe("terminal row model", () => {
});
expect(rows[0].runs.map((run) => ({ text: run.text, cellCount: run.cellCount }))).toEqual([
{ text: "A界", cellCount: 3 },
{ text: "A", cellCount: 2 },
{ text: "界", cellCount: 1 },
]);
});
test("keeps wide scripts in the run as segments the renderer spaces to two cells", () => {
const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME);
const rows = buildRows({
grid: [
[
cell("한", { width: 2 }),
cell(" ", { width: 0 }),
cell("글", { width: 2 }),
cell(" ", { width: 0 }),
cell(" ", { width: 1 }),
cell("中", { width: 2 }),
cell(" ", { width: 0 }),
cell("あ", { width: 2 }),
cell(" ", { width: 0 }),
cell("o", { width: 1 }),
cell("k", { width: 1 }),
],
],
resolver,
});
expect(
rows[0].runs.map((run) => ({
text: run.text,
cellCount: run.cellCount,
segments: run.renderKind === "text" ? run.segments : null,
})),
).toEqual([
{
text: "한글 中あok",
cellCount: 11,
segments: [
{ text: "한글", wideGlyphClass: "hangul" },
{ text: " ", wideGlyphClass: null },
{ text: "中", wideGlyphClass: "han" },
{ text: "あ", wideGlyphClass: "kana" },
{ text: "ok", wideGlyphClass: null },
],
},
]);
});
test("gives wide glyphs outside the spaced scripts their own box", () => {
const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME);
const [row] = buildRows({
grid: [
[
cell("ㄱ", { width: 2 }),
cell(" ", { width: 0 }),
cell("A", { width: 2 }),
cell(" ", { width: 0 }),
cell("가", { width: 2 }),
cell(" ", { width: 0 }),
],
],
resolver,
});
expect(
row.runs.map((run) => [run.text, run.cellCount, run.renderKind === "text" && run.isolated]),
).toEqual([
["ㄱ", 2, true],
["A", 2, true],
["가", 2, false],
]);
});
test("gives non-ASCII narrow glyphs their own cell", () => {
const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME);
const [row] = buildRows({
grid: [
["⏺", " ", "e\u0301", " ", "d", "o", "n", "e"].map((char) => cell(char, { width: 1 })),
],
resolver,
});
expect(row.runs.map((run) => [run.text, run.cellCount])).toEqual([
["⏺", 1],
[" ", 1],
["e\u0301", 1],
[" done", 5],
]);
});
@@ -27,8 +27,40 @@ interface TerminalRunBase {
foregroundColor: string;
}
export type TerminalWideGlyphClass = "hangul" | "han" | "kana";
interface TerminalWideGlyphClassSpec {
name: TerminalWideGlyphClass;
sample: string;
ranges: ReadonlyArray<readonly [number, number]>;
}
// Wide scripts whose glyphs share one advance within a font. The renderer measures each sample
// once and letter-spaces the script so every glyph advances exactly two cells.
export const TERMINAL_WIDE_GLYPH_CLASSES: ReadonlyArray<TerminalWideGlyphClassSpec> = [
{ name: "hangul", sample: "가", ranges: [[0xac00, 0xd7a3]] },
{
name: "han",
sample: "中",
ranges: [
[0x3400, 0x4dbf],
[0x4e00, 0x9fff],
],
},
{ name: "kana", sample: "あ", ranges: [[0x3041, 0x30ff]] },
];
export interface TerminalTextSegment {
text: string;
/** Null for terminal-font text, which advances one cell per glyph. */
wideGlyphClass: TerminalWideGlyphClass | null;
}
export interface TerminalTextRun extends TerminalRunBase {
renderKind: "text";
/** Holds one glyph whose fallback-font advance is unknown, drawn in its own box. */
isolated: boolean;
segments: TerminalTextSegment[];
}
export interface TerminalCustomGlyphRun extends TerminalRunBase {
@@ -78,6 +110,43 @@ function terminalCharWidth(char: string): number {
return isWide ? 2 : 1;
}
// Text runs render with the font's natural advances. Printable ASCII comes from the terminal font
// and the spaced wide scripts get a measured letter spacing, so both stay on the cell grid. Any
// other glyph (symbols, emoji, jamo) falls back to a font with an unknown advance; inside a run it
// would shift every glyph after it and pull the text away from the cursor.
function resolveGlyphSpacing(
char: string,
cellCount: number,
): TerminalWideGlyphClass | null | "isolated" {
if (char.length !== 1) {
return "isolated";
}
const code = char.charCodeAt(0);
if (cellCount === 1 && code >= 0x20 && code <= 0x7e) {
return null;
}
if (cellCount !== 2) {
return "isolated";
}
const glyphClass = TERMINAL_WIDE_GLYPH_CLASSES.find((spec) =>
spec.ranges.some(([start, end]) => code >= start && code <= end),
);
return glyphClass?.name ?? "isolated";
}
function appendSegment(
segments: TerminalTextSegment[],
text: string,
wideGlyphClass: TerminalWideGlyphClass | null,
): void {
const last = segments[segments.length - 1];
if (last && last.wideGlyphClass === wideGlyphClass) {
last.text += text;
return;
}
segments.push({ text, wideGlyphClass });
}
function shouldSkipSpacerCell(cells: TerminalRenderableCell[], col: number, char: string): boolean {
if (terminalCharWidth(char) < 2) {
return false;
@@ -107,15 +176,23 @@ function appendRun(input: {
col: number;
}): void {
const renderKind = input.customGlyph ? "custom-glyph" : "text";
const spacing = input.customGlyph ? null : resolveGlyphSpacing(input.text, input.cellCount);
const isolated = spacing === "isolated";
const wideGlyphClass = spacing === "isolated" ? null : spacing;
const previousRun = input.runs[input.runs.length - 1];
if (
!isolated &&
previousRun &&
previousRun.styleKey === input.styleKey &&
previousRun.renderKind === renderKind
previousRun.renderKind === renderKind &&
!(previousRun.renderKind === "text" && previousRun.isolated)
) {
const offset = previousRun.cellCount;
previousRun.text += input.text;
previousRun.cellCount += input.cellCount;
if (previousRun.renderKind === "text") {
appendSegment(previousRun.segments, input.text, wideGlyphClass);
}
if (previousRun.renderKind === "custom-glyph" && input.customGlyph) {
previousRun.glyphs.push({
key: `${input.col}:${input.text}`,
@@ -142,7 +219,12 @@ function appendRun(input: {
});
return;
}
input.runs.push({ ...baseRun, renderKind: "text" });
input.runs.push({
...baseRun,
renderKind: "text",
isolated,
segments: [{ text: input.text, wideGlyphClass }],
});
}
function cellIntersectsSelection(input: {
@@ -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 {
-6
View File
@@ -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";
}
-10
View File
@@ -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;

Some files were not shown because too many files have changed in this diff Show More