mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdf300fdeb |
-145
@@ -85,151 +85,6 @@
|
||||
"max-nested-callbacks": ["error", { "max": 3 }]
|
||||
},
|
||||
"overrides": [
|
||||
// Exceptions name individual APIs in individual modules; new APIs still fail in these files.
|
||||
// TODO entries are runtime debt, not startup exemptions. Do not add directory-wide exceptions.
|
||||
{
|
||||
"files": ["packages/server/src/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"],
|
||||
"rules": {
|
||||
"paseo/no-sync-io": [
|
||||
"error",
|
||||
{
|
||||
"allow": {
|
||||
// TODO: literal executableExists is a synchronous public capability; move its callers and Windows existence predicates to async together.
|
||||
"executable-resolution/executable-resolution.ts": ["existsSync"],
|
||||
// TODO: winget enumeration shares synchronous existence predicates with literal lookup; convert the executable-resolution boundary together.
|
||||
"executable-resolution/windows.ts": ["readdirSync"],
|
||||
// TODO: live file-change classification is a synchronous event mapper; move the probe to async event ingestion.
|
||||
"server/agent/providers/claude/agent.ts": ["existsSync"],
|
||||
// TODO: new-session construction still resolves its project directory synchronously; move preparation before construction.
|
||||
"server/agent/providers/claude/project-dir.ts": ["realpathSync"],
|
||||
// TODO: live subagent output is read by a synchronous event mapper; add bounded async loading at ingestion.
|
||||
"server/agent/providers/claude/subagents/workflow-output.ts": [
|
||||
"closeSync",
|
||||
"openSync",
|
||||
"readSync"
|
||||
],
|
||||
// TODO: diagnostics probes provider-owned auth/session storage; convert alongside provider discovery and readiness.
|
||||
"server/agent/providers/omp/agent.ts": ["existsSync"],
|
||||
// TODO: synchronous provider configuration discovery; move discovery into async launch preparation.
|
||||
"server/agent/providers/omp/provider-config.ts": ["existsSync"],
|
||||
// TODO: synchronous session-root selection is shared by descriptor builders; migrate callers with explicit readiness.
|
||||
"server/agent/providers/omp/session-descriptor.ts": ["existsSync"],
|
||||
// TODO: launch directory creation shares the server generation/start state machine; audit concurrent rotation before adding an await.
|
||||
"server/agent/providers/opencode/server-manager.ts": ["mkdirSync"],
|
||||
// TODO: launch preparation owns MCP config merge, extension generation and synchronous cleanup; migrate lifecycle and diagnostics together.
|
||||
"server/agent/providers/pi/agent.ts": [
|
||||
"existsSync",
|
||||
"mkdtempSync",
|
||||
"readFileSync",
|
||||
"rmSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
// Provider child process: Pi loads this extension outside the daemon; it reads child-agent status there.
|
||||
"server/agent/providers/pi/extensions/pi-subagents/index.ts": ["readFileSync"],
|
||||
// Startup/shutdown only: remove stale Unix listening sockets before bind and during disposal.
|
||||
"server/bootstrap.ts": ["existsSync", "unlinkSync"],
|
||||
// Startup configuration resolution: detect the bundled app distribution before the daemon listens.
|
||||
"server/config.ts": ["existsSync"],
|
||||
// Startup identity: load or create the daemon keypair before accepting connections.
|
||||
"server/daemon-keypair.ts": ["existsSync", "readFileSync"],
|
||||
// Fatal exit diagnostics: synchronously flush the exit reason when the worker is about to terminate.
|
||||
"server/daemon-worker.ts": ["appendFileSync", "mkdirSync"],
|
||||
// Startup host identity: memoized scutil lookup is warmed before listening; later callers use the cache.
|
||||
"server/host-name.ts": ["execFileSync"],
|
||||
// TODO: relationship persistence is atomic with enrollment/revocation state transitions; serialize those transitions before async writes.
|
||||
"server/hub/relationship-controller.ts": [
|
||||
"existsSync",
|
||||
"readFileSync",
|
||||
"renameSync",
|
||||
"rmSync"
|
||||
],
|
||||
// Local client bootstrap: reads the local credential and pid lock to select/authenticate a daemon connection.
|
||||
"server/local-credential.ts": ["readFileSync"],
|
||||
// Startup logger construction: create the log directory before opening the logging destination.
|
||||
"server/logger.ts": ["mkdirSync"],
|
||||
// TODO: runtime skill commands resolve their bundled source through a synchronous targets capability; migrate the capability and controller together.
|
||||
"server/orchestration-skills/internal/paths.ts": ["existsSync"],
|
||||
// Module initialization: the package version is resolved once and cached at import.
|
||||
"server/package-version.ts": ["existsSync", "readFileSync"],
|
||||
// Startup config and exported synchronous configuration API; runtime DaemonConfigStore uses the async load/save capabilities.
|
||||
"server/persisted-config.ts": ["existsSync", "readFileSync"],
|
||||
// Startup pid identity: read and memoize the Linux boot id for the daemon lock.
|
||||
"server/pid-lock.ts": ["readFileSync"],
|
||||
// Startup builtin loader construction: select the bundled plugin root before loading plugins.
|
||||
"server/plugins/builtin/index.ts": ["existsSync"],
|
||||
// Build child process: plugin graph traversal and esbuild preparation run in the isolated compiler process.
|
||||
"server/plugins/compiler-build.ts": ["existsSync", "readFileSync", "realpathSync"],
|
||||
// Build child process: select the esbuild binary for the isolated plugin compiler.
|
||||
"server/plugins/compiler-esbuild.ts": ["existsSync"],
|
||||
// Build child process: TypeScript resolution and source reads run in the isolated plugin compiler.
|
||||
"server/plugins/compiler-imports.ts": ["readFileSync", "realpathSync"],
|
||||
// TODO: plugin install/update/remove commits sources.json synchronously; preserve record/write ordering with an async mutation queue.
|
||||
"server/plugins/managed-source.ts": ["existsSync", "readFileSync"],
|
||||
// TODO: shared synchronous persistence supports startup identity plus Hub/plugin/push runtime writers; migrate those owners before removing these exports.
|
||||
"server/private-files.ts": [
|
||||
"chmodSync",
|
||||
"mkdirSync",
|
||||
"realpathSync",
|
||||
"renameSync",
|
||||
"rmSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
// TODO: constructor load plus runtime renew/revoke/prune use synchronous persistence; introduce ordered async mutations and readiness.
|
||||
"server/push/token-store.ts": ["existsSync", "readFileSync"],
|
||||
// Startup identity: load or create the server id before accepting connections.
|
||||
"server/server-id.ts": ["existsSync", "readFileSync"],
|
||||
// Listener lifecycle only: unlink the Unix socket before binding or after stopping the proxy.
|
||||
"server/service-proxy.ts": ["existsSync", "unlinkSync"],
|
||||
// TODO: self-update origin validation exposes synchronous package-root and realpath checks; migrate validation and its runtime interface together.
|
||||
"server/session/daemon/install-origin.ts": ["existsSync", "readFileSync"],
|
||||
// TODO: npm self-update inspection probes the reported install path; move the probe into bounded async installation inspection.
|
||||
"server/session/daemon/npm-global-cli.ts": ["lstatSync"],
|
||||
// Speech child process: model-file validation runs in the worker-backed local speech provider.
|
||||
"server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts": ["existsSync"],
|
||||
// Speech child process: native addon discovery and patchelf run outside the daemon.
|
||||
"server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts": [
|
||||
"existsSync",
|
||||
"spawnSync"
|
||||
],
|
||||
// Speech child process: model-file validation runs in the worker-backed local speech provider.
|
||||
"server/speech/providers/local/sherpa/sherpa-tts.ts": ["existsSync"],
|
||||
// TODO: static request selection and index injection need bounded async serving with response/error ownership; keep this as a separate request-path conversion.
|
||||
"server/web-ui.ts": ["readFileSync", "statSync"],
|
||||
// Startup registry hydration: check persisted workspace directories before the runtime reconciliation service starts.
|
||||
"server/workspace-registry-bootstrap.ts": ["statSync"],
|
||||
// Terminal child process: hook config installation/removal is owned by terminal creation and teardown in the worker.
|
||||
"terminal/agent-hooks/agent-hook-installer.ts": [
|
||||
"existsSync",
|
||||
"readFileSync",
|
||||
"rmSync"
|
||||
],
|
||||
// Node loader thread: TypeScript terminal-worker resolution runs in the loader, outside the daemon event loop.
|
||||
"terminal/terminal-ts-loader.mjs": ["existsSync"],
|
||||
// Terminal child process: executable preparation and shell integration run in the worker-backed terminal manager.
|
||||
"terminal/terminal.ts": [
|
||||
"accessSync",
|
||||
"chmodSync",
|
||||
"existsSync",
|
||||
"mkdirSync",
|
||||
"readFileSync",
|
||||
"statSync"
|
||||
],
|
||||
// TODO: legacy realpath helpers still serve install-origin validation and other synchronous consumers; migrate remaining callers before removing them.
|
||||
"utils/path.ts": ["realpathSync"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/server/src/**/*.{test,spec,fixture}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
|
||||
"packages/server/src/**/test-utils/**",
|
||||
"packages/server/src/server/daemon-e2e/**",
|
||||
"packages/server/src/server/agent/providers/pi/extensions/*-test.ts"
|
||||
],
|
||||
"rules": { "paseo/no-sync-io": "off" }
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"plugins/*-provider/server/**/*.ts",
|
||||
|
||||
@@ -273,26 +273,6 @@ Playwright dispatch lateness. Set `PASEO_PROFILE_TYPING_KEYS`, `PASEO_PROFILE_TY
|
||||
`PASEO_PROFILE_CPU_PATH` and `PASEO_PROFILE_TRACE_PATH` captures run separately after the latency
|
||||
measurements so profiling overhead does not contaminate them.
|
||||
|
||||
For diff layout, two benchmarks share one corpus
|
||||
(`packages/app/src/git/diff-document/benchmark-corpus.ts`: a real source file, a changelog with
|
||||
600-character lines, generated code with ligature punctuation, and JSON). The node bench runs the
|
||||
shared document model with the web and native measurers replaced by counters, so it reports how
|
||||
many strings each backend would have to shape, which is the cost that survives the move to canvas
|
||||
or Skia:
|
||||
|
||||
```bash
|
||||
cd packages/app && npx vitest bench --project unit src/git/diff-document/model.bench.ts
|
||||
```
|
||||
|
||||
The browser benchmark opens the same corpus as a working diff, toggles line wrap from the toolbar,
|
||||
and reports pointer-down to painted latency, the longest main-thread task, and the `DiffDocument`
|
||||
React commit time for cold and warm toggles, then scroll frame times in both modes:
|
||||
|
||||
```bash
|
||||
cd packages/app && PASEO_DIFF_PERF_E2E=1 npx playwright test --project=browser \
|
||||
e2e/browser/diff-performance.spec.ts -g "toggling line wrap"
|
||||
```
|
||||
|
||||
Set `PASEO_PROFILE_TYPING_SCENARIO=height-growth` to alternate `Shift+Enter` and printable input.
|
||||
That report includes input and composer height changes plus React work grouped into composer,
|
||||
stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler
|
||||
@@ -562,39 +542,6 @@ patch every build the consumers use: Metro resolves the `react-native` field of
|
||||
(`src/*.ts` for `react-native-svg`), while Node and Vitest resolve `main`/`module`
|
||||
(`lib/commonjs`, `lib/module`). Patching only `lib/` leaves the app bundle unfixed.
|
||||
|
||||
## Dependency overrides and workspace tooling
|
||||
|
||||
Root `package.json` `overrides` pin one Expo SDK across the tree (`expo`, `expo-router`,
|
||||
`react-native`, …). npm does not apply those overrides to packages it places under a
|
||||
workspace's own `node_modules`. When `expo-module-scripts` was declared in
|
||||
`packages/expo-two-way-audio`, npm nested it there and its `jest-expo` peer `expo@*` resolved to
|
||||
a second, newer Expo SDK and React Native: 200+ duplicate packages nothing imported (#7730).
|
||||
Declare tooling that peer-depends on `expo` or `react-native` in the root `devDependencies`,
|
||||
where the overrides apply; workspace scripts still find the binary through the root
|
||||
`node_modules/.bin`.
|
||||
|
||||
## Dependency signature audit
|
||||
|
||||
The `quality` CI job runs `npm audit signatures`. It verifies every lockfile package's registry
|
||||
signature and, for packages published with provenance, fetches
|
||||
`/-/npm/v1/attestations/<name>@<version>` from the registry. Cloudflare caches that document
|
||||
for a year. An edge that cached it in the seconds before the registry attached the publish
|
||||
attestation keeps serving a document with only the provenance bundle, and npm fails with:
|
||||
|
||||
```
|
||||
npm error code EMISSINGSIGNATUREKEY
|
||||
npm error <name>@<version> has attestations but no corresponding public key(s) can be found
|
||||
```
|
||||
|
||||
The same package's registry signature verified a moment earlier with the same keys, so the
|
||||
key list is intact. The failure names one package, repeats only for that package, and depends
|
||||
on the Azure region the runner landed in, because each region maps to one Cloudflare colo. A
|
||||
rerun passes only by landing elsewhere.
|
||||
|
||||
To confirm, fetch the URL from several runners and compare `content-length`, the `cf-ray` colo
|
||||
suffix, and the number of `attestations` entries (#7730 has the probe). To fix, remove or
|
||||
replace that version in the lockfile and report the URL to npm so they purge it.
|
||||
|
||||
## ACP provider catalog versions
|
||||
|
||||
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
|
||||
|
||||
+1
-8
@@ -430,15 +430,8 @@ The [public reference](../public-docs/plugins/reference.md#icons) owns the autho
|
||||
|
||||
Server entries register lifecycle observers with `server.on()` and request transforms with
|
||||
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
|
||||
owns callback shapes, ordering, and failure behavior. `agent.create` edits the agent's saved
|
||||
configuration; `agent.session_open` contributes launch-only `env`, `tools`, and `mcpServers`,
|
||||
re-derived on each open and following the plugin's lifecycle. See
|
||||
[Inject MCP servers](../public-docs/plugins/reference.md#inject-mcp-servers) for choosing the hook.
|
||||
`plugin-examples/lifecycle-logger` registers all
|
||||
owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all
|
||||
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
|
||||
Session-open hooks also [attach tools to individual agents](../public-docs/plugins/reference.md#attach-tools-to-agents),
|
||||
as shown in `plugin-examples/agent-tools`. The agent manager stamps their metadata per session;
|
||||
executable definitions stay in the plugin process.
|
||||
|
||||
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
|
||||
live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
The app and the daemon are separate products that ship separately. A user updates the app from an app store or a desktop auto-update; they update the daemon when they feel like it. Every combination happens in the wild: new app against an old daemon, old app against a new daemon, and both sides months apart.
|
||||
|
||||
Development can have the same version drift: an app reload picks up new code while a long-running daemon keeps its loaded version. Check the connected daemon version when diagnosing behavior that crosses this boundary.
|
||||
In development both sides are always the same version, which is why this is the constraint contributors miss most often.
|
||||
|
||||
Two contracts follow from it.
|
||||
|
||||
@@ -38,7 +38,7 @@ The app checks for the capability and either runs the feature or tells the user
|
||||
- **No defensive branches spread through the feature.** Detection happens in one place, and everything downstream reads a clean shape.
|
||||
- Capability flags live in `features` on the `server_info` message (`packages/protocol/src/messages.ts`, the `server_info` schema).
|
||||
|
||||
Existing functionality keeps working across versions. Never gate an established request on a new enhancement: negotiate the optional enhancement at the client boundary and preserve the existing request for older hosts. Whole-turn pagination can require a capability; reading conversation history cannot.
|
||||
Existing functionality keeps working across versions because of the protocol contract. Gating a new feature never substitutes for that.
|
||||
|
||||
## Client capability ownership
|
||||
|
||||
|
||||
+10
-25
@@ -13,7 +13,7 @@ and rebuilds the projection when an agent resumes.
|
||||
The invariants are:
|
||||
|
||||
> A continuously subscribed client applies every committed row in order. Opening or resuming an
|
||||
> agent establishes the daemon's current tail in one complete-turn request, with older history reachable
|
||||
> agent establishes the daemon's current tail in one bounded request, with older history reachable
|
||||
> through backward pagination.
|
||||
|
||||
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
|
||||
@@ -44,21 +44,7 @@ recovery is complete only when `hasNewer: false`.
|
||||
|
||||
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
|
||||
|
||||
Opening or resuming an agent fetches the latest available turns with a soft target of 40
|
||||
projected items. Backward pages and prompt-jump windows also include whole turns, including
|
||||
all currently available rows of an active run. There is no per-turn cap: a large run can
|
||||
exceed the target. Local history mounting uses the same boundaries so revealing older rows
|
||||
does not change the collapse state of a turn already displayed.
|
||||
|
||||
`wholeTurns` is a preference negotiated once at the client fetch boundary. Hosts advertising
|
||||
`features.wholeTurnTimeline` receive it; other hosts receive the existing item-based request.
|
||||
Reading history remains available on older hosts, whose pages can still split turns. Forward
|
||||
catch-up stays incremental, but its reset snapshot honors a supported whole-turn request.
|
||||
Source coverage still includes interleaved tool updates.
|
||||
|
||||
Canonical IDs define runs. With missing IDs, human prompts, heartbeats and typed received
|
||||
agent messages each start a fallback turn. This deterministic fallback is shared by paging
|
||||
and presentation; it cannot recover historical batching or steering. Older history remains
|
||||
Opening or resuming an agent fetches one bounded latest tail page. Older history remains
|
||||
user-driven by scrolling upward.
|
||||
|
||||
A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling.
|
||||
@@ -163,13 +149,12 @@ range describes those exact items: `startSeq` drives older pagination and `endSe
|
||||
catch-up. The owner requests `after endSeq`, and requests `before startSeq` when the user loads older
|
||||
history. Code outside the owner does not distinguish cached and network timelines.
|
||||
|
||||
The first resume request is bounded. If it reports more newer history, fetch one latest complete-turn tail
|
||||
The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail
|
||||
instead of replaying every missed page. Live gap recovery still pages forward until current.
|
||||
|
||||
If the canonical window exceeds the cache item target, contains a discontiguous retained range, has a
|
||||
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
|
||||
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
|
||||
range and keeps a display-only tail of complete available turns. The item target is soft;
|
||||
the byte budget still evicts entire cache rows. Never slice items while retaining the pre-slice range; that
|
||||
range and keeps a display-only tail. Never slice items while retaining the pre-slice range; that
|
||||
falsely certifies discarded source rows. A display-only row paints without granting synchronization
|
||||
authority, so the owner uses the ordinary bounded `tail` bootstrap.
|
||||
|
||||
@@ -253,11 +238,11 @@ foreground control ownership remains a separate daemon concern. Cancellation req
|
||||
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions
|
||||
remain a separate pre-turn registry and retire on canonical acknowledgement.
|
||||
|
||||
Every canonical run owns a visible turn, including heartbeats and agent notifications.
|
||||
Notifications delivered during a run stay inside it; batched notifications share the run
|
||||
that starts or accepts them. Received messages retain sender attribution, while lifecycle
|
||||
notifications remain visible events. Collapse, timing and locally mounted history share
|
||||
these boundaries.
|
||||
Canonical turns and visible responses are different boundaries. Agent-originated prompts and lifecycle
|
||||
notifications project into tool rows, so they do not create a human-message boundary. Legacy system
|
||||
prompts, including schedule injections, remain hidden. One visible response can span several canonical
|
||||
turns; layout and copy group that response together while lifecycle, timing, tool sequences, and exact
|
||||
fork positions retain the canonical `turnId` boundaries.
|
||||
|
||||
Agent-message XML belongs to the daemon's `agent/agent-messages` boundary. Prompt delivery encodes
|
||||
provenance; acceptance, provider echoes, import, and replay share its projection. The delivery ID is
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-zgyG4roTTTOnNkqOu10gnwwG8GXcjrKH6XlqOvvqKQo=
|
||||
sha256-eNiKmiZ3XXZMRZRSoGxUbMn2RtY6AX079vUatBYM+qE=
|
||||
|
||||
Generated
+4070
-2329
File diff suppressed because it is too large
Load Diff
@@ -131,7 +131,6 @@
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "44.2.0",
|
||||
"expo-module-scripts": "^5.0.8",
|
||||
"get-port-cli": "^3.0.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"knip": "^5.82.1",
|
||||
|
||||
@@ -53,7 +53,6 @@ import {
|
||||
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
|
||||
import {
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
scrollTimelineUntilOlderHistoryIsReachable,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
import {
|
||||
@@ -987,7 +986,7 @@ test.describe("Agent message submission", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps one prompt when a delayed provider echo belongs to a large whole turn", async ({
|
||||
test("keeps one prompt when its provider echo crosses the bounded tail", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({
|
||||
@@ -1004,10 +1003,8 @@ test.describe("Agent message submission", () => {
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
await page.reload();
|
||||
|
||||
// The complete turn arrives on reload; scrolling mounts its oldest rows
|
||||
// without requiring another page or an increase in total content height.
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await expect(submittedPrompt).toBeVisible();
|
||||
await expect(submittedPrompt).toHaveCount(0);
|
||||
await scrollTimelineUntilOlderHistoryIsReachable(page, prompt);
|
||||
await expect(submittedPrompt).toHaveCount(1);
|
||||
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false");
|
||||
} finally {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { CDPSession, Locator, Page, TestInfo } from "@playwright/test";
|
||||
import { pullRequestCorpus } from "../../src/git/diff-document/benchmark-corpus";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
@@ -297,187 +296,8 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
test("toggling line wrap on a pull-request-sized diff", async ({ page }, testInfo) => {
|
||||
test.setTimeout(300_000);
|
||||
const corpus = await createPullRequestCorpusRepository();
|
||||
const client = await connectSeedClient();
|
||||
|
||||
try {
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: corpus.repo.path },
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? "Failed to create wrap performance workspace");
|
||||
}
|
||||
|
||||
await configureUnwrappedUnifiedDiff(page);
|
||||
await instrumentDiffLatency(page);
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
const openStartedAt = performance.now();
|
||||
await openChangesPanel(page, 90_000);
|
||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||
const open = {
|
||||
readyMs: Math.round(performance.now() - openStartedAt),
|
||||
...(await readDiffDocumentRenders(page)),
|
||||
};
|
||||
|
||||
const toggles: WrapToggleMeasurement[] = [];
|
||||
for (const name of [
|
||||
"wrap on (cold)",
|
||||
"wrap off (cold)",
|
||||
"wrap on (warm)",
|
||||
"wrap off (warm)",
|
||||
]) {
|
||||
toggles.push(await measureWrapToggle(page, name));
|
||||
}
|
||||
const unwrappedScroll = await continuouslyScrollCanvas(page);
|
||||
toggles.push(await measureWrapToggle(page, "wrap on (warm, scrolled)"));
|
||||
const wrappedScroll = await continuouslyScrollCanvas(page);
|
||||
|
||||
const report = {
|
||||
scenario: "wrap-toggle",
|
||||
sourceFiles: corpus.fileCount,
|
||||
sourceLines: corpus.lineCount,
|
||||
open,
|
||||
toggles,
|
||||
scroll: {
|
||||
unwrapped: summarizeScroll(unwrappedScroll),
|
||||
wrapped: summarizeScroll(wrappedScroll),
|
||||
},
|
||||
};
|
||||
await attachPerformanceReport(testInfo, report);
|
||||
console.log(`[perf] Wrap toggle: ${JSON.stringify(report)}`);
|
||||
|
||||
const [wrapOn, wrapOff] = toggles;
|
||||
expect(wrapOn!.scrollHeightAfter).toBeGreaterThan(wrapOn!.scrollHeightBefore);
|
||||
expect(wrapOff!.scrollHeightAfter).toBe(wrapOn!.scrollHeightBefore);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await corpus.repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
interface WrapToggleMeasurement {
|
||||
name: string;
|
||||
/** Pointer down on the toolbar button to two painted frames after the document resized. */
|
||||
latencyMs: number;
|
||||
scrollHeightBefore: number;
|
||||
scrollHeightAfter: number;
|
||||
longestTaskMs: number;
|
||||
longTaskTotalMs: number;
|
||||
diffDocumentCommits: number;
|
||||
diffDocumentRenderMs: number;
|
||||
}
|
||||
|
||||
interface LongTask {
|
||||
startTime: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface DiffLatencyScope {
|
||||
__PASEO_RENDER_PROFILE_ENABLED__?: boolean;
|
||||
__PASEO_RENDER_PROFILE__?: Array<{ id: string; actualDuration: number }>;
|
||||
__PASEO_RESET_RENDER_PROFILE__?: () => void;
|
||||
__PASEO_LONG_TASKS__?: LongTask[];
|
||||
__PASEO_WRAP_TOGGLE_AT__?: number;
|
||||
}
|
||||
|
||||
async function createPullRequestCorpusRepository() {
|
||||
const files = pullRequestCorpus();
|
||||
const repo = await createTempGitRepo("diff-wrap-perf-", {
|
||||
files: files.map((file) => ({ path: file.path, content: "baseline\n" })),
|
||||
});
|
||||
for (const file of files) {
|
||||
await writeFile(path.join(repo.path, file.path), `${file.lines.join("\n")}\n`);
|
||||
}
|
||||
return {
|
||||
repo,
|
||||
fileCount: files.length,
|
||||
lineCount: files.reduce((count, file) => count + file.lines.length, 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function instrumentDiffLatency(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const scope = globalThis as typeof globalThis & DiffLatencyScope;
|
||||
scope.__PASEO_RENDER_PROFILE_ENABLED__ = true;
|
||||
scope.__PASEO_LONG_TASKS__ = [];
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
scope.__PASEO_LONG_TASKS__?.push({ startTime: entry.startTime, duration: entry.duration });
|
||||
}
|
||||
}).observe({ type: "longtask", buffered: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function readDiffDocumentRenders(
|
||||
page: Page,
|
||||
): Promise<{ diffDocumentCommits: number; diffDocumentRenderMs: number }> {
|
||||
return page.evaluate(() => {
|
||||
const scope = globalThis as typeof globalThis & DiffLatencyScope;
|
||||
const samples = (scope.__PASEO_RENDER_PROFILE__ ?? []).filter(
|
||||
(sample) => sample.id === "DiffDocument",
|
||||
);
|
||||
return {
|
||||
diffDocumentCommits: samples.length,
|
||||
diffDocumentRenderMs: Math.round(
|
||||
samples.reduce((total, sample) => total + sample.actualDuration, 0),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function measureWrapToggle(page: Page, name: string): Promise<WrapToggleMeasurement> {
|
||||
const scroll = page.getByTestId("git-diff-scroll");
|
||||
const scrollHeightBefore = await scroll.evaluate((element) => element.scrollHeight);
|
||||
await page.evaluate(() => {
|
||||
const scope = globalThis as typeof globalThis & DiffLatencyScope;
|
||||
scope.__PASEO_RESET_RENDER_PROFILE__?.();
|
||||
scope.__PASEO_LONG_TASKS__ = [];
|
||||
scope.__PASEO_WRAP_TOGGLE_AT__ = undefined;
|
||||
document.addEventListener(
|
||||
"pointerdown",
|
||||
() => {
|
||||
scope.__PASEO_WRAP_TOGGLE_AT__ = performance.now();
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
);
|
||||
});
|
||||
await page.getByTestId("changes-toggle-wrap-lines").click();
|
||||
const measured = await scroll.evaluate(async (element, before) => {
|
||||
const scope = globalThis as typeof globalThis & DiffLatencyScope;
|
||||
const deadline = performance.now() + 240_000;
|
||||
while (element.scrollHeight === before && performance.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
const doneAt = performance.now();
|
||||
// Long task entries are delivered after the task ends; give the observer a turn.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const startedAt = scope.__PASEO_WRAP_TOGGLE_AT__ ?? doneAt;
|
||||
const longTasks = (scope.__PASEO_LONG_TASKS__ ?? []).filter(
|
||||
(task) => task.startTime + task.duration >= startedAt && task.startTime <= doneAt,
|
||||
);
|
||||
return {
|
||||
latencyMs: Math.round(doneAt - startedAt),
|
||||
scrollHeightAfter: element.scrollHeight,
|
||||
longestTaskMs: Math.round(Math.max(0, ...longTasks.map((task) => task.duration))),
|
||||
longTaskTotalMs: Math.round(longTasks.reduce((total, task) => total + task.duration, 0)),
|
||||
};
|
||||
}, scrollHeightBefore);
|
||||
return { name, scrollHeightBefore, ...measured, ...(await readDiffDocumentRenders(page)) };
|
||||
}
|
||||
|
||||
function summarizeScroll(report: Awaited<ReturnType<typeof continuouslyScrollCanvas>>) {
|
||||
const { frameSignatures: _frameSignatures, ...summary } = report;
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function measureAdditionalPerformanceScenario(
|
||||
page: Page,
|
||||
cdp: CDPSession,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { submitMessage, expectComposerEditable } from "../support/helpers/composer";
|
||||
import {
|
||||
focusTerminalSurface,
|
||||
typeInTerminal,
|
||||
@@ -11,11 +12,13 @@ import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon
|
||||
import type { MockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
openAgentTimeline,
|
||||
expectTimelinePromptVisible,
|
||||
holdOlderHistoryPages,
|
||||
openAgentTimeline,
|
||||
scrollThroughOlderHistoryPages,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
|
||||
test("loads history across browser reconnection while retaining old-daemon connections and terminals", async ({
|
||||
test("does not repeat an assistant block when the current app paginates a published 0.2.5 daemon", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
@@ -55,8 +58,22 @@ test("loads history across browser reconnection while retaining old-daemon conne
|
||||
};
|
||||
|
||||
try {
|
||||
await agent.client.sendAgentMessage(agent.agentId, "emit 1 coalesced agent stream updates");
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
await agent.client.sendAgentMessage(
|
||||
agent.agentId,
|
||||
`timeline-pagination-older-turn-${index}: emit 1 coalesced agent stream updates`,
|
||||
);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
}
|
||||
await agent.client.sendAgentMessage(agent.agentId, "build a realistic long mock timeline");
|
||||
await agent.client.waitForFinish(agent.agentId, 20_000);
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
await agent.client.sendAgentMessage(
|
||||
agent.agentId,
|
||||
`timeline-pagination-turn-${index}: emit 1 coalesced agent stream updates`,
|
||||
);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
}
|
||||
|
||||
const host = buildSeededHost({
|
||||
serverId,
|
||||
@@ -72,15 +89,34 @@ test("loads history across browser reconnection while retaining old-daemon conne
|
||||
{ seededHost: host, preferences: buildCreateAgentPreferences() },
|
||||
);
|
||||
|
||||
const history = await holdOlderHistoryPages(page, agent, daemon.port);
|
||||
await openAgentTimeline(page, agent, serverId);
|
||||
await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates");
|
||||
await expectTimelinePromptVisible(
|
||||
page,
|
||||
"timeline-pagination-turn-19: emit 1 coalesced agent stream updates",
|
||||
);
|
||||
await scrollThroughOlderHistoryPages(page, 3, history);
|
||||
|
||||
history.expectRepeatedEntries();
|
||||
await history.expectOwnedTextRendered("Now I have a clearer picture.");
|
||||
expect(observationSockets.size).toBe(1);
|
||||
|
||||
await test.step("reconnect the browser and reload the old daemon history", async () => {
|
||||
observationSockets.clear();
|
||||
await page.reload();
|
||||
await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates");
|
||||
await expect.poll(() => observationSockets.size).toBe(1);
|
||||
await test.step("send a live turn after reconnecting the old daemon", async () => {
|
||||
await daemon.restart();
|
||||
await expect.poll(() => observationSockets.size, { timeout: 30_000 }).toBe(2);
|
||||
await expectComposerEditable(page);
|
||||
await submitMessage(
|
||||
page,
|
||||
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
|
||||
);
|
||||
await expectTimelinePromptVisible(
|
||||
page,
|
||||
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
|
||||
);
|
||||
// The browser and the seed client reconnect independently after the restart.
|
||||
await agent.client.connect();
|
||||
await agent.client.waitForFinish(agent.agentId, 20_000);
|
||||
await page.screenshot({ path: test.info().outputPath("old-daemon-live-reconnect.png") });
|
||||
});
|
||||
|
||||
await test.step("open a terminal and receive its output", async () => {
|
||||
@@ -94,6 +130,7 @@ test("loads history across browser reconnection while retaining old-daemon conne
|
||||
await expect
|
||||
.poll(() => getTerminalBufferText(page), { timeout: 15_000 })
|
||||
.toContain("compatibility-terminal-output");
|
||||
await page.screenshot({ path: test.info().outputPath("old-daemon-terminal.png") });
|
||||
await agent.client.killTerminal(terminalId);
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
||||
@@ -275,123 +274,3 @@ test.describe("Settings toggle tab regression", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function selectSendingOption(
|
||||
page: import("@playwright/test").Page,
|
||||
setting: string,
|
||||
option: string,
|
||||
) {
|
||||
await page.getByRole("button", { name: new RegExp(`^${setting}:`) }).click();
|
||||
await page.getByRole("menuitem", { name: option, exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: `${setting}: ${option}`, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function sendDraftWithShortcut(
|
||||
page: import("@playwright/test").Page,
|
||||
draft: string,
|
||||
shortcut: string,
|
||||
) {
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await composer.fill(draft);
|
||||
await composer.press(shortcut);
|
||||
await expect(composer).toHaveValue("");
|
||||
await expect(page.getByText(draft, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
test("send shortcut selection persists and controls the composer", async ({ page }, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "send-shortcut-" });
|
||||
const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl";
|
||||
try {
|
||||
const agent = await createMockIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Send shortcut",
|
||||
});
|
||||
await openAgentRouteAndExpectFocused({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: agent.id,
|
||||
});
|
||||
await openComposerTab(page, agent.id);
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
|
||||
await test.step("Enter sends by default", async () => {
|
||||
await sendDraftWithShortcut(page, "Default Enter submission", "Enter");
|
||||
});
|
||||
await test.step("choose the explicit modifier shortcut and retain it after reload", async () => {
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Send shortcut: Enter", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Send shortcut: Enter", exact: true }).click();
|
||||
await expect(page.getByRole("menuitem")).toHaveText(["Enter", `${modifierLabel}+Enter`]);
|
||||
// Let the menu opening animation settle for visual QA.
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: testInfo.outputPath("send-shortcut-selector.png") });
|
||||
await page.getByRole("menuitem", { name: `${modifierLabel}+Enter`, exact: true }).click();
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Send shortcut: ${modifierLabel}+Enter`, exact: true }),
|
||||
).toBeVisible();
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await expect(composer).toBeVisible();
|
||||
});
|
||||
await test.step("Enter and Shift+Enter keep editing; modifier Enter sends", async () => {
|
||||
await composer.fill("First line");
|
||||
await composer.press("Enter");
|
||||
await expect(composer).toHaveValue("First line\n");
|
||||
await composer.press("Shift+Enter");
|
||||
await expect(composer).toHaveValue("First line\n\n");
|
||||
await sendDraftWithShortcut(page, "Sent with Control", "Control+Enter");
|
||||
await sendDraftWithShortcut(page, "Sent with Command", "Meta+Enter");
|
||||
});
|
||||
await test.step("switch back to Enter without changing the default send behavior", async () => {
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await selectSendingOption(page, "Send shortcut", "Enter");
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await composer.fill("Shift still edits");
|
||||
await composer.press("Shift+Enter");
|
||||
await expect(composer).toHaveValue("Shift still edits\n");
|
||||
await sendDraftWithShortcut(page, "Restored Enter submission", "Enter");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
for (const behavior of ["Steer", "Interrupt", "Queue"] as const) {
|
||||
test(`modifier send shortcut follows ${behavior} while an agent runs`, async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "running-send-shortcut-",
|
||||
title: "Running send shortcut",
|
||||
model: "one-minute-stream",
|
||||
});
|
||||
const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl";
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await selectSendingOption(page, "Send shortcut", `${modifierLabel}+Enter`);
|
||||
await selectSendingOption(page, "Default send", behavior);
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await sendDraftWithShortcut(page, "Stay running while I write a follow-up", "Control+Enter");
|
||||
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible();
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await composer.fill("Follow-up");
|
||||
await composer.press("Enter");
|
||||
await expect(composer).toHaveValue("Follow-up\n");
|
||||
await composer.press("Control+Enter");
|
||||
await expect(composer).toHaveValue("");
|
||||
await expect(page.getByText("Follow-up", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Send queued message" })).toHaveCount(
|
||||
behavior === "Queue" ? 1 : 0,
|
||||
);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,29 +1,8 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import path from "node:path";
|
||||
import {
|
||||
holdOlderHistoryPages,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
import { getActiveTabTestId } from "../support/helpers/launcher";
|
||||
import type { Page, Locator } from "@playwright/test";
|
||||
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";
|
||||
|
||||
test.use({
|
||||
e2eDaemonConfig: {
|
||||
version: 1,
|
||||
agents: {
|
||||
providers: {
|
||||
codex: {
|
||||
enabled: true,
|
||||
command: [process.execPath, path.resolve("e2e/fixtures/catalog-codex.mjs")],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns";
|
||||
|
||||
async function useChatDisplay(page: Page, chatDisplay: ChatDisplay): Promise<void> {
|
||||
@@ -40,10 +19,7 @@ async function seedAnsweredAgent(title: string) {
|
||||
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.\n" + "Keep the previous response visible.\n".repeat(40),
|
||||
);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Make the fix and run the burst script.");
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
return agent;
|
||||
}
|
||||
@@ -60,84 +36,17 @@ async function rowsBetweenLatestSummaryAndReply(page: Page): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
async function groupedToolsInsideLatestTurn(page: Page): Promise<string[]> {
|
||||
return page.evaluate(() => {
|
||||
const rows = [...document.querySelectorAll("[data-message-id]")];
|
||||
const start = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]"));
|
||||
const end = rows.findLastIndex((row) => row.querySelector("[data-testid=assistant-message]"));
|
||||
return rows
|
||||
.slice(start + 1, end)
|
||||
.flatMap((row) =>
|
||||
[...row.querySelectorAll("[data-testid=tool-call-group]")].map(
|
||||
(group) => group.textContent ?? "",
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function expectCompactMessageToggle(prompt: Locator, label: string) {
|
||||
const button = prompt.getByRole("button", { name: label, exact: true });
|
||||
const text = prompt.getByText(label, { exact: true });
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const control = (await button.boundingBox())!;
|
||||
const glyphs = (await text.boundingBox())!;
|
||||
return control.height - glyphs.height;
|
||||
},
|
||||
{ message: "The inline toggle adds no vertical space beyond its text" },
|
||||
)
|
||||
.toBe(0);
|
||||
}
|
||||
|
||||
async function expandAndCollapseLongPrompt(page: Page) {
|
||||
const prompt = page.getByTestId("user-message").last();
|
||||
const before = await prompt.boundingBox();
|
||||
const text = prompt.locator('[data-message-text="true"]');
|
||||
const messageLeft = (await text.boundingBox())!.x;
|
||||
await expect
|
||||
.poll(async () => (await prompt.getByText("Show more", { exact: true }).boundingBox())!.x)
|
||||
.toBe(messageLeft);
|
||||
await expectCompactMessageToggle(prompt, "Show more");
|
||||
await prompt.getByRole("button", { name: "Show more", exact: true }).click();
|
||||
await expect(prompt.getByRole("button", { name: "Show less", exact: true })).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => (await prompt.getByText("Show less", { exact: true }).boundingBox())!.x)
|
||||
.toBe(messageLeft);
|
||||
await expect
|
||||
.poll(async () => (await prompt.boundingBox())?.height ?? 0)
|
||||
.toBeGreaterThan(before!.height);
|
||||
await expectCompactMessageToggle(prompt, "Show less");
|
||||
await prompt.getByRole("button", { name: "Show less", exact: true }).click();
|
||||
await expect.poll(async () => (await prompt.boundingBox())?.height ?? 0).toBe(before!.height);
|
||||
await prompt.scrollIntoViewIfNeeded();
|
||||
const bounds = (await prompt.boundingBox())!;
|
||||
const body = (await text.boundingBox())!;
|
||||
await page.screenshot({
|
||||
path: "/tmp/message-toggle-aligned.png",
|
||||
clip: {
|
||||
x: body.x - 16,
|
||||
y: bounds.y,
|
||||
width: bounds.x + bounds.width - body.x + 16,
|
||||
height: bounds.height,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Turn collapse", () => {
|
||||
test("folds each answered turn behind a summary line that opens it", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
await useChatDisplay(page, "collapseTurns");
|
||||
const agent = await seedAnsweredAgent("Turn collapse");
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await expandAndCollapseLongPrompt(page);
|
||||
|
||||
const summary = page.getByTestId("turn-summary").last().getByRole("button");
|
||||
await expect(summary).toHaveText(/^Edited 1 file/);
|
||||
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(
|
||||
@@ -148,9 +57,6 @@ test.describe("Turn collapse", () => {
|
||||
await summary.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0);
|
||||
await expect(summary).toBeInViewport();
|
||||
await summary.evaluate((element) => element.scrollIntoView({ block: "start" }));
|
||||
await page.screenshot({ path: "/tmp/turn-rail-aligned.png" });
|
||||
await expect.poll(() => groupedToolsInsideLatestTurn(page)).toEqual([]);
|
||||
|
||||
await summary.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
@@ -159,255 +65,3 @@ test.describe("Turn collapse", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("received messages show their sender and open that agent", async ({ page }) => {
|
||||
await page.addInitScript(() =>
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "dark" })),
|
||||
);
|
||||
const recipient = await seedMockAgentWorkspace({
|
||||
repoPrefix: "sender-attribution-",
|
||||
title: "Implementation",
|
||||
});
|
||||
try {
|
||||
const sender = await recipient.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: recipient.cwd,
|
||||
workspaceId: recipient.workspaceId,
|
||||
title: "Reviewer",
|
||||
model: "gpt-6.1-sol",
|
||||
modeId: "full-access",
|
||||
});
|
||||
await recipient.client.sendAgentMessage(
|
||||
recipient.agentId,
|
||||
"I reviewed the turn-grouping changes. Heartbeats now keep their own timing footer, and the previous response stays visible.",
|
||||
{ sourceAgentId: sender.id },
|
||||
);
|
||||
await openAgentRoute(page, recipient);
|
||||
await expectComposerVisible(page);
|
||||
const attribution = page.getByRole("button", { name: "Sent by Reviewer", exact: true });
|
||||
await expect(attribution).toBeVisible();
|
||||
await expect(page.getByTestId("user-message").filter({ has: attribution })).toContainText(
|
||||
"I reviewed the turn-grouping changes.",
|
||||
);
|
||||
const labelColor = await attribution
|
||||
.getByText("Sent by Reviewer", { exact: true })
|
||||
.evaluate((element) => getComputedStyle(element).color);
|
||||
await expect(attribution.locator("svg")).toHaveCSS("fill", labelColor);
|
||||
await attribution.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: "/tmp/sender-attribution.png" });
|
||||
await attribution.click();
|
||||
await expect.poll(() => getActiveTabTestId(page)).toContain(sender.id);
|
||||
} finally {
|
||||
await recipient.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function seedWholeTurnHistory() {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "whole-turn-history-",
|
||||
title: "Whole turns",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
for (const prompt of [
|
||||
...Array.from(
|
||||
{ length: 25 },
|
||||
(_, index) => `Short ${index}: emit 1 coalesced agent stream updates`,
|
||||
),
|
||||
"Older long turn: emit 60 tool agent stream updates",
|
||||
"Latest long turn: emit 120 tool agent stream updates",
|
||||
]) {
|
||||
await agent.client.sendAgentMessage(agent.agentId, prompt);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
test("loads complete long turns and preserves expansion while revealing older history", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await useChatDisplay(page, "collapseTurns");
|
||||
const agent = await seedWholeTurnHistory();
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
const latest = page
|
||||
.getByTestId("turn-summary")
|
||||
.getByRole("button", { name: "Ran 120 commands", exact: true });
|
||||
await expect(latest).toBeVisible();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
await latest.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
history.releasePage(1);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("turn-summary")
|
||||
.getByRole("button", { name: "Ran 60 commands", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
|
||||
history.expectNoRepeatedEntries();
|
||||
await latest.scrollIntoViewIfNeeded();
|
||||
await latest.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function useThinkingDisplay(
|
||||
page: Page,
|
||||
autoExpandReasoning: boolean,
|
||||
chatDisplay: ChatDisplay,
|
||||
) {
|
||||
await page.addInitScript(
|
||||
(settings) => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({
|
||||
...settings,
|
||||
chatDisplay: sessionStorage.getItem("e2e-chat-display") ?? settings.chatDisplay,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ autoExpandReasoning, chatDisplay },
|
||||
);
|
||||
}
|
||||
|
||||
for (const autoExpandReasoning of [false, true]) {
|
||||
test(`reasoning follows Expand thinking (${autoExpandReasoning}) when opening its turn`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
await useThinkingDisplay(page, autoExpandReasoning, "collapseToolCalls");
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "thinking-group-",
|
||||
title: "Thinking with tools",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "Inspect the scroll container",
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
const reasoning = page
|
||||
.getByText(/Need to find the scroll container, the layout effect/)
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expectThinkingPresentation(page, autoExpandReasoning);
|
||||
await switchToCollapsedTurns(page);
|
||||
await expectComposerVisible(page);
|
||||
const summary = page.getByTestId("turn-summary").last().getByRole("button");
|
||||
await expect(summary).toBeVisible();
|
||||
await expect(reasoning).toHaveCount(0);
|
||||
await summary.click();
|
||||
await expect(reasoning).toHaveCount(autoExpandReasoning ? 1 : 0);
|
||||
await expect.poll(() => groupedToolsInsideLatestTurn(page)).toEqual([]);
|
||||
await openOneToolOutput(page);
|
||||
await summary.click();
|
||||
await expect(reasoning).toHaveCount(0);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function openOneToolOutput(page: Page) {
|
||||
const output = page.getByText(/\[burst\] tick 1 userIsAtBottom=true/).filter({ visible: true });
|
||||
await expect(output).toHaveCount(0);
|
||||
const command = page
|
||||
.getByTestId("tool-call-badge")
|
||||
.filter({ hasText: "node scripts/simulate-stream-burst.mjs" })
|
||||
.first();
|
||||
await command.getByRole("button").first().click();
|
||||
await expect(output.first()).toBeVisible();
|
||||
await command.getByRole("button").first().click();
|
||||
await expect(output).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function expectThinkingPresentation(page: Page, autoExpandReasoning: boolean) {
|
||||
const reasoning = page
|
||||
.getByText(/Need to find the scroll container, the layout effect/)
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
if (autoExpandReasoning) {
|
||||
await expect(reasoning).toBeVisible();
|
||||
await expect(page.getByTestId("tool-call-group").filter({ hasText: /thought/ })).toHaveCount(0);
|
||||
} else {
|
||||
await expect(reasoning).toHaveCount(0);
|
||||
const group = page
|
||||
.getByTestId("tool-call-group")
|
||||
.filter({ hasText: /thought/, visible: true })
|
||||
.last();
|
||||
await expect(group).toContainText(/read 1 file/i);
|
||||
await group.getByRole("button").first().click();
|
||||
await expect(reasoning).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
async function switchToCollapsedTurns(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
sessionStorage.setItem("e2e-chat-display", "collapseTurns");
|
||||
});
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
test("a scheduled heartbeat opens a separate turn without reopening the previous response", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await useChatDisplay(page, "collapseTurns");
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "heartbeat-turn-",
|
||||
title: "Heartbeat turns",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "Inspect the scroll container",
|
||||
});
|
||||
const schedules = agent.client as unknown as Pick<
|
||||
DaemonClient,
|
||||
"scheduleCreate" | "scheduleRunOnce" | "scheduleDelete"
|
||||
>;
|
||||
let scheduleId: string | undefined;
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
const summaries = page.getByTestId("turn-summary");
|
||||
await expect(summaries).toHaveCount(1);
|
||||
const previousSummary = await summaries.first().innerText();
|
||||
const previousReplyId = await page
|
||||
.getByTestId("assistant-message")
|
||||
.last()
|
||||
.evaluate((element) => element.closest("[data-message-id]")!.getAttribute("data-message-id"));
|
||||
const created = await schedules.scheduleCreate({
|
||||
name: "Check the completed work",
|
||||
prompt: "Inspect the scroll container again",
|
||||
cadence: { type: "cron", expression: "0 0 1 1 *" },
|
||||
target: { type: "self", agentId: agent.agentId },
|
||||
runOnCreate: false,
|
||||
});
|
||||
expect(created.error).toBeNull();
|
||||
scheduleId = created.schedule!.id;
|
||||
const fired = await schedules.scheduleRunOnce({ id: scheduleId });
|
||||
expect(fired.error).toBeNull();
|
||||
await expect(
|
||||
page.getByTestId("tool-call-badge").filter({ hasText: /^Heartbeat/ }),
|
||||
).toBeVisible();
|
||||
await expect(summaries.first()).toHaveText(previousSummary);
|
||||
const previousReply = page
|
||||
.locator(`[data-message-id="${previousReplyId}"]`)
|
||||
.getByTestId("assistant-message");
|
||||
await expect(previousReply).toBeVisible();
|
||||
await expect(previousReply).toContainText("(end of synthetic stream)");
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await expect(summaries).toHaveCount(2);
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
await expect(page.getByRole("button", { name: /^Worked for/ })).toHaveCount(2);
|
||||
await page.screenshot({ path: "/tmp/heartbeat-turn-footer.png" });
|
||||
} finally {
|
||||
if (scheduleId) await schedules.scheduleDelete({ id: scheduleId });
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19,9 +19,6 @@ export async function addCustomModel(page: Page, id: string): Promise<void> {
|
||||
.getByRole("button", { name: "Add", exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1);
|
||||
// Config updates before provider refresh finishes and dismisses the add sheet.
|
||||
// Resizing or interacting with the model list must wait for that save to finish.
|
||||
await expect(page.getByTestId("add-custom-model-sheet")).toBeHidden();
|
||||
}
|
||||
|
||||
export async function removeCustomModel(page: Page, id: string): Promise<void> {
|
||||
|
||||
@@ -149,11 +149,7 @@ export interface SeedDaemonClient {
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
cancelAgent(agentId: string): Promise<void>;
|
||||
sendAgentMessage(
|
||||
agentId: string,
|
||||
text: string,
|
||||
options?: { sourceAgentId?: string },
|
||||
): Promise<void>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
waitForFinish(
|
||||
agentId: string,
|
||||
timeout?: number,
|
||||
|
||||
@@ -76,8 +76,8 @@
|
||||
"@xterm/addon-search": "^0.17.0-beta.213",
|
||||
"@xterm/addon-unicode11": "^0.10.0-beta.213",
|
||||
"@xterm/addon-web-links": "^0.13.0-beta.213",
|
||||
"@xterm/addon-webgl": "0.20.0-beta.291",
|
||||
"@xterm/xterm": "6.1.0-beta.292",
|
||||
"@xterm/addon-webgl": "^0.20.0-beta.212",
|
||||
"@xterm/xterm": "^6.1.0-beta.213",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "~57.0.30",
|
||||
"expo-asset": "~57.0.20",
|
||||
|
||||
@@ -71,7 +71,7 @@ function layoutFor(input: { platform: "web" | "native"; isTurnActive: boolean })
|
||||
isTurnActive: input.isTurnActive,
|
||||
history: strategy.orderTail([userMessage("user"), assistant]),
|
||||
liveHead: [],
|
||||
timingByItemId: timingFor(assistant.id),
|
||||
timingByAssistantId: timingFor(assistant.id),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { continuesTurn } from "./turn-membership";
|
||||
|
||||
export const DEFAULT_MOUNTED_RECENT_STREAM_ITEMS = 20;
|
||||
|
||||
@@ -33,7 +32,7 @@ export function findMountedWindowStart(input: {
|
||||
}
|
||||
|
||||
let startIndex = Math.max(items.length - minMountedCount, 0);
|
||||
while (startIndex > 0 && continuesTurn(items[startIndex - 1], items[startIndex])) {
|
||||
while (startIndex > 0 && items[startIndex]?.kind !== "user_message") {
|
||||
startIndex -= 1;
|
||||
}
|
||||
return startIndex;
|
||||
|
||||
@@ -104,7 +104,7 @@ function layoutFor(input: {
|
||||
strategy,
|
||||
streamHead: input.head ?? [],
|
||||
}),
|
||||
timingByItemId: timingFor(...(input.timingIds ?? [])),
|
||||
timingByAssistantId: timingFor(...(input.timingIds ?? [])),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
|
||||
}
|
||||
|
||||
describe("layoutStream", () => {
|
||||
it("keeps separate footers for an assistant turn and an adjacent tool-only turn", () => {
|
||||
it("places one response footer after an adjacent tagged tool-only turn", () => {
|
||||
const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1");
|
||||
const nextTool = toolCall("next-tool", 2, "turn-2");
|
||||
const layout = layoutFor({
|
||||
@@ -157,8 +157,8 @@ describe("layoutStream", () => {
|
||||
timingIds: [priorAssistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter?.itemId).toBe(nextTool.id);
|
||||
expect(footerOwners(layout)).toEqual([priorAssistant.id, nextTool.id]);
|
||||
expect(layout.auxiliaryTurnFooter?.itemId).toBe(priorAssistant.id);
|
||||
expect(footerOwners(layout)).toEqual([priorAssistant.id]);
|
||||
});
|
||||
|
||||
it("recomputes cached history layout when only the live boundary turn changes", () => {
|
||||
@@ -171,7 +171,7 @@ describe("layoutStream", () => {
|
||||
isTurnActive: true,
|
||||
history,
|
||||
liveHead,
|
||||
timingByItemId: timingFor(priorAssistant.id),
|
||||
timingByAssistantId: timingFor(priorAssistant.id),
|
||||
});
|
||||
|
||||
liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" };
|
||||
@@ -180,7 +180,7 @@ describe("layoutStream", () => {
|
||||
isTurnActive: true,
|
||||
history,
|
||||
liveHead,
|
||||
timingByItemId: timingFor(priorAssistant.id),
|
||||
timingByAssistantId: timingFor(priorAssistant.id),
|
||||
});
|
||||
|
||||
expect(footerOwners(first)).toEqual([]);
|
||||
|
||||
@@ -39,13 +39,13 @@ export interface StreamLayoutInput {
|
||||
isTurnActive: boolean;
|
||||
history: StreamItem[];
|
||||
liveHead: StreamItem[];
|
||||
timingByItemId: Map<string, TurnTiming>;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
}
|
||||
|
||||
interface LayoutSegmentInput {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
timingByItemId: Map<string, TurnTiming>;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
hasAuxiliaryFooter: boolean;
|
||||
frameOrder: StreamFrameChildOrder;
|
||||
@@ -57,8 +57,8 @@ interface LayoutSegmentInput {
|
||||
phase: "streaming" | "complete";
|
||||
}
|
||||
|
||||
interface TurnFooterSource {
|
||||
item: StreamItem;
|
||||
interface AssistantFooterSource {
|
||||
item: Extract<StreamItem, { kind: "assistant_message" }>;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
}
|
||||
@@ -67,26 +67,23 @@ function createTurnFooterHost(input: {
|
||||
item: StreamItem;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
timingByItemId: Map<string, TurnTiming>;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
}): TurnFooterHost {
|
||||
return {
|
||||
itemId: input.item.id,
|
||||
items: input.items,
|
||||
timing: input.timingByItemId.get(input.item.id),
|
||||
timing: input.timingByAssistantId.get(input.item.id),
|
||||
startIndex: input.index,
|
||||
};
|
||||
}
|
||||
|
||||
function findTurnFooterSource(input: {
|
||||
function findLatestAssistantInResponse(input: {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
startIndex: number;
|
||||
boundaryAboveItems?: StreamItem[] | null;
|
||||
boundaryAboveIndex?: number | null;
|
||||
}): TurnFooterSource | null {
|
||||
const last = input.items[input.startIndex];
|
||||
const toolFallback: TurnFooterSource | null =
|
||||
last?.kind === "tool_call" ? { item: last, items: input.items, index: input.startIndex } : null;
|
||||
}): AssistantFooterSource | null {
|
||||
let items = input.items;
|
||||
let index = input.startIndex;
|
||||
let canCrossBoundary = true;
|
||||
@@ -100,7 +97,7 @@ function findTurnFooterSource(input: {
|
||||
) {
|
||||
const item = items[index];
|
||||
if (!item || (laterItem && !continuesResponse(item, laterItem))) {
|
||||
return toolFallback;
|
||||
return null;
|
||||
}
|
||||
if (item.kind === "assistant_message") {
|
||||
return { item, items, index };
|
||||
@@ -114,7 +111,7 @@ function findTurnFooterSource(input: {
|
||||
input.boundaryAboveIndex === null ||
|
||||
input.boundaryAboveIndex === undefined
|
||||
) {
|
||||
return toolFallback;
|
||||
return null;
|
||||
}
|
||||
|
||||
items = input.boundaryAboveItems;
|
||||
@@ -134,20 +131,20 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
|
||||
return null;
|
||||
}
|
||||
|
||||
const source = findTurnFooterSource({
|
||||
const assistant = findLatestAssistantInResponse({
|
||||
strategy: input.strategy,
|
||||
items: footerItems,
|
||||
startIndex: latestIndex,
|
||||
});
|
||||
if (!source) {
|
||||
if (!assistant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createTurnFooterHost({
|
||||
item: source.item,
|
||||
items: source.items,
|
||||
index: source.index,
|
||||
timingByItemId: input.timingByItemId,
|
||||
item: assistant.item,
|
||||
items: assistant.items,
|
||||
index: assistant.index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -157,7 +154,7 @@ function resolveCompletedFooter(input: {
|
||||
index: number;
|
||||
item: StreamItem;
|
||||
belowItem: StreamItem | null;
|
||||
timingByItemId: Map<string, TurnTiming>;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
boundaryAboveItems: StreamItem[] | null;
|
||||
boundaryAboveIndex: number | null;
|
||||
@@ -166,21 +163,21 @@ function resolveCompletedFooter(input: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const source = findTurnFooterSource({
|
||||
const assistant = findLatestAssistantInResponse({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
startIndex: input.index,
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
});
|
||||
if (!source || input.auxiliaryTurnFooter?.itemId === source.item.id) {
|
||||
if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) {
|
||||
return null;
|
||||
}
|
||||
return createTurnFooterHost({
|
||||
item: source.item,
|
||||
items: source.items,
|
||||
index: source.index,
|
||||
timingByItemId: input.timingByItemId,
|
||||
item: assistant.item,
|
||||
items: assistant.items,
|
||||
index: assistant.index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -309,7 +306,7 @@ function layoutSegmentItem(
|
||||
index,
|
||||
item,
|
||||
belowItem,
|
||||
timingByItemId: input.timingByItemId,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
@@ -378,7 +375,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
history = layoutSegment({
|
||||
strategy: input.strategy,
|
||||
items: input.history,
|
||||
timingByItemId: input.timingByItemId,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter,
|
||||
hasAuxiliaryFooter,
|
||||
frameOrder,
|
||||
@@ -398,7 +395,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
const liveHead = layoutSegment({
|
||||
strategy: input.strategy,
|
||||
items: input.liveHead,
|
||||
timingByItemId: input.timingByItemId,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter,
|
||||
hasAuxiliaryFooter,
|
||||
frameOrder,
|
||||
|
||||
@@ -69,8 +69,8 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
historyStart: 2,
|
||||
});
|
||||
|
||||
expect(model.turnTiming.byItemId.has("hidden-a")).toBe(false);
|
||||
expect(model.turnTiming.byItemId.get("visible-a")).toEqual({
|
||||
expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false);
|
||||
expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({
|
||||
completedAt: tail[3]?.timestamp,
|
||||
durationMs: 1000,
|
||||
});
|
||||
@@ -184,7 +184,7 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
});
|
||||
|
||||
expect(model.turnTiming.runningStartedAt).toBe(tail[0]?.timestamp);
|
||||
expect(model.turnTiming.byItemId.has("live-a")).toBe(false);
|
||||
expect(model.turnTiming.byAssistantId.has("live-a")).toBe(false);
|
||||
});
|
||||
|
||||
it("maps completed turn timing to assistant ids across committed history and live head", () => {
|
||||
@@ -201,7 +201,7 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
});
|
||||
|
||||
expect(model.turnTiming.runningStartedAt).toBe(null);
|
||||
expect(model.turnTiming.byItemId.get("live-a")).toEqual({
|
||||
expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({
|
||||
completedAt: head[0]?.timestamp,
|
||||
durationMs: 3000,
|
||||
});
|
||||
@@ -220,7 +220,7 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
});
|
||||
|
||||
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
|
||||
expect(model.turnTiming.byItemId.get("a1")).toEqual({
|
||||
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({
|
||||
completedAt: tail[1]?.timestamp,
|
||||
durationMs: 3000,
|
||||
});
|
||||
@@ -238,7 +238,7 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(model.turnTiming.byItemId.size).toBe(0);
|
||||
expect(model.turnTiming.byAssistantId.size).toBe(0);
|
||||
});
|
||||
|
||||
it.each(["web", "native"] as const)(
|
||||
@@ -274,10 +274,10 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
|
||||
expect(second.history).toBe(first.history);
|
||||
expect(second.segments.historyMounted).toBe(first.segments.historyMounted);
|
||||
expect(second.turnTiming.byItemId).toBe(first.turnTiming.byItemId);
|
||||
expect(second.turnTiming.byAssistantId).toBe(first.turnTiming.byAssistantId);
|
||||
expect(first.history).toHaveLength(20);
|
||||
expect(first.turnTiming.byItemId.has("a29")).toBe(false);
|
||||
expect(first.turnTiming.byItemId.get("a38")).toEqual({
|
||||
expect(first.turnTiming.byAssistantId.has("a29")).toBe(false);
|
||||
expect(first.turnTiming.byAssistantId.get("a38")).toEqual({
|
||||
completedAt: tail[77]?.timestamp,
|
||||
durationMs: 1000,
|
||||
});
|
||||
|
||||
@@ -588,129 +588,3 @@ describe("timeline presentation", () => {
|
||||
expect(projectTimelineItems([source], () => [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("opening a turn reveals individual calls rather than another tool group", () => {
|
||||
const present = createStreamPresentation();
|
||||
const timestamp = new Date();
|
||||
const tail: StreamItem[] = [
|
||||
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
|
||||
toolCall("one", "Bash"),
|
||||
toolCall("two", "Bash"),
|
||||
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
|
||||
];
|
||||
const result = present({
|
||||
...presentationOptions,
|
||||
transform: undefined,
|
||||
tail,
|
||||
head: [],
|
||||
display: "collapseTurns",
|
||||
isTurnActive: false,
|
||||
expandedTurnHostIds: new Set(["prompt"]),
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual(tail.map((item) => item.id));
|
||||
expect(result.groupsByHostId.size).toBe(0);
|
||||
});
|
||||
|
||||
it("presents received agent messages as attributed bubbles with the original turn identity", () => {
|
||||
const call = toolCall("received", "agent_message");
|
||||
if (call.payload.source !== "agent") throw new Error("Expected agent tool");
|
||||
call.turnId = "turn";
|
||||
call.payload.data.agentMessage = {
|
||||
event: "message",
|
||||
sender: { id: "sender", title: "Reviewer" },
|
||||
text: "Ready",
|
||||
};
|
||||
const result = createStreamPresentation()({
|
||||
...presentationOptions,
|
||||
transform: undefined,
|
||||
tail: [call],
|
||||
head: [],
|
||||
});
|
||||
expect(result.tail[0]).toMatchObject({
|
||||
kind: "user_message",
|
||||
id: call.id,
|
||||
turnId: "turn",
|
||||
text: "Ready",
|
||||
sender: { id: "sender", title: "Reviewer" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reopen the previous turn while a new run waits for its first row", () => {
|
||||
const timestamp = new Date();
|
||||
const tail: StreamItem[] = [
|
||||
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
|
||||
toolCall("one", "Bash"),
|
||||
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
|
||||
];
|
||||
for (const item of tail) item.turnId = "completed";
|
||||
const result = createStreamPresentation()({
|
||||
...presentationOptions,
|
||||
transform: undefined,
|
||||
tail,
|
||||
head: [],
|
||||
display: "collapseTurns",
|
||||
isTurnActive: true,
|
||||
activeTurnId: "new",
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
|
||||
});
|
||||
|
||||
it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => {
|
||||
const present = createStreamPresentation();
|
||||
const timestamp = new Date();
|
||||
const thought: StreamItem = {
|
||||
kind: "thought",
|
||||
id: "thought",
|
||||
text: "Consider this",
|
||||
status: "ready",
|
||||
timestamp,
|
||||
};
|
||||
const tail: StreamItem[] = [toolCall("one", "Bash"), thought, toolCall("two", "Bash")];
|
||||
for (const item of tail) item.turnId = "run";
|
||||
const input = {
|
||||
...presentationOptions,
|
||||
transform: undefined,
|
||||
tail,
|
||||
head: [],
|
||||
display: "collapseToolCalls" as const,
|
||||
isTurnActive: false,
|
||||
};
|
||||
const grouped = present({ ...input, autoExpandReasoning: false });
|
||||
expect(grouped.tail).toHaveLength(1);
|
||||
expect(grouped.groupsByHostId.get("tool-one")?.run.calls).toEqual(tail);
|
||||
const separate = present({ ...input, autoExpandReasoning: true });
|
||||
expect(separate.tail.map((item) => item.id)).toEqual(["tool-one", "thought", "tool-two"]);
|
||||
expect(separate.groupsByHostId.has("thought")).toBe(false);
|
||||
const next = { ...thought, id: "next", turnId: "next-run" };
|
||||
const bounded = present({ ...input, tail: [...tail, next], autoExpandReasoning: false });
|
||||
expect(bounded.tail.map((item) => item.id)).toEqual(["tool-one", "next"]);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"reveals raw reasoning when opening a turn (Expand thinking %s)",
|
||||
(autoExpandReasoning) => {
|
||||
const timestamp = new Date();
|
||||
const tail: StreamItem[] = [
|
||||
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
|
||||
{ kind: "thought", id: "thought", text: "Consider this", status: "ready", timestamp },
|
||||
toolCall("one", "Bash"),
|
||||
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
|
||||
];
|
||||
const input = {
|
||||
...presentationOptions,
|
||||
transform: undefined,
|
||||
tail,
|
||||
head: [],
|
||||
display: "collapseTurns" as const,
|
||||
isTurnActive: false,
|
||||
autoExpandReasoning,
|
||||
};
|
||||
const present = createStreamPresentation();
|
||||
const closed = present(input);
|
||||
expect(closed.tail.some((item) => item.id === "thought")).toBe(false);
|
||||
const opened = present({ ...input, expandedTurnHostIds: new Set(["prompt"]) });
|
||||
expect(opened.tail).toEqual(tail);
|
||||
expect(opened.turns.nestedItemIds.has("thought")).toBe(true);
|
||||
expect(opened.groupsByHostId.size).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isTurnBoundary } from "./turn-membership";
|
||||
import type { ChatDisplay } from "@/hooks/use-settings";
|
||||
import type { TimelineItemTransform } from "@/plugins/timeline/model";
|
||||
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
|
||||
@@ -17,11 +16,9 @@ interface PresentationInput {
|
||||
head: StreamItem[];
|
||||
transform: TimelineItemTransform | undefined;
|
||||
display: ChatDisplay;
|
||||
autoExpandReasoning?: boolean;
|
||||
/** User messages whose collapsed turn the reader opened. */
|
||||
expandedTurnHostIds: ReadonlySet<string>;
|
||||
isTurnActive: boolean;
|
||||
activeTurnId?: string | null;
|
||||
}
|
||||
|
||||
export interface StreamPresentation extends ToolCallDetailProjection {
|
||||
@@ -58,7 +55,6 @@ export function createStreamPresentation() {
|
||||
let historyTransform: TimelineItemTransform | undefined;
|
||||
let historyRows: StreamItem[] = [];
|
||||
let preparedTail: StreamItem[] | undefined;
|
||||
let preparedAutoExpandReasoning: boolean | undefined;
|
||||
let preparedLevel: ToolCallDetailLevel | undefined;
|
||||
let preparedHistory: PreparedToolCallHistory | null = null;
|
||||
let collapsedSource: StreamItem[] | undefined;
|
||||
@@ -67,22 +63,6 @@ export function createStreamPresentation() {
|
||||
let collapsed: TurnCollapse | undefined;
|
||||
|
||||
function presentRows(item: StreamItem): StreamItem[] {
|
||||
if (item.kind === "tool_call" && item.payload.source === "agent") {
|
||||
const message = item.payload.data.agentMessage;
|
||||
if (message?.event === "message") {
|
||||
return [
|
||||
{
|
||||
kind: "user_message",
|
||||
id: item.id,
|
||||
turnId: item.turnId,
|
||||
timelineCursor: item.timelineCursor,
|
||||
timestamp: item.timestamp,
|
||||
text: message.text,
|
||||
sender: message.sender,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
return [item.kind === "user_message" ? presentUserMessage(item) : item];
|
||||
}
|
||||
|
||||
@@ -95,14 +75,7 @@ export function createStreamPresentation() {
|
||||
}
|
||||
// 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) &&
|
||||
!isTurnBoundary(projection.tail.at(-1) ?? null, projection.head[0] ?? null) &&
|
||||
!(
|
||||
input.activeTurnId &&
|
||||
projection.tail.at(-1)?.turnId &&
|
||||
input.activeTurnId !== projection.tail.at(-1)?.turnId
|
||||
);
|
||||
const isLatestTurnOpen = input.isTurnActive || projection.head.length > 0;
|
||||
if (
|
||||
!collapsed ||
|
||||
collapsedSource !== projection.tail ||
|
||||
@@ -120,20 +93,7 @@ export function createStreamPresentation() {
|
||||
collapsedExpandedHostIds = input.expandedTurnHostIds;
|
||||
}
|
||||
const { tail, ...turns } = collapsed;
|
||||
if (turns.nestedItemIds.size === 0) return { ...projection, tail, turns };
|
||||
// Opening a turn reveals its raw calls; those rows must not become group hosts again.
|
||||
const withoutExpandedRows = (groups: StreamPresentation["groupsByHostId"]) => ({
|
||||
size: groups.size - [...turns.nestedItemIds].filter((id) => groups.has(id)).length,
|
||||
get: (id: string) => (turns.nestedItemIds.has(id) ? undefined : groups.get(id)),
|
||||
has: (id: string) => !turns.nestedItemIds.has(id) && groups.has(id),
|
||||
});
|
||||
return {
|
||||
...projection,
|
||||
tail,
|
||||
turns,
|
||||
groupsByHostId: withoutExpandedRows(projection.groupsByHostId),
|
||||
historyGroupUpdatesByHostId: withoutExpandedRows(projection.historyGroupUpdatesByHostId),
|
||||
};
|
||||
return { ...projection, tail, turns };
|
||||
}
|
||||
|
||||
return (input: PresentationInput): StreamPresentation => {
|
||||
@@ -151,15 +111,10 @@ export function createStreamPresentation() {
|
||||
const blocks = presentRows(item);
|
||||
head.push(...blocks);
|
||||
}
|
||||
if (
|
||||
preparedTail !== historyRows ||
|
||||
preparedLevel !== level ||
|
||||
preparedAutoExpandReasoning !== input.autoExpandReasoning
|
||||
) {
|
||||
preparedHistory = prepareToolCallHistory(level, historyRows, input.autoExpandReasoning);
|
||||
if (preparedTail !== historyRows || preparedLevel !== level) {
|
||||
preparedHistory = prepareToolCallHistory(level, historyRows);
|
||||
preparedTail = historyRows;
|
||||
preparedLevel = level;
|
||||
preparedAutoExpandReasoning = input.autoExpandReasoning;
|
||||
}
|
||||
return collapse(
|
||||
projectToolCallDetailLevel({
|
||||
|
||||
@@ -214,7 +214,7 @@ describe("neighbor and traversal semantics", () => {
|
||||
).toBe("assistant-1\n\nassistant-2");
|
||||
});
|
||||
|
||||
it("keeps copied content within the canonical turn without a visible prompt", () => {
|
||||
it("collects copy content across adjacent turns without a visible prompt", () => {
|
||||
const chronological: StreamItem[] = [
|
||||
{ ...assistantMessage("a1", "first turn", 1), turnId: "turn-1" },
|
||||
{ ...toolCall("tool", 2), turnId: "turn-2" },
|
||||
@@ -228,7 +228,7 @@ describe("neighbor and traversal semantics", () => {
|
||||
items: chronological,
|
||||
startIndex: 2,
|
||||
}),
|
||||
).toBe("second turn");
|
||||
).toBe("first turn\n\nsecond turn");
|
||||
});
|
||||
|
||||
it("returns undefined neighbor when index would be out of bounds", () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import { reduceStreamUpdate, type StreamItem } from "@/types/stream";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { StreamRenderInput, StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
|
||||
import { createWebStreamStrategy } from "./strategy-web";
|
||||
|
||||
@@ -131,79 +131,6 @@ describe("createWebStreamStrategy", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each(["liveHead", "historyMounted"] as const)(
|
||||
"keeps one rendered tool row after late prior-turn updates in %s",
|
||||
(lane) => {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const input: StreamRenderInput = {
|
||||
agentId: "agent",
|
||||
segments: { historyVirtualized: [], historyMounted: [], liveHead: [] },
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: lane === "historyMounted",
|
||||
hasLiveHead: lane === "liveHead",
|
||||
},
|
||||
renderers: createRenderers(() => {}),
|
||||
listEmptyComponent: null,
|
||||
viewportRef: React.createRef<StreamViewportHandle>(),
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: () => {},
|
||||
onNearHistoryStart: () => true,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
let items: StreamItem[] = [];
|
||||
const updates = [
|
||||
{ callId: "background", turnId: "old", status: "running" },
|
||||
{ callId: "exec", turnId: "current", status: "running" },
|
||||
{ callId: "background", turnId: "old", status: "completed" },
|
||||
{ callId: "exec", turnId: "current", status: "completed" },
|
||||
] as const;
|
||||
for (const [index, update] of updates.entries()) {
|
||||
items = reduceStreamUpdate(
|
||||
items,
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
turnId: update.turnId,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: update.callId,
|
||||
name: "exec_command",
|
||||
status: update.status,
|
||||
error: null,
|
||||
detail: { type: "shell", command: update.callId },
|
||||
},
|
||||
},
|
||||
new Date(index),
|
||||
);
|
||||
act(() =>
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...input,
|
||||
segments: { ...input.segments, [lane]: items },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
const rows = [...container.querySelectorAll("[data-history-row-id]")];
|
||||
expect(rows.map((row) => row.getAttribute("data-history-row-id"))).toEqual([
|
||||
"agent_tool_turn:old/background",
|
||||
"agent_tool_turn:current/exec",
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("mounts virtualized history without recursive row measurement updates", () => {
|
||||
const rowRenderCount = vi.fn();
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
|
||||
@@ -110,7 +110,7 @@ function runTicks(input: { tail: StreamItem[]; historyStart: number; heads: Stre
|
||||
isTurnActive: true,
|
||||
history: model.history,
|
||||
liveHead: model.segments.liveHead,
|
||||
timingByItemId: model.turnTiming.byItemId,
|
||||
timingByAssistantId: model.turnTiming.byAssistantId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,41 +48,6 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
|
||||
const ids = (items: StreamItem[]) => items.map((item) => item.id);
|
||||
|
||||
describe("collapseTurns", () => {
|
||||
it("keeps lifecycle events and compaction recognizable beside a semantic action summary", () => {
|
||||
const create = shell("create");
|
||||
if (create.payload.source !== "agent") throw new Error("Expected agent tool");
|
||||
create.payload.data.name = "mcp__paseo__create_agent";
|
||||
create.payload.data.detail = { type: "unknown", input: { title: "Side fix" }, output: null };
|
||||
const finished = shell("finished");
|
||||
if (finished.payload.source !== "agent") throw new Error("Expected agent tool");
|
||||
finished.payload.data.name = "agent_notification";
|
||||
finished.payload.data.agentMessage = {
|
||||
event: "finished",
|
||||
sender: { id: "worker", title: "PR helper" },
|
||||
text: "Finished",
|
||||
};
|
||||
const items: StreamItem[] = [
|
||||
user("u1"),
|
||||
create,
|
||||
finished,
|
||||
{
|
||||
kind: "compaction",
|
||||
id: "compaction",
|
||||
timestamp: at,
|
||||
status: "completed",
|
||||
} satisfies StreamItem,
|
||||
shell("bash"),
|
||||
reply("done"),
|
||||
].map((item) => Object.assign(item, { turnId: "same-run" }));
|
||||
const result = collapse(items);
|
||||
expect(ids(result.tail)).toEqual(["u1", "finished", "compaction", "done"]);
|
||||
expect(result.turnsByHostId.get("u1")?.summary.tools).toMatchObject({
|
||||
commandCount: 1,
|
||||
otherToolCount: 0,
|
||||
paseoActions: { "Created agent": 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("folds a finished turn's work into its user message and keeps the reply", () => {
|
||||
const result = collapse([
|
||||
user("u1"),
|
||||
@@ -98,6 +63,7 @@ describe("collapseTurns", () => {
|
||||
hostId: "u1",
|
||||
expanded: false,
|
||||
summary: {
|
||||
messageCount: 1,
|
||||
tools: expect.objectContaining({ commandCount: 2 }),
|
||||
},
|
||||
});
|
||||
@@ -155,34 +121,6 @@ describe("collapseTurns", () => {
|
||||
expect(result.turnsByHostId.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a completed response folded when a promptless canonical turn starts", () => {
|
||||
const first = [user("u1"), shell("c1"), reply("r1")].map((item) =>
|
||||
Object.assign(item, { turnId: "first" }),
|
||||
);
|
||||
const next = { ...shell("heartbeat"), turnId: "second" };
|
||||
const result = collapse([...first, next], { isLatestTurnOpen: true });
|
||||
expect(ids(result.tail)).toEqual(["u1", "r1", "heartbeat"]);
|
||||
expect(result.turnsByHostId.has("u1")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps mid-turn messages visible when work is folded", () => {
|
||||
const tail = [user("prompt"), shell("work"), user("steer"), shell("more"), reply("done")];
|
||||
for (const item of tail) item.turnId = "same-turn";
|
||||
const result = collapse(tail);
|
||||
expect(ids(result.tail)).toEqual(["prompt", "steer", "done"]);
|
||||
expect(result.hostIdByFoldedMessageId.has("steer")).toBe(false);
|
||||
});
|
||||
|
||||
it("reveals every raw call inside an expanded turn", () => {
|
||||
const calls = [shell("one"), shell("two")];
|
||||
const result = collapse([user("prompt"), calls[0]!, reply("done")], {
|
||||
expandedHostIds: new Set(["prompt"]),
|
||||
getGroupedCalls: (id) => (id === "one" ? calls : undefined),
|
||||
});
|
||||
expect(ids(result.tail)).toEqual(["prompt", "one", "two", "done"]);
|
||||
expect([...result.nestedItemIds]).toEqual(["one", "two"]);
|
||||
});
|
||||
|
||||
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")], {
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { isTurnBoundary, isTurnTrigger } from "../turn-membership";
|
||||
import {
|
||||
getStreamItemMessageId,
|
||||
type StreamItem,
|
||||
type ThoughtItem,
|
||||
type ToolCallItem,
|
||||
} from "@/types/stream";
|
||||
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 input or first loaded row that carries the turn's summary line. */
|
||||
/** The user message the turn answers. Its row carries the turn's summary line. */
|
||||
hostId: string;
|
||||
expanded: boolean;
|
||||
summary: TurnSummary;
|
||||
@@ -33,7 +28,7 @@ export interface CollapseTurnsInput {
|
||||
isLatestTurnOpen: boolean;
|
||||
expandedHostIds: ReadonlySet<string>;
|
||||
/** The calls a tool-call group row stands for; undefined for a single call. */
|
||||
getGroupedCalls: (itemId: string) => readonly (ToolCallItem | ThoughtItem)[] | undefined;
|
||||
getGroupedCalls: (itemId: string) => readonly ToolCallItem[] | undefined;
|
||||
}
|
||||
|
||||
const EMPTY_TURNS: ReadonlyMap<string, CollapsedTurn> = new Map();
|
||||
@@ -44,21 +39,17 @@ function summarizeTurn(
|
||||
items: readonly StreamItem[],
|
||||
getGroupedCalls: CollapseTurnsInput["getGroupedCalls"],
|
||||
): TurnSummary {
|
||||
const calls: (ToolCallItem | ThoughtItem)[] = [];
|
||||
const calls: ToolCallItem[] = [];
|
||||
let messageCount = 0;
|
||||
for (const item of items) {
|
||||
if ((item.kind === "tool_call" || item.kind === "thought") && !isTurnTrigger(item))
|
||||
calls.push(...(getGroupedCalls(item.id) ?? [item]));
|
||||
if (item.kind === "assistant_message") messageCount += 1;
|
||||
else if (item.kind === "tool_call") calls.push(...(getGroupedCalls(item.id) ?? [item]));
|
||||
}
|
||||
return { tools: summarizeToolCalls(calls) };
|
||||
return { messageCount, tools: summarizeToolCalls(calls) };
|
||||
}
|
||||
|
||||
function hasWork(summary: TurnSummary): boolean {
|
||||
const { paseoActions, thoughtCount: _thoughtCount, ...counts } = summary.tools;
|
||||
return Object.keys(paseoActions).length > 0 || Object.values(counts).some((count) => count > 0);
|
||||
}
|
||||
|
||||
function staysVisibleWhenCollapsed(item: StreamItem): boolean {
|
||||
return isTurnTrigger(item) || item.kind === "compaction";
|
||||
return summary.messageCount > 0 || Object.values(summary.tools).some((count) => count > 0);
|
||||
}
|
||||
|
||||
function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number {
|
||||
@@ -72,7 +63,7 @@ function findLastAssistantIndex(tail: StreamItem[], start: number, end: number):
|
||||
* 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. Reasoning is revealed only when the turn opens.
|
||||
* before its reply. Thoughts fold with the turn but are not counted.
|
||||
*/
|
||||
export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
const { tail } = input;
|
||||
@@ -86,47 +77,28 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
const host = tail[runStart - 1];
|
||||
const isClosed = runEnd < tail.length || !input.isLatestTurnOpen;
|
||||
const replyIndex = findLastAssistantIndex(tail, runStart, runEnd);
|
||||
if (
|
||||
!host ||
|
||||
(host.kind !== "user_message" && host.kind !== "tool_call") ||
|
||||
!isClosed ||
|
||||
replyIndex <= runStart
|
||||
) {
|
||||
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) && !folded.some((item) => item.kind === "assistant_message")) {
|
||||
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 });
|
||||
const revealed = expanded
|
||||
? folded.flatMap<StreamItem>((item) =>
|
||||
item.kind === "tool_call" || item.kind === "thought"
|
||||
? [...(input.getGroupedCalls(item.id) ?? [item])]
|
||||
: [item],
|
||||
)
|
||||
: folded;
|
||||
for (const item of revealed) {
|
||||
if (staysVisibleWhenCollapsed(item)) continue;
|
||||
for (const item of folded) {
|
||||
hostIdByFoldedMessageId.set(getStreamItemMessageId(item), host.id);
|
||||
if (expanded) nestedItemIds.add(item.id);
|
||||
}
|
||||
output.push(
|
||||
...(expanded ? revealed : folded.filter((item) => staysVisibleWhenCollapsed(item))),
|
||||
);
|
||||
if (expanded) output.push(...folded);
|
||||
output.push(...tail.slice(replyIndex, runEnd));
|
||||
};
|
||||
|
||||
for (let index = 0; index < tail.length; index += 1) {
|
||||
if (
|
||||
!(index === 0 && isTurnTrigger(tail[index]!)) &&
|
||||
!isTurnBoundary(tail[index - 1] ?? null, tail[index] ?? null)
|
||||
)
|
||||
continue;
|
||||
if (tail[index]?.kind !== "user_message") continue;
|
||||
flushRun(index);
|
||||
output.push(tail[index]!);
|
||||
runStart = index + 1;
|
||||
@@ -142,10 +114,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
};
|
||||
}
|
||||
return {
|
||||
tail:
|
||||
output.length === tail.length && output.every((item, index) => item === tail[index])
|
||||
? tail
|
||||
: output,
|
||||
tail: output.length === tail.length ? tail : output,
|
||||
turnsByHostId,
|
||||
nestedItemIds,
|
||||
hostIdByFoldedMessageId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Summary } from "lucide-react-native";
|
||||
import { ExpandableBadge } from "@/components/message";
|
||||
@@ -17,7 +17,19 @@ export const TurnSummaryLine = memo(function TurnSummaryLine({
|
||||
onExpandedChange: (hostId: string, expanded: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const label = useOverviewSummaryLabel(turn.summary.tools) || t("turnSummary.details");
|
||||
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],
|
||||
|
||||
@@ -197,7 +197,7 @@ function CompletedTurnFooter({
|
||||
return (
|
||||
<View style={stylesheet.turnFooterSlot}>
|
||||
<AssistantTurnFooter
|
||||
getContent={items[startIndex]?.kind === "assistant_message" ? getContent : undefined}
|
||||
getContent={getContent}
|
||||
completedAt={timing?.completedAt}
|
||||
durationMs={timing?.durationMs}
|
||||
onFork={boundary && onForkAssistantTurn ? handleFork : undefined}
|
||||
|
||||
@@ -53,7 +53,7 @@ function layoutFor(items: StreamItem[], isTurnActive: boolean) {
|
||||
isTurnActive,
|
||||
history: model.segments.historyMounted,
|
||||
liveHead: model.segments.liveHead,
|
||||
timingByItemId: model.turnTiming.byItemId,
|
||||
timingByAssistantId: model.turnTiming.byAssistantId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -67,24 +67,6 @@ function completedFooterIds(layout: ReturnType<typeof layoutFor>["layout"]): str
|
||||
}
|
||||
|
||||
describe("canonical turn membership", () => {
|
||||
it("uses trigger fallback for missing IDs without pretending to recover batching", () => {
|
||||
const previous = assistant("reply", 1, "first");
|
||||
const trigger = runningTool("heartbeat", 2, "first");
|
||||
if (trigger.kind !== "tool_call" || trigger.payload.source !== "agent")
|
||||
throw new Error("Expected agent tool");
|
||||
trigger.payload.data.name = "heartbeat";
|
||||
expect(continuesTurn(previous, trigger)).toBe(true);
|
||||
delete trigger.turnId;
|
||||
expect(continuesTurn(previous, trigger)).toBe(false);
|
||||
trigger.payload.data.name = "agent_message";
|
||||
trigger.payload.data.agentMessage = {
|
||||
event: "finished",
|
||||
sender: { id: "worker" },
|
||||
text: "Done",
|
||||
};
|
||||
expect(continuesTurn(previous, trigger)).toBe(false);
|
||||
});
|
||||
|
||||
it("stops tagged traversal at every mismatching turn ID", () => {
|
||||
expect(continuesTurn(assistant("first", 1, "turn-1"), runningTool("next", 2, "turn-2"))).toBe(
|
||||
false,
|
||||
@@ -94,7 +76,7 @@ describe("canonical turn membership", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a separate footer for each canonical turn without visible prompts", () => {
|
||||
it("renders adjacent turns without visible prompts as one response", () => {
|
||||
const items = [
|
||||
user("prompt", 1, "turn-1"),
|
||||
assistant("first reply", 2, "turn-1"),
|
||||
@@ -106,11 +88,7 @@ describe("canonical turn membership", () => {
|
||||
|
||||
const completed = layoutFor(items, false);
|
||||
|
||||
expect(completedFooterIds(completed.layout)).toEqual([
|
||||
"first reply",
|
||||
"heartbeat-1-reply",
|
||||
"heartbeat-2-reply",
|
||||
]);
|
||||
expect(completedFooterIds(completed.layout)).toEqual(["heartbeat-2-reply"]);
|
||||
expect(completed.layout.auxiliaryTurnFooter?.timing).toEqual({
|
||||
completedAt: at(6),
|
||||
durationMs: null,
|
||||
@@ -145,7 +123,7 @@ describe("canonical turn membership", () => {
|
||||
const completedItems = [...build(turnId), assistant("done", 9, turnId)];
|
||||
const completed = layoutFor(completedItems, false);
|
||||
expect(completedFooterIds(completed.layout)).toEqual(["done"]);
|
||||
expect(completed.model.turnTiming.byItemId.get("done")).toMatchObject({
|
||||
expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({
|
||||
completedAt: at(9),
|
||||
durationMs: 8000,
|
||||
});
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import { continuesTimelineTurn, isTimelineTurnTrigger } from "@getpaseo/protocol/timeline-turn";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
|
||||
/**
|
||||
* Canonical turn IDs take precedence. Timelines without them retain the legacy
|
||||
* trigger boundary rule; missing IDs cannot reconstruct historical batching.
|
||||
* user-message boundary rule so old daemons and persisted rows need no rewrite.
|
||||
*/
|
||||
export function continuesTurn(previous: StreamItem | null, next: StreamItem | null): boolean {
|
||||
if (!previous || !next) return false;
|
||||
return continuesTimelineTurn(previous.turnId, next.turnId, isTurnTrigger(next));
|
||||
if (previous.turnId !== undefined && next.turnId !== undefined) {
|
||||
return previous.turnId === next.turnId;
|
||||
}
|
||||
return next.kind !== "user_message";
|
||||
}
|
||||
|
||||
/**
|
||||
* Every canonical turn owns its response, including system-triggered runs.
|
||||
* A visible response can span multiple canonical turns when their prompts are
|
||||
* system-injected and therefore absent from the Paseo timeline.
|
||||
*/
|
||||
export function continuesResponse(previous: StreamItem | null, next: StreamItem | null): boolean {
|
||||
if (!previous || !next) return false;
|
||||
return continuesTurn(previous, next);
|
||||
return continuesTurn(previous, next) || next.kind !== "user_message";
|
||||
}
|
||||
|
||||
export function isTurnBoundary(previous: StreamItem | null, next: StreamItem | null): boolean {
|
||||
@@ -34,12 +37,3 @@ export function startsNewTurn(item: StreamItem, previous: StreamItem | null): bo
|
||||
export function belongsToTurn(item: StreamItem, turnId: string | null): boolean {
|
||||
return turnId !== null && item.turnId === turnId;
|
||||
}
|
||||
|
||||
/** Inputs that start a turn when older history has no canonical turn IDs. */
|
||||
export function isTurnTrigger(item: StreamItem): boolean {
|
||||
return isTimelineTurnTrigger(
|
||||
item.kind === "tool_call" && item.payload.source === "agent"
|
||||
? { type: item.kind, ...item.payload.data }
|
||||
: { type: item.kind },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,6 @@ import {
|
||||
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
|
||||
import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface";
|
||||
import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links";
|
||||
import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder";
|
||||
import {
|
||||
createWorkspaceFileTabTarget,
|
||||
type WorkspaceFileLocation,
|
||||
@@ -460,7 +459,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
view: "files",
|
||||
});
|
||||
});
|
||||
const openInlineOutsideDirectory = useOpenOutsideWorkspaceFolder(resolvedServerId);
|
||||
const inlinePathOpener = useMemo(
|
||||
() =>
|
||||
createInlinePathOpener({
|
||||
@@ -468,9 +466,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
client,
|
||||
openFile: openInlineFile,
|
||||
openDirectory: openInlineDirectory,
|
||||
openOutsideDirectory: openInlineOutsideDirectory,
|
||||
}),
|
||||
[workspaceRoot, client, openInlineFile, openInlineDirectory, openInlineOutsideDirectory],
|
||||
[workspaceRoot, client, openInlineFile, openInlineDirectory],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isActive) inlinePathOpener.cancel();
|
||||
@@ -527,10 +524,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
|
||||
transform: transformTimelineItem,
|
||||
display: chatDisplay,
|
||||
autoExpandReasoning,
|
||||
expandedTurnHostIds,
|
||||
isTurnActive,
|
||||
activeTurnId: effectiveTurnPresentation.turnId,
|
||||
}),
|
||||
[
|
||||
presentStream,
|
||||
@@ -538,10 +533,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
effectiveStreamHead,
|
||||
transformTimelineItem,
|
||||
chatDisplay,
|
||||
autoExpandReasoning,
|
||||
expandedTurnHostIds,
|
||||
isTurnActive,
|
||||
effectiveTurnPresentation.turnId,
|
||||
],
|
||||
);
|
||||
const {
|
||||
@@ -583,12 +576,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
isTurnActive,
|
||||
history: baseRenderModel.history,
|
||||
liveHead: baseRenderModel.segments.liveHead,
|
||||
timingByItemId: baseRenderModel.turnTiming.byItemId,
|
||||
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
|
||||
}),
|
||||
[
|
||||
baseRenderModel.history,
|
||||
baseRenderModel.segments.liveHead,
|
||||
baseRenderModel.turnTiming.byItemId,
|
||||
baseRenderModel.turnTiming.byAssistantId,
|
||||
isTurnActive,
|
||||
streamRenderStrategy,
|
||||
],
|
||||
@@ -729,7 +722,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
agentId={agentId}
|
||||
messageId={item.messageId}
|
||||
message={item.text}
|
||||
sender={item.sender}
|
||||
images={item.images}
|
||||
attachments={item.attachments}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
@@ -876,15 +868,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
presentation.groupsByHostId.get(hostId),
|
||||
);
|
||||
const renderToolCallItem = useCallback(
|
||||
(
|
||||
layoutItem: StreamLayoutItem,
|
||||
item: Extract<StreamItem, { kind: "tool_call" | "thought" }>,
|
||||
) => {
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
|
||||
const group = getToolCallGroup(item.id);
|
||||
if (!group) {
|
||||
return item.kind === "thought"
|
||||
? renderThoughtItem(layoutItem, item)
|
||||
: renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
|
||||
return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
|
||||
}
|
||||
const expanded = expandedToolCallGroupIds.has(group.run.id);
|
||||
return (
|
||||
@@ -897,21 +884,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
{expanded
|
||||
? group.run.calls.map((call, index) => (
|
||||
<React.Fragment key={call.id}>
|
||||
{call.kind === "thought" ? (
|
||||
<ThoughtSlot
|
||||
itemId={call.id}
|
||||
text={call.text}
|
||||
status={call.status}
|
||||
isLastInSequence={index === group.run.calls.length - 1}
|
||||
defaultExpanded
|
||||
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
|
||||
/>
|
||||
) : (
|
||||
renderSingleToolCallItem(
|
||||
call,
|
||||
index === group.run.calls.length - 1,
|
||||
GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
|
||||
)
|
||||
{renderSingleToolCallItem(
|
||||
call,
|
||||
index === group.run.calls.length - 1,
|
||||
GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
|
||||
)}
|
||||
</React.Fragment>
|
||||
))
|
||||
@@ -921,8 +897,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
},
|
||||
[
|
||||
expandedToolCallGroupIds,
|
||||
renderThoughtItem,
|
||||
setInlineDetailsExpanded,
|
||||
getToolCallGroup,
|
||||
renderSingleToolCallItem,
|
||||
setToolCallGroupExpanded,
|
||||
@@ -940,20 +914,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return renderAssistantMessageItem(layoutItem, item);
|
||||
|
||||
case "thought":
|
||||
return renderToolCallItem(layoutItem, item);
|
||||
return renderThoughtItem(layoutItem, item);
|
||||
|
||||
case "tool_call":
|
||||
return (
|
||||
<>
|
||||
{renderToolCallItem(layoutItem, item)}
|
||||
{getCollapsedTurn(item.id) ? (
|
||||
<TurnSummaryLine
|
||||
turn={getCollapsedTurn(item.id)!}
|
||||
onExpandedChange={setTurnExpanded}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
return renderToolCallItem(layoutItem, item);
|
||||
|
||||
case "notification":
|
||||
return <Notification level={item.level} message={item.message} />;
|
||||
@@ -983,9 +947,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
agentId,
|
||||
renderUserMessageItem,
|
||||
renderAssistantMessageItem,
|
||||
renderThoughtItem,
|
||||
renderToolCallItem,
|
||||
getCollapsedTurn,
|
||||
setTurnExpanded,
|
||||
resolvedServerId,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -70,17 +70,6 @@ function indexEntries(items: StreamItem[]): IndexedStreamItem[] {
|
||||
}
|
||||
|
||||
describe("findMountedWindowStart", () => {
|
||||
it("mounts complete autonomous runs and keeps same-run steers together", () => {
|
||||
const items = [
|
||||
{ ...userMessage("human", 1), turnId: "first" },
|
||||
{ ...assistantMessage("reply", 2), turnId: "first" },
|
||||
{ ...toolCall("wake", 3), turnId: "autonomous" },
|
||||
{ ...userMessage("steer", 4), turnId: "autonomous" },
|
||||
{ ...assistantMessage("done", 5), turnId: "autonomous" },
|
||||
];
|
||||
expect(findMountedWindowStart({ items, minMountedCount: 1 })).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps all items mounted when the chat is below the threshold", () => {
|
||||
const items = [userMessage("u1", 1), assistantMessage("a1", 2)];
|
||||
|
||||
|
||||
@@ -71,9 +71,6 @@ it("ignores an older probe after a newer file link opens", async () => {
|
||||
openDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
});
|
||||
const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred");
|
||||
await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred");
|
||||
@@ -98,9 +95,6 @@ it("ignores a pending probe when its view is left", async () => {
|
||||
openDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
});
|
||||
const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred");
|
||||
opener.cancel();
|
||||
@@ -108,25 +102,3 @@ it("ignores a pending probe when its view is left", async () => {
|
||||
await pending;
|
||||
expect(opened).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends folders outside the workspace away from the workspace Files panel", async () => {
|
||||
const { createInlinePathOpener } = await import("./path-open");
|
||||
const workspaceFolders: string[] = [];
|
||||
const outsideFolders: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/Users/me/repo",
|
||||
client: null,
|
||||
openFile: () => {},
|
||||
openDirectory: (directory) => {
|
||||
workspaceFolders.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
outsideFolders.push(directory);
|
||||
},
|
||||
});
|
||||
for (const raw of ["~/Downloads/contracts/", "/tmp/reports/", "/Users/me/repo/docs/"]) {
|
||||
await opener.open({ raw, path: raw }, "preferred");
|
||||
}
|
||||
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
|
||||
expect(workspaceFolders).toEqual(["docs"]);
|
||||
});
|
||||
|
||||
@@ -14,23 +14,12 @@ interface PathOpenInput {
|
||||
client: { listDirectory(root: string, path: string): Promise<unknown> } | null;
|
||||
}
|
||||
|
||||
type PathOpenTarget =
|
||||
| { kind: "file" }
|
||||
| { kind: "directory"; directoryPath: string }
|
||||
| { kind: "outsideDirectory"; directoryPath: string };
|
||||
|
||||
// Workspace paths arrive relative to the root; anything still absolute or
|
||||
// home-relative lives outside it, where the Files panel cannot list it.
|
||||
function isOutsideWorkspacePath(pathValue: string): boolean {
|
||||
return isAbsolutePath(pathValue) || pathValue === "~" || pathValue.startsWith("~/");
|
||||
}
|
||||
type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string };
|
||||
|
||||
/** Resolve workspace folders from filesystem facts, including names that contain dots. */
|
||||
export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathOpenTarget> {
|
||||
if (!input.file) {
|
||||
return isOutsideWorkspacePath(input.directory)
|
||||
? { kind: "outsideDirectory", directoryPath: input.directory }
|
||||
: { kind: "directory", directoryPath: input.directory };
|
||||
return { kind: "directory", directoryPath: input.directory };
|
||||
}
|
||||
if (
|
||||
input.lineStart !== undefined ||
|
||||
@@ -53,7 +42,6 @@ export function createInlinePathOpener(input: {
|
||||
client: PathOpenInput["client"];
|
||||
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
|
||||
openDirectory: (path: string) => void;
|
||||
openOutsideDirectory: (path: string) => void;
|
||||
}) {
|
||||
let generation = 0;
|
||||
return {
|
||||
@@ -70,8 +58,6 @@ export function createInlinePathOpener(input: {
|
||||
if (ownGeneration !== generation) return;
|
||||
if (destination.kind === "directory") {
|
||||
input.openDirectory(destination.directoryPath);
|
||||
} else if (destination.kind === "outsideDirectory") {
|
||||
input.openOutsideDirectory(destination.directoryPath);
|
||||
} else if (normalized.file) {
|
||||
const location = normalizeWorkspaceFileLocation({
|
||||
path: normalized.file,
|
||||
|
||||
@@ -3,10 +3,11 @@ import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
/**
|
||||
* Align the rail with the expanded caret's left edge. Keep the content offset fixed so
|
||||
* expanded tool cards stay in place, with a gap between their border and the rail.
|
||||
* 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 = 1;
|
||||
const RAIL_LEFT = 7;
|
||||
const RAIL_WIDTH = 1;
|
||||
export const INDENT_RAIL_CONTENT_OFFSET = 21;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { MessagePreview, MessageSender } from "@/components/user-message";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content";
|
||||
import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu";
|
||||
@@ -90,7 +89,6 @@ export type { InlinePathTarget } from "@/assistant-file-links";
|
||||
export type { AssistantForkTarget };
|
||||
|
||||
interface UserMessageProps {
|
||||
sender?: AgentMessage["sender"];
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
messageId?: string;
|
||||
@@ -389,7 +387,6 @@ function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessage
|
||||
const MESSAGE_TEXT_DATASET = { messageText: "true" };
|
||||
|
||||
export const UserMessage = memo(function UserMessage({
|
||||
sender,
|
||||
serverId,
|
||||
agentId,
|
||||
messageId,
|
||||
@@ -476,45 +473,42 @@ export const UserMessage = memo(function UserMessage({
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
{sender ? <MessageSender sender={sender} serverId={serverId} /> : null}
|
||||
<View style={userMessageStylesheet.bubble}>
|
||||
<MessagePreview>
|
||||
{hasImages ? (
|
||||
<View style={imagePreviewContainerStyle}>
|
||||
{images.map((image) => (
|
||||
<UserMessageImagePill
|
||||
key={image.id}
|
||||
image={image}
|
||||
onOpen={setLightboxMetadata}
|
||||
accessibilityLabel={t("composer.attachments.openImage")}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{hasAttachments ? (
|
||||
<View style={attachmentPreviewContainerStyle}>
|
||||
{attachments.map((attachment, index) => {
|
||||
const content = getAgentAttachmentPillContent(attachment, t);
|
||||
return (
|
||||
<AttachmentFrame
|
||||
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
|
||||
>
|
||||
<AttachmentLabel
|
||||
icon={content.icon}
|
||||
title={content.title}
|
||||
subtitle={content.subtitle}
|
||||
/>
|
||||
</AttachmentFrame>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
{hasText ? (
|
||||
<Text selectable style={userMessageStylesheet.text} dataSet={MESSAGE_TEXT_DATASET}>
|
||||
{message}
|
||||
</Text>
|
||||
) : null}
|
||||
</MessagePreview>
|
||||
{hasImages ? (
|
||||
<View style={imagePreviewContainerStyle}>
|
||||
{images.map((image) => (
|
||||
<UserMessageImagePill
|
||||
key={image.id}
|
||||
image={image}
|
||||
onOpen={setLightboxMetadata}
|
||||
accessibilityLabel={t("composer.attachments.openImage")}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{hasAttachments ? (
|
||||
<View style={attachmentPreviewContainerStyle}>
|
||||
{attachments.map((attachment, index) => {
|
||||
const content = getAgentAttachmentPillContent(attachment, t);
|
||||
return (
|
||||
<AttachmentFrame
|
||||
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
|
||||
>
|
||||
<AttachmentLabel
|
||||
icon={content.icon}
|
||||
title={content.title}
|
||||
subtitle={content.subtitle}
|
||||
/>
|
||||
</AttachmentFrame>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
{hasText ? (
|
||||
<Text selectable style={userMessageStylesheet.text} dataSet={MESSAGE_TEXT_DATASET}>
|
||||
{message}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{hasText ? (
|
||||
<View
|
||||
@@ -547,7 +541,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
});
|
||||
|
||||
interface AssistantTurnFooterProps {
|
||||
getContent?: () => string;
|
||||
getContent: () => string;
|
||||
completedAt?: Date;
|
||||
durationMs?: number | null;
|
||||
onFork?: (target: AssistantForkTarget) => Promise<void> | void;
|
||||
@@ -647,12 +641,10 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
|
||||
return (
|
||||
<View style={assistantTurnFooterStylesheet.container}>
|
||||
{getContent ? (
|
||||
<TurnCopyButton
|
||||
getContent={getContent}
|
||||
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
||||
/>
|
||||
) : null}
|
||||
<TurnCopyButton
|
||||
getContent={getContent}
|
||||
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
||||
/>
|
||||
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
|
||||
{primaryLabel ? (
|
||||
<Pressable
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
@@ -43,7 +42,8 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
|
||||
: undefined;
|
||||
if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
await getHostRuntimeStore().fetchAgentTimeline(input.serverId, input.agentId, {
|
||||
...planTimelineTailFetch(),
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,10 +4,9 @@ import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "reac
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
type EdgeFadeBackdrop = "surface0" | "surfaceWorkspace" | "surface3";
|
||||
type EdgeFadeBackdrop = "surface0" | "surfaceWorkspace";
|
||||
|
||||
const backdropColorMappings: Record<EdgeFadeBackdrop, (theme: Theme) => { color: string }> = {
|
||||
surface3: (theme) => ({ color: theme.colors.surface3 }),
|
||||
surface0: (theme) => ({ color: theme.colors.surface0 }),
|
||||
surfaceWorkspace: (theme) => ({ color: theme.colors.surfaceWorkspace }),
|
||||
};
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useState, useCallback, useMemo, type ReactNode } from "react";
|
||||
import { View, type LayoutChangeEvent } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EdgeFade } from "@/components/ui/edge-fade";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
|
||||
const PREVIEW_HEIGHT = 240;
|
||||
|
||||
export function MessagePreview({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [height, setHeight] = useState(0);
|
||||
const overflowing = height > PREVIEW_HEIGHT;
|
||||
const measure = useCallback(
|
||||
(event: LayoutChangeEvent) => setHeight(event.nativeEvent.layout.height),
|
||||
[],
|
||||
);
|
||||
const toggle = useCallback(() => setExpanded((value) => !value), []);
|
||||
const accessibilityState = useMemo(() => ({ expanded }), [expanded]);
|
||||
return (
|
||||
<>
|
||||
<View style={!expanded && styles.preview}>
|
||||
<View style={styles.body} onLayout={measure}>
|
||||
{children}
|
||||
</View>
|
||||
{overflowing && !expanded ? (
|
||||
<EdgeFade edge="bottom" solidFraction={0} backdrop="surface3" style={styles.fade} />
|
||||
) : null}
|
||||
</View>
|
||||
{overflowing ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
style={styles.toggle}
|
||||
accessibilityState={accessibilityState}
|
||||
onPress={toggle}
|
||||
>
|
||||
{t(expanded ? "turnSummary.showLess" : "turnSummary.showMore")}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageSender({
|
||||
sender,
|
||||
serverId,
|
||||
}: {
|
||||
sender: AgentMessage["sender"];
|
||||
serverId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const host = sender.serverId ?? serverId;
|
||||
const agent = useSessionStore((state) => {
|
||||
const session = host ? state.sessions[host] : undefined;
|
||||
return session?.agents.get(sender.id) ?? session?.agentDetails.get(sender.id);
|
||||
});
|
||||
const ProviderIcon = useProviderIcon(agent?.provider ?? "", host);
|
||||
const renderProviderIcon = useCallback(
|
||||
(color: string) => <ProviderIcon size={16} color={color} />,
|
||||
[ProviderIcon],
|
||||
);
|
||||
const openSender = useCallback(() => {
|
||||
if (host) navigateToAgent({ serverId: host, agentId: sender.id });
|
||||
}, [host, sender.id]);
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={renderProviderIcon}
|
||||
disabled={!host}
|
||||
onPress={openSender}
|
||||
>
|
||||
{t("turnSummary.sentBy", { name: agent?.title ?? sender.title ?? sender.id })}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
preview: { maxHeight: PREVIEW_HEIGHT, overflow: "hidden" },
|
||||
body: { flexShrink: 0 },
|
||||
toggle: { alignSelf: "flex-start", paddingHorizontal: 0, minHeight: 0, borderWidth: 0 },
|
||||
fade: { position: "absolute", bottom: 0, left: 0, right: 0, height: theme.spacing[12] },
|
||||
}));
|
||||
@@ -2495,7 +2495,6 @@ function ComposerContentImpl({
|
||||
voiceAgentId={agentId}
|
||||
isAgentRunning={isAgentRunning}
|
||||
defaultSendBehavior={activeSendBehavior}
|
||||
sendShortcut={appSettings.sendShortcut}
|
||||
onQueue={handleQueue}
|
||||
onSubmitLoadingPress={submitLoadingPressHandler}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { SendShortcut } from "@/hooks/use-settings";
|
||||
import {
|
||||
useComposerEditingSession,
|
||||
useComposerFullscreen,
|
||||
@@ -89,7 +88,6 @@ import {
|
||||
resolveAlternateSendAction,
|
||||
resolveComposerSurfacePresentation,
|
||||
resolveComposerSurfaceText,
|
||||
resolveMessageInputEnterAction,
|
||||
resolveRetainedSelection,
|
||||
runAlternateSendAction,
|
||||
runDefaultSendAction,
|
||||
@@ -98,11 +96,6 @@ import {
|
||||
} from "./state";
|
||||
|
||||
const DEFAULT_SEND_KEYS: ShortcutKey[][] = [["Enter"]];
|
||||
const MOD_ENTER_SEND_KEYS: ShortcutKey[][] = [["mod", "Enter"]];
|
||||
|
||||
function resolveSendKeys(sendShortcut: SendShortcut): ShortcutKey[][] {
|
||||
return sendShortcut === "mod-enter" ? MOD_ENTER_SEND_KEYS : DEFAULT_SEND_KEYS;
|
||||
}
|
||||
const COMPOSER_INPUT_DATASET = { composerInput: "" } as const;
|
||||
|
||||
export interface AttachmentMenuItem {
|
||||
@@ -174,7 +167,6 @@ export interface MessageInputProps {
|
||||
* running. "interrupt" and "steer" send immediately, "queue" queues. Required so the default
|
||||
* lives only in DEFAULT_CLIENT_SETTINGS. */
|
||||
defaultSendBehavior: "interrupt" | "steer" | "queue";
|
||||
sendShortcut: SendShortcut;
|
||||
/** Callback for queue button when agent is running */
|
||||
onQueue?: (payload: MessagePayload) => void;
|
||||
/** Optional handler used when submit button is in loading state. */
|
||||
@@ -410,9 +402,6 @@ interface DesktopKeyPressContext {
|
||||
onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined;
|
||||
input: ComposerKeyPressEvent["input"];
|
||||
submitOnEnter: boolean;
|
||||
sendShortcut: SendShortcut;
|
||||
isAgentRunning: boolean;
|
||||
onQueue: ((payload: MessagePayload) => void) | undefined;
|
||||
isSubmitDisabled: boolean;
|
||||
isSubmitLoading: boolean;
|
||||
disabled: boolean;
|
||||
@@ -439,21 +428,12 @@ function handleDesktopKeyPressImpl(
|
||||
}
|
||||
|
||||
if (event.nativeEvent.key !== "Enter") return;
|
||||
|
||||
const action = resolveMessageInputEnterAction({
|
||||
submitOnEnter: ctx.submitOnEnter,
|
||||
sendShortcut: ctx.sendShortcut,
|
||||
shiftKey: shiftKey ?? false,
|
||||
metaKey: metaKey ?? false,
|
||||
ctrlKey: ctrlKey ?? false,
|
||||
isAgentRunning: ctx.isAgentRunning,
|
||||
onQueue: ctx.onQueue,
|
||||
});
|
||||
if (action === null) return;
|
||||
if (!ctx.submitOnEnter) return;
|
||||
if (shiftKey) return;
|
||||
|
||||
if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return;
|
||||
event.preventDefault();
|
||||
if (action === "alternate-send") {
|
||||
if (metaKey || ctrlKey) {
|
||||
ctx.handleAlternateSendAction();
|
||||
return;
|
||||
}
|
||||
@@ -1132,7 +1112,6 @@ interface ResolvedMessageInputProps {
|
||||
voiceAgentId: string | undefined;
|
||||
isAgentRunning: boolean;
|
||||
defaultSendBehavior: "interrupt" | "steer" | "queue";
|
||||
sendShortcut: SendShortcut;
|
||||
onQueue: ((payload: MessagePayload) => void) | undefined;
|
||||
onSubmitLoadingPress: (() => void) | undefined;
|
||||
onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined;
|
||||
@@ -1180,7 +1159,6 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
|
||||
voiceAgentId: props.voiceAgentId,
|
||||
isAgentRunning: props.isAgentRunning ?? false,
|
||||
defaultSendBehavior: props.defaultSendBehavior,
|
||||
sendShortcut: props.sendShortcut,
|
||||
onQueue: props.onQueue,
|
||||
onSubmitLoadingPress: props.onSubmitLoadingPress,
|
||||
onKeyPressCallback: props.onKeyPress,
|
||||
@@ -1258,7 +1236,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
voiceAgentId,
|
||||
isAgentRunning,
|
||||
defaultSendBehavior,
|
||||
sendShortcut,
|
||||
onQueue,
|
||||
onSubmitLoadingPress,
|
||||
onKeyPressCallback,
|
||||
@@ -1778,9 +1755,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
selectionRef.current,
|
||||
),
|
||||
submitOnEnter: shouldSubmitOnEnter,
|
||||
sendShortcut,
|
||||
isAgentRunning,
|
||||
onQueue,
|
||||
isSubmitDisabled,
|
||||
isSubmitLoading,
|
||||
disabled,
|
||||
@@ -1792,9 +1766,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
shouldHandleWebKeyPress,
|
||||
onKeyPressCallback,
|
||||
shouldSubmitOnEnter,
|
||||
sendShortcut,
|
||||
isAgentRunning,
|
||||
onQueue,
|
||||
isSubmitDisabled,
|
||||
isSubmitLoading,
|
||||
disabled,
|
||||
@@ -2009,7 +1980,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
submitLabel={submitLabel}
|
||||
submitButtonTestID={submitButtonTestID}
|
||||
buttonIconSize={buttonIconSize}
|
||||
sendKeys={resolveSendKeys(sendShortcut)}
|
||||
sendKeys={DEFAULT_SEND_KEYS}
|
||||
sendTooltipLabel={sendTooltipLabel}
|
||||
/>
|
||||
),
|
||||
@@ -2030,7 +2001,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
submitLabel,
|
||||
submitButtonTestID,
|
||||
buttonIconSize,
|
||||
sendShortcut,
|
||||
sendTooltipLabel,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2,12 +2,10 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
applyDictationTranscript,
|
||||
computeCanStartDictation,
|
||||
type MessageInputEnterContext,
|
||||
resolveActiveSendBehavior,
|
||||
resolveComposerSurfacePresentation,
|
||||
resolveComposerSurfaceText,
|
||||
resolveGlyphRailOffset,
|
||||
resolveMessageInputEnterAction,
|
||||
resolveRetainedSelection,
|
||||
resolveAlternateSendAction,
|
||||
runAlternateSendAction,
|
||||
@@ -322,212 +320,6 @@ describe("composer send behavior", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMessageInputEnterAction", () => {
|
||||
function enterContext(
|
||||
overrides: Partial<MessageInputEnterContext> = {},
|
||||
): MessageInputEnterContext {
|
||||
return {
|
||||
submitOnEnter: true,
|
||||
sendShortcut: "enter",
|
||||
shiftKey: false,
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
isAgentRunning: false,
|
||||
onQueue: () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("returns null for plain, Shift, and modifier Enter when submit on enter is disabled", () => {
|
||||
expect(resolveMessageInputEnterAction(enterContext({ submitOnEnter: false }))).toBeNull();
|
||||
expect(
|
||||
resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, shiftKey: true })),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, metaKey: true })),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, ctrlKey: true })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("still returns null when modifier Enter sends and submit on enter is disabled", () => {
|
||||
expect(
|
||||
resolveMessageInputEnterAction(
|
||||
enterContext({ sendShortcut: "mod-enter", submitOnEnter: false, metaKey: true }),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveMessageInputEnterAction(
|
||||
enterContext({ sendShortcut: "mod-enter", submitOnEnter: false }),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("sends on plain Enter when Enter sends", () => {
|
||||
expect(resolveMessageInputEnterAction(enterContext())).toBe("default-send");
|
||||
});
|
||||
|
||||
it("returns null for Shift+Enter when Enter sends", () => {
|
||||
expect(resolveMessageInputEnterAction(enterContext({ shiftKey: true }))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for Shift+Cmd+Enter and Shift+Ctrl+Enter so Shift keeps its newline", () => {
|
||||
expect(
|
||||
resolveMessageInputEnterAction(
|
||||
enterContext({ shiftKey: true, metaKey: true, isAgentRunning: true }),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveMessageInputEnterAction(
|
||||
enterContext({ shiftKey: true, ctrlKey: true, isAgentRunning: true }),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("sends on Cmd+Enter and Ctrl+Enter while idle even with a queue handler", () => {
|
||||
expect(resolveMessageInputEnterAction(enterContext({ metaKey: true }))).toBe("default-send");
|
||||
expect(resolveMessageInputEnterAction(enterContext({ ctrlKey: true }))).toBe("default-send");
|
||||
});
|
||||
|
||||
it("queues on Cmd+Enter and Ctrl+Enter while the agent is running with a queue handler", () => {
|
||||
expect(
|
||||
resolveMessageInputEnterAction(enterContext({ metaKey: true, isAgentRunning: true })),
|
||||
).toBe("alternate-send");
|
||||
expect(
|
||||
resolveMessageInputEnterAction(enterContext({ ctrlKey: true, isAgentRunning: true })),
|
||||
).toBe("alternate-send");
|
||||
});
|
||||
|
||||
it("sends on Cmd+Enter and Ctrl+Enter while running when no queue handler exists", () => {
|
||||
expect(
|
||||
resolveMessageInputEnterAction(
|
||||
enterContext({ metaKey: true, isAgentRunning: true, onQueue: undefined }),
|
||||
),
|
||||
).toBe("default-send");
|
||||
expect(
|
||||
resolveMessageInputEnterAction(
|
||||
enterContext({ ctrlKey: true, isAgentRunning: true, onQueue: undefined }),
|
||||
),
|
||||
).toBe("default-send");
|
||||
});
|
||||
|
||||
it("returns null for plain and Shift+Enter when modifier Enter sends", () => {
|
||||
expect(resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter" }))).toBeNull();
|
||||
expect(
|
||||
resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", shiftKey: true })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for Shift+Cmd+Enter and Shift+Ctrl+Enter when modifier Enter sends", () => {
|
||||
expect(
|
||||
resolveMessageInputEnterAction(
|
||||
enterContext({ sendShortcut: "mod-enter", shiftKey: true, metaKey: true }),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveMessageInputEnterAction(
|
||||
enterContext({ sendShortcut: "mod-enter", shiftKey: true, ctrlKey: true }),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("sends on Cmd+Enter and Ctrl+Enter while idle when modifier Enter sends", () => {
|
||||
expect(
|
||||
resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", metaKey: true })),
|
||||
).toBe("default-send");
|
||||
expect(
|
||||
resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", ctrlKey: true })),
|
||||
).toBe("default-send");
|
||||
});
|
||||
|
||||
it("uses the default action on modifier Enter while running when modifier Enter sends", () => {
|
||||
expect(
|
||||
resolveMessageInputEnterAction(
|
||||
enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: true }),
|
||||
),
|
||||
).toBe("default-send");
|
||||
expect(
|
||||
resolveMessageInputEnterAction(
|
||||
enterContext({ sendShortcut: "mod-enter", ctrlKey: true, isAgentRunning: true }),
|
||||
),
|
||||
).toBe("default-send");
|
||||
});
|
||||
|
||||
describe("composed with the send actions", () => {
|
||||
function actions() {
|
||||
const calls: string[] = [];
|
||||
return {
|
||||
calls,
|
||||
handleSendMessage: () => calls.push("send"),
|
||||
handleQueueMessage: () => calls.push("queue"),
|
||||
onQueue: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCommandEnterToSendModifierEnter() {
|
||||
return resolveMessageInputEnterAction(
|
||||
enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: true }),
|
||||
);
|
||||
}
|
||||
|
||||
it("queues through the default send action when queue behavior is selected and the agent is running", () => {
|
||||
const sendAction = actions();
|
||||
|
||||
expect(resolveCommandEnterToSendModifierEnter()).toBe("default-send");
|
||||
|
||||
runDefaultSendAction({
|
||||
defaultSendBehavior: "queue",
|
||||
isAgentRunning: true,
|
||||
onQueue: sendAction.onQueue,
|
||||
handleSendMessage: sendAction.handleSendMessage,
|
||||
handleQueueMessage: sendAction.handleQueueMessage,
|
||||
});
|
||||
|
||||
expect(sendAction.calls).toEqual(["queue"]);
|
||||
});
|
||||
|
||||
it("sends through the default send action when queue behavior is selected and the agent is idle", () => {
|
||||
const sendAction = actions();
|
||||
|
||||
expect(
|
||||
resolveMessageInputEnterAction(
|
||||
enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: false }),
|
||||
),
|
||||
).toBe("default-send");
|
||||
|
||||
runDefaultSendAction({
|
||||
defaultSendBehavior: "queue",
|
||||
isAgentRunning: false,
|
||||
onQueue: sendAction.onQueue,
|
||||
handleSendMessage: sendAction.handleSendMessage,
|
||||
handleQueueMessage: sendAction.handleQueueMessage,
|
||||
});
|
||||
|
||||
expect(sendAction.calls).toEqual(["send"]);
|
||||
});
|
||||
|
||||
it.each([{ defaultSendBehavior: "steer" }, { defaultSendBehavior: "interrupt" }] as const)(
|
||||
"sends through the default send action when $defaultSendBehavior behavior is selected and the agent is running",
|
||||
({ defaultSendBehavior }) => {
|
||||
const sendAction = actions();
|
||||
|
||||
expect(resolveCommandEnterToSendModifierEnter()).toBe("default-send");
|
||||
|
||||
runDefaultSendAction({
|
||||
defaultSendBehavior,
|
||||
isAgentRunning: true,
|
||||
onQueue: sendAction.onQueue,
|
||||
handleSendMessage: sendAction.handleSendMessage,
|
||||
handleQueueMessage: sendAction.handleQueueMessage,
|
||||
});
|
||||
|
||||
expect(sendAction.calls).toEqual(["send"]);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopRealtimeVoice", () => {
|
||||
it("keeps voice mode active when the running agent refuses cancellation", async () => {
|
||||
const cancellationError = new Error("active run cancellation was not acknowledged");
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { SendShortcut } from "@/hooks/use-settings";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
|
||||
import type { MessagePayload, TextReplacement } from "@/composer/types";
|
||||
@@ -152,29 +151,6 @@ export function runAlternateSendAction(ctx: SendActionContext): void {
|
||||
runDefaultSendAction(ctx);
|
||||
}
|
||||
|
||||
export type MessageInputEnterAction = "alternate-send" | "default-send";
|
||||
|
||||
export interface MessageInputEnterContext {
|
||||
submitOnEnter: boolean;
|
||||
sendShortcut: SendShortcut;
|
||||
shiftKey: boolean;
|
||||
metaKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
isAgentRunning: boolean;
|
||||
onQueue: ((payload: MessagePayload) => void) | undefined;
|
||||
}
|
||||
|
||||
export function resolveMessageInputEnterAction(
|
||||
ctx: MessageInputEnterContext,
|
||||
): MessageInputEnterAction | null {
|
||||
if (!ctx.submitOnEnter) return null;
|
||||
if (ctx.shiftKey) return null;
|
||||
const hasModifier = ctx.metaKey || ctx.ctrlKey;
|
||||
if (ctx.sendShortcut === "mod-enter") return hasModifier ? "default-send" : null;
|
||||
if (hasModifier && ctx.isAgentRunning && ctx.onQueue) return "alternate-send";
|
||||
return "default-send";
|
||||
}
|
||||
|
||||
export function runMessageInputKeyboardAction(
|
||||
action: MessageInputKeyboardActionKind,
|
||||
actions: MessageInputKeyboardActions,
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* The diff shapes the layout benchmarks measure, shared by the node bench
|
||||
* (`model.bench.ts`) and the browser benchmark (`e2e/browser/diff-performance.spec.ts`)
|
||||
* so both report against the same text.
|
||||
*/
|
||||
export interface BenchmarkCorpusFile {
|
||||
path: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
function repeatToLength(unit: string, length: number): string {
|
||||
return unit.repeat(Math.ceil(length / unit.length)).slice(0, length);
|
||||
}
|
||||
|
||||
/** A real source file: short lines, a few with ligature punctuation. */
|
||||
export function realCodeCorpusFile(): BenchmarkCorpusFile {
|
||||
const source = readFileSync(path.resolve(__dirname, "../diff-pane.tsx"), "utf8");
|
||||
return { path: "packages/app/src/git/diff-pane.tsx", lines: source.split("\n") };
|
||||
}
|
||||
|
||||
/** The CHANGELOG shape that once froze the renderer: 42 lines, longest ~600 characters. */
|
||||
export function changelogCorpusFile(): BenchmarkCorpusFile {
|
||||
const sentence =
|
||||
"- Fixed an issue where the diff viewer re-measured every line -> on toggle, which made wrapping painfully slow. ";
|
||||
return {
|
||||
path: "CHANGELOG.md",
|
||||
lines: Array.from({ length: 42 }, (_, index) =>
|
||||
repeatToLength(sentence, 200 + ((index * 97) % 398)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Generated TypeScript: ligature punctuation on every line, every tenth line long. */
|
||||
export function generatedCodeCorpusFile(lineCount: number): BenchmarkCorpusFile {
|
||||
return {
|
||||
path: "src/generated.ts",
|
||||
lines: Array.from({ length: lineCount }, (_, index) => {
|
||||
const base = `const handler${index} = async (event) => items.filter((item) => item.id !== ${index} && item.kind === "selected");`;
|
||||
return index % 10 === 9 ? repeatToLength(`${base} // `, 320) : base;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Serialized data: no ligatures, every line wraps several times. */
|
||||
export function jsonCorpusFile(lineCount: number): BenchmarkCorpusFile {
|
||||
return {
|
||||
path: "fixtures/data.json",
|
||||
lines: Array.from({ length: lineCount }, (_, index) =>
|
||||
repeatToLength(`{"id": ${index}, "label": "row ${index}", "tags": ["alpha", "beta"]}, `, 300),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** A pull-request-sized working diff: one of each shape above. */
|
||||
export function pullRequestCorpus(): BenchmarkCorpusFile[] {
|
||||
return [
|
||||
realCodeCorpusFile(),
|
||||
changelogCorpusFile(),
|
||||
generatedCodeCorpusFile(1200),
|
||||
jsonCorpusFile(300),
|
||||
];
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { bench, describe } from "vitest";
|
||||
import {
|
||||
changelogCorpusFile,
|
||||
generatedCodeCorpusFile,
|
||||
jsonCorpusFile,
|
||||
pullRequestCorpus,
|
||||
realCodeCorpusFile,
|
||||
type BenchmarkCorpusFile,
|
||||
} from "./benchmark-corpus";
|
||||
import { buildDiffDocumentModel } from "./model";
|
||||
import {
|
||||
createAdditiveAdvances,
|
||||
createChunkedAdvanceMeasurer,
|
||||
createChunkedWidthMeasurer,
|
||||
createMeasuredAdvances,
|
||||
requiresShaping,
|
||||
} from "./text-measurement";
|
||||
import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types";
|
||||
|
||||
// The pure layout work the diff surface does when a diff opens or line wrap is
|
||||
// toggled, without a browser or Skia. Counts are the backend-independent cost
|
||||
// owners (how many strings a shaper has to see), timings are the JS overhead
|
||||
// around them:
|
||||
// npx vitest bench --project unit src/git/diff-document/model.bench.ts
|
||||
|
||||
const DESKTOP_VIEWPORT = { width: 1400, height: 900 };
|
||||
const PHONE_VIEWPORT = { width: 390, height: 800 };
|
||||
const CELL_WIDTH = 7.8;
|
||||
|
||||
interface MeasurementStats {
|
||||
/** Whole strings handed to the backend. */
|
||||
calls: number;
|
||||
/** Characters inside those strings; canvas and Skia cost scales with this. */
|
||||
characters: number;
|
||||
}
|
||||
|
||||
interface Backend {
|
||||
measurer: TextMeasurer;
|
||||
stats: MeasurementStats;
|
||||
}
|
||||
|
||||
function monospaceWidth(text: string): number {
|
||||
let width = 0;
|
||||
for (const character of text) width += character.charCodeAt(0) > 0x2e7f ? 2 : 1;
|
||||
return width * CELL_WIDTH;
|
||||
}
|
||||
|
||||
function countingMeasure(stats: MeasurementStats): (text: string) => number {
|
||||
return (text) => {
|
||||
stats.calls += 1;
|
||||
stats.characters += text.length;
|
||||
return monospaceWidth(text);
|
||||
};
|
||||
}
|
||||
|
||||
/** Previous web backend, retained to isolate the cost of width-only probes. */
|
||||
function webBackendWithoutWidthProbe(): Backend {
|
||||
const stats: MeasurementStats = { calls: 0, characters: 0 };
|
||||
const measure = countingMeasure(stats);
|
||||
return { stats, measurer: { measure, measureAdvances: createMeasuredAdvances(measure) } };
|
||||
}
|
||||
|
||||
/**
|
||||
* `createWebTextMeasurer` in surface.web.tsx, with canvas replaced by a counter.
|
||||
* Width-only probes shape one string per chunk, like the native measurer; the
|
||||
* widths are the same sums, in the same order, as the last cumulative advance.
|
||||
*/
|
||||
function webBackend(): Backend {
|
||||
const stats: MeasurementStats = { calls: 0, characters: 0 };
|
||||
const measure = countingMeasure(stats);
|
||||
const additive = createAdditiveAdvances(measure);
|
||||
return {
|
||||
stats,
|
||||
measurer: {
|
||||
measure,
|
||||
measureAdvances: createMeasuredAdvances(measure),
|
||||
measureWidth: createChunkedWidthMeasurer((graphemes) => {
|
||||
const text = graphemes.join("");
|
||||
return requiresShaping(text) ? measure(text) : (additive(graphemes).at(-1) ?? 0);
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** `createNativeTextMeasurer` in text.native.ts, with Skia replaced by a counter. */
|
||||
function nativeBackend(): Backend {
|
||||
const stats: MeasurementStats = { calls: 0, characters: 0 };
|
||||
const widths = new Map<string, number>();
|
||||
const additive = (graphemes: readonly string[]) => {
|
||||
let advance = 0;
|
||||
return graphemes.map((grapheme) => {
|
||||
let width = widths.get(grapheme);
|
||||
if (width === undefined) {
|
||||
stats.calls += 1;
|
||||
stats.characters += grapheme.length;
|
||||
width = monospaceWidth(grapheme);
|
||||
widths.set(grapheme, width);
|
||||
}
|
||||
return (advance += width);
|
||||
});
|
||||
};
|
||||
const shape = countingMeasure(stats);
|
||||
return {
|
||||
stats,
|
||||
measurer: {
|
||||
measure(text) {
|
||||
return requiresShaping(text) ? shape(text) : (additive([...text]).at(-1) ?? 0);
|
||||
},
|
||||
measureWidth: createChunkedWidthMeasurer((graphemes) => {
|
||||
const text = graphemes.join("");
|
||||
return requiresShaping(text) ? shape(text) : (additive(graphemes).at(-1) ?? 0);
|
||||
}),
|
||||
measureAdvances: createChunkedAdvanceMeasurer({
|
||||
requiresShaping,
|
||||
measureAdditive: additive,
|
||||
measureShaped(graphemes) {
|
||||
shape(graphemes.join(""));
|
||||
let advance = 0;
|
||||
return graphemes.map((grapheme) => (advance += monospaceWidth(grapheme)));
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function addedFile(file: BenchmarkCorpusFile): ParsedDiffFile {
|
||||
return {
|
||||
path: file.path,
|
||||
isNew: true,
|
||||
isDeleted: false,
|
||||
additions: file.lines.length,
|
||||
deletions: 0,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 0,
|
||||
oldCount: 0,
|
||||
newStart: 1,
|
||||
newCount: file.lines.length,
|
||||
lines: file.lines.map((content) => ({
|
||||
type: "add" as const,
|
||||
content,
|
||||
tokens: [{ text: content, style: null }],
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
interface Scenario {
|
||||
name: string;
|
||||
files: ParsedDiffFile[];
|
||||
}
|
||||
|
||||
const SCENARIOS: Scenario[] = [
|
||||
{ name: "real code (diff-pane.tsx)", files: [addedFile(realCodeCorpusFile())] },
|
||||
{ name: "changelog (42 long prose lines)", files: [addedFile(changelogCorpusFile())] },
|
||||
{ name: "generated code (1200 lines)", files: [addedFile(generatedCodeCorpusFile(1200))] },
|
||||
{ name: "json (300 lines x 300 chars)", files: [addedFile(jsonCorpusFile(300))] },
|
||||
{ name: "pull request (all of the above)", files: pullRequestCorpus().map(addedFile) },
|
||||
];
|
||||
|
||||
function input(
|
||||
scenario: Scenario,
|
||||
options: {
|
||||
wrapLines: boolean;
|
||||
viewport: { width: number; height: number };
|
||||
measureText: TextMeasurer;
|
||||
},
|
||||
): BuildDiffDocumentModelInput {
|
||||
return {
|
||||
files: scenario.files,
|
||||
collapsedFilePaths: new Set(),
|
||||
layout: "unified",
|
||||
wrapLines: options.wrapLines,
|
||||
viewportWidth: options.viewport.width,
|
||||
typography: { family: "monospace", size: 13, lineHeight: 20 },
|
||||
measureText: options.measureText,
|
||||
palette: {
|
||||
surface: "#000",
|
||||
headerSurface: "#111",
|
||||
border: "#222",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
addition: "green",
|
||||
deletion: "red",
|
||||
additionBackground: "#010",
|
||||
deletionBackground: "#100",
|
||||
emptyBackground: "#111",
|
||||
selection: "blue",
|
||||
headerActiveSurface: "#222",
|
||||
headerBorder: "#333",
|
||||
statusSuccess: "green",
|
||||
statusDanger: "red",
|
||||
statusWarning: "orange",
|
||||
syntax: {},
|
||||
},
|
||||
labels: { binary: "Binary", tooLarge: "Too large" },
|
||||
// What the surfaces pass at scrollTop 0: five viewports tall.
|
||||
materializationWindow: { top: 0, height: options.viewport.height * 5 },
|
||||
};
|
||||
}
|
||||
|
||||
interface Variant {
|
||||
name: string;
|
||||
wrapLines: boolean;
|
||||
viewport: { width: number; height: number };
|
||||
backend: () => Backend;
|
||||
}
|
||||
|
||||
const VARIANTS: Variant[] = [
|
||||
{
|
||||
name: "unwrapped desktop | web",
|
||||
wrapLines: false,
|
||||
viewport: DESKTOP_VIEWPORT,
|
||||
backend: webBackend,
|
||||
},
|
||||
{
|
||||
name: "wrapped desktop | web",
|
||||
wrapLines: true,
|
||||
viewport: DESKTOP_VIEWPORT,
|
||||
backend: webBackend,
|
||||
},
|
||||
{
|
||||
name: "wrapped desktop | web without width probe",
|
||||
wrapLines: true,
|
||||
viewport: DESKTOP_VIEWPORT,
|
||||
backend: webBackendWithoutWidthProbe,
|
||||
},
|
||||
{
|
||||
name: "wrapped phone | web",
|
||||
wrapLines: true,
|
||||
viewport: PHONE_VIEWPORT,
|
||||
backend: webBackend,
|
||||
},
|
||||
{
|
||||
name: "unwrapped desktop | native",
|
||||
wrapLines: false,
|
||||
viewport: DESKTOP_VIEWPORT,
|
||||
backend: nativeBackend,
|
||||
},
|
||||
{
|
||||
name: "wrapped desktop | native",
|
||||
wrapLines: true,
|
||||
viewport: DESKTOP_VIEWPORT,
|
||||
backend: nativeBackend,
|
||||
},
|
||||
{
|
||||
name: "wrapped phone | native",
|
||||
wrapLines: true,
|
||||
viewport: PHONE_VIEWPORT,
|
||||
backend: nativeBackend,
|
||||
},
|
||||
];
|
||||
|
||||
function countModel(scenario: Scenario, variant: Variant) {
|
||||
const { measurer, stats } = variant.backend();
|
||||
const started = performance.now();
|
||||
const model = buildDiffDocumentModel(
|
||||
input(scenario, {
|
||||
wrapLines: variant.wrapLines,
|
||||
viewport: variant.viewport,
|
||||
measureText: measurer,
|
||||
}),
|
||||
);
|
||||
const elapsedMs = performance.now() - started;
|
||||
let materializedCells = 0;
|
||||
let fragments = 0;
|
||||
let graphemes = 0;
|
||||
for (const row of model.rows) {
|
||||
if (row.kind !== "line") continue;
|
||||
for (const cell of row.cells) {
|
||||
if (!cell || cell.fragments.length === 0) continue;
|
||||
materializedCells += 1;
|
||||
fragments += cell.fragments.length;
|
||||
for (const fragment of cell.fragments) graphemes += fragment.graphemes.length;
|
||||
}
|
||||
}
|
||||
const sourceLines = scenario.files.reduce(
|
||||
(count, file) => count + file.hunks.reduce((total, hunk) => total + hunk.lines.length, 0),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
sourceLines,
|
||||
materializedCells,
|
||||
fragments,
|
||||
graphemeObjects: graphemes,
|
||||
measureCalls: stats.calls,
|
||||
measuredCharacters: stats.characters,
|
||||
coldMs: Number(elapsedMs.toFixed(1)),
|
||||
};
|
||||
}
|
||||
|
||||
const report: Record<string, ReturnType<typeof countModel>> = {};
|
||||
for (const scenario of SCENARIOS) {
|
||||
for (const variant of VARIANTS) {
|
||||
report[`${scenario.name} | ${variant.name}`] = countModel(scenario, variant);
|
||||
}
|
||||
}
|
||||
console.table(report);
|
||||
|
||||
for (const scenario of SCENARIOS) {
|
||||
describe(scenario.name, () => {
|
||||
for (const variant of VARIANTS) {
|
||||
if (variant.backend === nativeBackend) continue;
|
||||
bench(variant.name, () => {
|
||||
buildDiffDocumentModel(
|
||||
input(scenario, {
|
||||
wrapLines: variant.wrapLines,
|
||||
viewport: variant.viewport,
|
||||
measureText: variant.backend().measurer,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
resolveRelayoutScrollTop,
|
||||
shouldApplyRelayoutScroll,
|
||||
} from "./model";
|
||||
import type { BuildDiffDocumentModelInput, DiffCell, TextMeasurer } from "./types";
|
||||
import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types";
|
||||
|
||||
const measurer: TextMeasurer = { measure: (text) => Array.from(text).length * 10 };
|
||||
|
||||
@@ -639,119 +639,17 @@ describe("diff document model", () => {
|
||||
expect(firstLineCells(last, "b.ts")).toBe(firstLineCells(first, "b.ts"));
|
||||
});
|
||||
|
||||
it("replaces estimated wrapped heights through reuse and preserves the scroll anchor", () => {
|
||||
const changed = fileWithChangedLine("x".repeat(100));
|
||||
const files = [changed, file("later.ts")];
|
||||
// Deliberately inaccurate M width makes the estimate differ from exact layout.
|
||||
const measureText = {
|
||||
measure: (text: string) => (text === "M" ? 5 : Array.from(text).length * 10),
|
||||
};
|
||||
const options = input({ files, measureText });
|
||||
const first = buildDiffDocumentModel({
|
||||
...options,
|
||||
materializationWindow: { top: 30, height: 1 },
|
||||
});
|
||||
const eager = buildDiffDocumentModel(options);
|
||||
const exact = buildDiffDocumentModel({ ...options, reuseFrom: [first] });
|
||||
expect(first.height).not.toBe(eager.height);
|
||||
expect(exact.rows).toEqual(eager.rows);
|
||||
expect(exact.height).toBe(eager.height);
|
||||
expect(firstLineCells(exact, changed.path)).toBe(firstLineCells(first, changed.path));
|
||||
const scrollTop = first.files[1]!.bodyTop + 3;
|
||||
const nextScrollTop = resolveRelayoutScrollTop(first, exact, scrollTop);
|
||||
expect(nextScrollTop).toBe(exact.files[1]!.bodyTop + 3);
|
||||
expect(captureScrollAnchor(exact, nextScrollTop)).toEqual(
|
||||
captureScrollAnchor(first, scrollTop),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the wrapped source offset when resize leaves the anchor outside the window", () => {
|
||||
const source = file();
|
||||
source.hunks[0]!.lines = Array.from({ length: 200 }, () => ({
|
||||
type: "add" as const,
|
||||
content: "x".repeat(200),
|
||||
}));
|
||||
const options = input({ files: [source], viewportWidth: 500 });
|
||||
const before = buildDiffDocumentModel(options);
|
||||
const scrollTop = before.rows[100]!.top + 36;
|
||||
const anchor = captureScrollAnchor(before, scrollTop)!;
|
||||
expect(anchor).toMatchObject({ kind: "text", cellOffset: 90 });
|
||||
const resizedOptions = { ...options, viewportWidth: 180 };
|
||||
const estimated = buildDiffDocumentModel({
|
||||
...resizedOptions,
|
||||
materializationWindow: {
|
||||
top: Math.floor(scrollTop / 800) * 800 - 800,
|
||||
height: 2000,
|
||||
},
|
||||
});
|
||||
const estimatedRow = estimated.rows[100]!;
|
||||
expect(estimatedRow.kind === "line" && estimatedRow.cells[0]?.fragments).toEqual([]);
|
||||
const restored = resolveRelayoutScrollTop(before, estimated, scrollTop);
|
||||
const recaptured = captureScrollAnchor(estimated, restored)!;
|
||||
expect(recaptured).toMatchObject({ kind: "text", cellOffset: 78 });
|
||||
|
||||
const materialized = buildDiffDocumentModel({
|
||||
...resizedOptions,
|
||||
materializationWindow: { top: restored - 800, height: 2000 },
|
||||
reuseFrom: [estimated],
|
||||
});
|
||||
const eager = buildDiffDocumentModel(resizedOptions);
|
||||
const expected = resolveRelayoutScrollTop(before, eager, scrollTop);
|
||||
expect(expected).toBe(28_938);
|
||||
expect(Math.abs(restored - expected)).toBeLessThan(before.lineHeight);
|
||||
expect(resolveRelayoutScrollTop(estimated, materialized, restored)).toBe(expected);
|
||||
});
|
||||
|
||||
it("materializes the relayout anchor even when off-screen height estimates are inaccurate", () => {
|
||||
const source = file();
|
||||
source.hunks[0]!.lines = Array.from({ length: 200 }, () => ({
|
||||
type: "add" as const,
|
||||
content: "x".repeat(200),
|
||||
}));
|
||||
const options = input({ files: [source], viewportWidth: 500 });
|
||||
const before = buildDiffDocumentModel(options);
|
||||
const scrollTop = before.rows[100]!.top + 38;
|
||||
const anchor = captureScrollAnchor(before, scrollTop)!;
|
||||
const resizedOptions = {
|
||||
...options,
|
||||
viewportWidth: 180,
|
||||
measureText: { measure: (text: string) => (text === "M" ? 5 : text.length * 10) },
|
||||
};
|
||||
const resized = buildDiffDocumentModel({
|
||||
...resizedOptions,
|
||||
materializationWindow: { top: scrollTop - 800, height: 2000 },
|
||||
materializationAnchor: anchor,
|
||||
});
|
||||
const row = resized.rows[100]!;
|
||||
expect(row.kind === "line" && row.cells[0]!.fragments.length).toBeGreaterThan(0);
|
||||
const restored = resolveRelayoutScrollTop(before, resized, scrollTop);
|
||||
const materialized = buildDiffDocumentModel({
|
||||
...resizedOptions,
|
||||
materializationWindow: { top: restored - 800, height: 2000 },
|
||||
materializationAnchor: captureScrollAnchor(resized, restored),
|
||||
reuseFrom: [resized],
|
||||
});
|
||||
const nextScrollTop = resolveRelayoutScrollTop(resized, materialized, restored);
|
||||
const nextAnchor = captureScrollAnchor(materialized, nextScrollTop);
|
||||
// Offset 90 belongs to the fragment beginning at 78 after narrowing.
|
||||
expect(nextAnchor).toMatchObject({ kind: "text", cellOffset: 78, viewportDelta: -2 });
|
||||
expect(nextScrollTop).toBe(resolveScrollAnchor(materialized, anchor));
|
||||
});
|
||||
|
||||
it("materializes wrapped text only inside the window", () => {
|
||||
it("keeps wrapped geometry fully measured", () => {
|
||||
const model = buildDiffDocumentModel(
|
||||
input({
|
||||
files: [file("a.ts"), file("b.ts")],
|
||||
wrapLines: true,
|
||||
materializationWindow: { top: FILE_HEADER_HEIGHT, height: 1 },
|
||||
materializationWindow: { top: 0, height: FILE_HEADER_HEIGHT },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(measuredFragments(model, "a.ts")).toBeGreaterThan(0);
|
||||
expect(measuredFragments(model, "b.ts")).toBe(0);
|
||||
expect(
|
||||
model.rows.filter((row) => row.kind === "line" && row.cells.some(hasFragments)),
|
||||
).toHaveLength(1);
|
||||
expect(measuredFragments(model, "b.ts")).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -900,7 +798,3 @@ function reviewActionsWithEditor(
|
||||
onDeleteComment() {},
|
||||
};
|
||||
}
|
||||
|
||||
function hasFragments(cell: DiffCell | null): boolean {
|
||||
return cell !== null && cell.fragments.length > 0;
|
||||
}
|
||||
|
||||
@@ -154,38 +154,27 @@ function appendReusableFileRows(candidate: {
|
||||
}): FileRowsResult {
|
||||
const reusedRows = candidate.model.rows.slice(candidate.file.rowStart, candidate.file.rowEnd);
|
||||
const retainsRowGeometry =
|
||||
!candidate.input.wrapLines &&
|
||||
candidate.model.reviewGeometryKey === candidate.reviewGeometryKey &&
|
||||
candidate.file.top === candidate.fileTop &&
|
||||
candidate.file.fileIndex === candidate.fileIndex &&
|
||||
candidate.file.rowStart === candidate.rows.length;
|
||||
let top = candidate.bodyTop;
|
||||
for (const reusedRow of reusedRows) {
|
||||
let nextRow =
|
||||
retainsRowGeometry && reusedRow.top === top
|
||||
? reusedRow
|
||||
: repositionReusedRow(reusedRow, candidate, top);
|
||||
let nextRow = retainsRowGeometry ? reusedRow : repositionReusedRow(reusedRow, candidate, top);
|
||||
if (
|
||||
nextRow.kind === "line" &&
|
||||
(intersectsMaterializationWindow(candidate.input, top, top + nextRow.height) ||
|
||||
containsMaterializationAnchor(candidate.input, candidate.file.path, nextRow.cells)) &&
|
||||
intersectsMaterializationWindow(candidate.input, top, top + nextRow.height) &&
|
||||
nextRow.cells.some((cell) => cell && cell.fragments.length === 0)
|
||||
) {
|
||||
const cells = materializeCells(
|
||||
nextRow.cells,
|
||||
candidate.file.file,
|
||||
candidate.file.gutterWidth,
|
||||
candidate.input,
|
||||
);
|
||||
nextRow = {
|
||||
...nextRow,
|
||||
cells,
|
||||
height:
|
||||
Math.max(
|
||||
candidate.input.typography.lineHeight,
|
||||
...cells.map(
|
||||
(cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight,
|
||||
),
|
||||
) + nextRow.reviewHeight,
|
||||
cells: materializeCells(
|
||||
nextRow.cells,
|
||||
candidate.file.file,
|
||||
candidate.file.gutterWidth,
|
||||
candidate.input,
|
||||
),
|
||||
};
|
||||
}
|
||||
candidate.rows.push(nextRow);
|
||||
@@ -251,7 +240,6 @@ function appendNewFileRows(candidate: {
|
||||
const lines = geometryLines(
|
||||
lineSources(candidate.file, candidate.input.layout, false),
|
||||
candidate.input,
|
||||
candidate.gutterWidth,
|
||||
);
|
||||
const fileBottom =
|
||||
candidate.bodyTop +
|
||||
@@ -264,8 +252,8 @@ function appendNewFileRows(candidate: {
|
||||
let maximumHorizontalOverflow = 0;
|
||||
for (const line of lines) {
|
||||
const shouldMaterialize =
|
||||
intersectsMaterializationWindow(candidate.input, top, top + line.height) ||
|
||||
containsMaterializationAnchor(candidate.input, candidate.file.path, line.cells);
|
||||
candidate.input.wrapLines ||
|
||||
intersectsMaterializationWindow(candidate.input, top, top + line.height);
|
||||
const cells = shouldMaterialize
|
||||
? materializeCells(line.cells, candidate.file, candidate.gutterWidth, candidate.input)
|
||||
: line.cells;
|
||||
@@ -287,7 +275,7 @@ function appendNewFileRows(candidate: {
|
||||
candidate.input.typography.lineHeight,
|
||||
...cells.map((cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight),
|
||||
);
|
||||
const height = shouldMaterialize ? textHeight + line.reviewHeight : line.height;
|
||||
const height = textHeight + line.reviewHeight;
|
||||
candidate.rows.push({
|
||||
kind: "line",
|
||||
index: candidate.rows.length,
|
||||
@@ -424,14 +412,13 @@ function cellSource(line: SplitDiffDisplayLine | null, includeTokens: boolean):
|
||||
};
|
||||
}
|
||||
|
||||
function geometryCell(source: CellSource, estimatedWrapColumns: number): DiffCell {
|
||||
function geometryCell(source: CellSource): DiffCell {
|
||||
return {
|
||||
type: source.type,
|
||||
content: source.content,
|
||||
lineNumber: source.lineNumber,
|
||||
tokens: [],
|
||||
fragments: [],
|
||||
estimatedWrapColumns,
|
||||
reviewTarget: source.reviewTarget,
|
||||
sourceIdentity: source.sourceIdentity,
|
||||
};
|
||||
@@ -440,87 +427,20 @@ function geometryCell(source: CellSource, estimatedWrapColumns: number): DiffCel
|
||||
function geometryLines(
|
||||
sources: Array<[CellSource] | [CellSource | null, CellSource | null]>,
|
||||
input: BuildDiffDocumentModelInput,
|
||||
gutterWidth: number,
|
||||
): GeometryLine[] {
|
||||
const characterWidth = input.wrapLines ? input.measureText.measure("M") : 0;
|
||||
return sources.map((sourceCells) => {
|
||||
const columns = input.wrapLines
|
||||
? Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
(input.viewportWidth / sourceCells.length - gutterWidth - CODE_HORIZONTAL_PADDING) /
|
||||
characterWidth,
|
||||
),
|
||||
)
|
||||
: Infinity;
|
||||
const cells = sourceCells.map((source) =>
|
||||
source ? geometryCell(source, columns) : null,
|
||||
source ? geometryCell(source) : null,
|
||||
) as DiffLineRow["cells"];
|
||||
const reviewHeight = reviewHeightForCells(cells, input);
|
||||
return {
|
||||
cells,
|
||||
reviewHeight,
|
||||
// Unseen wrapped rows use a monospace estimate; materialization replaces
|
||||
// it with the exact fragment count without changing source identity.
|
||||
height:
|
||||
input.typography.lineHeight *
|
||||
Math.max(
|
||||
1,
|
||||
...cells.map((cell) => {
|
||||
return cell && input.wrapLines
|
||||
? Math.ceil(estimatedDisplayOffset(cell.content) / columns)
|
||||
: 1;
|
||||
}),
|
||||
) +
|
||||
reviewHeight,
|
||||
height: input.typography.lineHeight + reviewHeight,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Use the same tab expansion and column count for estimated heights and anchors.
|
||||
function estimatedDisplayOffset(text: string): number {
|
||||
return text.length + (text.match(/\t/g)?.length ?? 0) * 3;
|
||||
}
|
||||
|
||||
function estimatedVisualLine(cell: DiffCell, sourceOffset: number): number {
|
||||
return Math.floor(
|
||||
estimatedDisplayOffset(cell.content.slice(0, sourceOffset)) /
|
||||
(cell.estimatedWrapColumns ?? Infinity),
|
||||
);
|
||||
}
|
||||
|
||||
function estimatedFragment(cell: DiffCell, visualIndex: number, lineHeight: number) {
|
||||
const columns = cell.estimatedWrapColumns ?? Infinity;
|
||||
const lastIndex = Math.max(0, Math.ceil(estimatedDisplayOffset(cell.content) / columns) - 1);
|
||||
const index = Math.min(visualIndex, lastIndex);
|
||||
const displayOffset = index === 0 ? 0 : index * columns;
|
||||
let start = Math.min(displayOffset, cell.content.length);
|
||||
if (cell.content.includes("\t")) {
|
||||
let offset = 0;
|
||||
start = 0;
|
||||
while (start < cell.content.length && offset < displayOffset) {
|
||||
offset += cell.content[start] === "\t" ? 4 : 1;
|
||||
start += 1;
|
||||
}
|
||||
}
|
||||
return { start, top: index * lineHeight };
|
||||
}
|
||||
|
||||
// A resize can move the anchor outside the old window. Measure that row in the
|
||||
// same pass: estimated columns cannot identify exact shaped-text fragments.
|
||||
function containsMaterializationAnchor(
|
||||
input: BuildDiffDocumentModelInput,
|
||||
path: string,
|
||||
cells: DiffLineRow["cells"],
|
||||
): boolean {
|
||||
const anchor = input.materializationAnchor;
|
||||
return (
|
||||
anchor?.kind === "text" &&
|
||||
anchor.path === path &&
|
||||
cells.some((cell) => cell && sameSourceIdentity(cell.sourceIdentity, anchor.sourceIdentity))
|
||||
);
|
||||
}
|
||||
|
||||
function intersectsMaterializationWindow(
|
||||
input: BuildDiffDocumentModelInput,
|
||||
fileTop: number,
|
||||
@@ -875,27 +795,17 @@ export function captureScrollAnchor(
|
||||
}
|
||||
const textY = Math.max(0, Math.min(scrollTop - row.top, row.height - row.reviewHeight));
|
||||
const visualIndex = Math.max(0, Math.floor(textY / model.lineHeight));
|
||||
let cellIndex = row.cells.findIndex(
|
||||
(cell) =>
|
||||
cell &&
|
||||
(cell.fragments.length > 0
|
||||
? Boolean(cell.fragments[visualIndex])
|
||||
: estimatedFragment(cell, visualIndex, model.lineHeight).top ===
|
||||
visualIndex * model.lineHeight),
|
||||
);
|
||||
let cellIndex = row.cells.findIndex((cell) => Boolean(cell?.fragments[visualIndex]));
|
||||
if (cellIndex < 0) cellIndex = row.cells.findIndex((cell) => cell !== null);
|
||||
const cell = row.cells[cellIndex];
|
||||
if (!cell) return { kind: "header", path: file.path, viewportDelta: row.top - scrollTop };
|
||||
const fragment =
|
||||
cell.fragments.length > 0
|
||||
? cell.fragments[Math.min(cell.fragments.length - 1, visualIndex)]!
|
||||
: estimatedFragment(cell, visualIndex, model.lineHeight);
|
||||
const anchorY = row.top + fragment.top;
|
||||
const fragment = cell.fragments[Math.min(cell.fragments.length - 1, visualIndex)];
|
||||
const anchorY = row.top + (fragment?.top ?? 0);
|
||||
return {
|
||||
kind: "text",
|
||||
path: file.path,
|
||||
sourceIdentity: cell.sourceIdentity,
|
||||
cellOffset: fragment.start,
|
||||
cellOffset: fragment?.start ?? 0,
|
||||
viewportDelta: anchorY - scrollTop,
|
||||
};
|
||||
}
|
||||
@@ -921,10 +831,8 @@ export function resolveScrollAnchor(model: DiffDocumentModel, anchor: DiffScroll
|
||||
const fragment =
|
||||
cell.fragments.find(
|
||||
(candidate) => candidate.start <= anchor.cellOffset && anchor.cellOffset < candidate.end,
|
||||
) ??
|
||||
cell.fragments.at(-1) ??
|
||||
estimatedFragment(cell, estimatedVisualLine(cell, anchor.cellOffset), model.lineHeight);
|
||||
return Math.max(0, row.top + fragment.top - anchor.viewportDelta);
|
||||
) ?? cell.fragments.at(-1);
|
||||
return Math.max(0, row.top + (fragment?.top ?? 0) - anchor.viewportDelta);
|
||||
}
|
||||
|
||||
export function resolveRelayoutScrollTop(
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
} from "./horizontal-offsets";
|
||||
import {
|
||||
buildDiffDocumentModel,
|
||||
captureScrollAnchor,
|
||||
resolveRelayoutScrollTop,
|
||||
retainReusableModels,
|
||||
shouldApplyRelayoutScroll,
|
||||
@@ -139,9 +138,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
tooLarge: t("workspace.git.diff.tooLarge"),
|
||||
},
|
||||
materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height),
|
||||
materializationAnchor: previousModelRef.current
|
||||
? captureScrollAnchor(previousModelRef.current, scrollTop.get())
|
||||
: null,
|
||||
reuseFrom,
|
||||
});
|
||||
reusableModelRef.current = {
|
||||
@@ -152,7 +148,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
}, [
|
||||
measurement,
|
||||
fileWindowTop,
|
||||
scrollTop,
|
||||
props.collapsedFilePaths,
|
||||
props.displayPreferences.layout,
|
||||
props.displayPreferences.wrapLines,
|
||||
|
||||
@@ -28,20 +28,10 @@ import {
|
||||
} from "./hit-testing";
|
||||
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
|
||||
import { HorizontalScroll } from "./horizontal-scroll.web";
|
||||
import {
|
||||
buildDiffDocumentModel,
|
||||
captureScrollAnchor,
|
||||
FILE_HEADER_HEIGHT,
|
||||
resolveRelayoutScrollTop,
|
||||
} from "./model";
|
||||
import { buildDiffDocumentModel, FILE_HEADER_HEIGHT, resolveRelayoutScrollTop } from "./model";
|
||||
import { paintWebFileHeader, paintWebViewport } from "./paint.web";
|
||||
import { hasPointerDragStarted } from "./pointer-gesture";
|
||||
import {
|
||||
createMeasuredAdvances,
|
||||
createAdditiveAdvances,
|
||||
createChunkedWidthMeasurer,
|
||||
requiresShaping,
|
||||
} from "./text-measurement";
|
||||
import { createMeasuredAdvances } from "./text-measurement";
|
||||
import { retainDiffViewport } from "./viewport";
|
||||
import type {
|
||||
DiffHit,
|
||||
@@ -193,9 +183,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
tooLarge: t("workspace.git.diff.tooLarge"),
|
||||
},
|
||||
materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height),
|
||||
materializationAnchor: previousModelRef.current
|
||||
? captureScrollAnchor(previousModelRef.current, scrollTopRef.current)
|
||||
: null,
|
||||
});
|
||||
return next;
|
||||
}, [
|
||||
@@ -1179,13 +1166,5 @@ function createWebTextMeasurer(typography: DiffTypography): TextMeasurer {
|
||||
};
|
||||
// Canvas exposes no glyph coverage, so `requiresShaping` alone decides which
|
||||
// runs a shaper has to see -- there is no `glyphIds` to fall back on.
|
||||
const additive = createAdditiveAdvances(measure);
|
||||
return {
|
||||
measure,
|
||||
measureAdvances: createMeasuredAdvances(measure),
|
||||
measureWidth: createChunkedWidthMeasurer((graphemes) => {
|
||||
const text = graphemes.join("");
|
||||
return requiresShaping(text) ? measure(text) : (additive(graphemes).at(-1) ?? 0);
|
||||
}),
|
||||
};
|
||||
return { measure, measureAdvances: createMeasuredAdvances((text) => measure(text)) };
|
||||
}
|
||||
|
||||
@@ -112,8 +112,6 @@ export interface DiffCell {
|
||||
lineNumber: number | null;
|
||||
tokens: DiffTokenRun[];
|
||||
fragments: DiffFragment[];
|
||||
/** Monospace columns per visual line until this cell is measured. */
|
||||
estimatedWrapColumns?: number;
|
||||
reviewTarget: ReviewableDiffTarget | null;
|
||||
sourceIdentity: DiffSourceIdentity;
|
||||
}
|
||||
@@ -187,8 +185,6 @@ export interface BuildDiffDocumentModelInput {
|
||||
reviewActions?: InlineReviewActions;
|
||||
labels: { binary: string; tooLarge: string };
|
||||
materializationWindow?: { top: number; height: number };
|
||||
/** Also measure the anchored row so relayout can restore its exact fragment. */
|
||||
materializationAnchor?: DiffScrollAnchor | null;
|
||||
/** A geometry-compatible model whose unchanged file measurements may be reused. */
|
||||
reuseFrom?: readonly DiffDocumentModel[];
|
||||
}
|
||||
|
||||
@@ -95,37 +95,6 @@ describe("diff document workspace cache", () => {
|
||||
expect(stats.calls).toBe(0);
|
||||
});
|
||||
|
||||
it("materializes a new anchor when the viewport window is unchanged", () => {
|
||||
const cache = createDiffDocumentWorkspaceCache();
|
||||
const source = diffFile();
|
||||
source.hunks[0]!.lines = Array.from({ length: 100 }, () => ({
|
||||
type: "add" as const,
|
||||
content: "x".repeat(200),
|
||||
}));
|
||||
const { measureText } = countingMeasurer();
|
||||
const input = modelInput([source], measureText, {
|
||||
wrapLines: true,
|
||||
materializationWindow: { top: 30, height: 54 },
|
||||
});
|
||||
const first = cache.buildModel(input);
|
||||
const anchored = cache.buildModel({
|
||||
...input,
|
||||
materializationAnchor: {
|
||||
kind: "text",
|
||||
path: source.path,
|
||||
sourceIdentity: { hunkIndex: 0, lineIndex: 50, side: "new" },
|
||||
cellOffset: 90,
|
||||
viewportDelta: -2,
|
||||
},
|
||||
});
|
||||
const firstRow = first.rows[50]!;
|
||||
const anchoredRow = anchored.rows[50]!;
|
||||
expect(firstRow.kind === "line" && firstRow.cells[0]!.fragments.length).toBe(0);
|
||||
expect(anchoredRow.kind === "line" && anchoredRow.cells[0]!.fragments.length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("retains text cells for unchanged files when another file changes", () => {
|
||||
const cache = createDiffDocumentWorkspaceCache();
|
||||
const { measureText } = countingMeasurer();
|
||||
|
||||
@@ -128,9 +128,7 @@ function exactModelKey(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">): s
|
||||
const materializationWindow = input.materializationWindow
|
||||
? [input.materializationWindow.top, input.materializationWindow.height]
|
||||
: null;
|
||||
const anchor = input.materializationAnchor;
|
||||
const anchoredRow = anchor?.kind === "text" ? [anchor.path, anchor.sourceIdentity] : null;
|
||||
return JSON.stringify([collapsedFilePaths, reviewGeometry, materializationWindow, anchoredRow]);
|
||||
return JSON.stringify([collapsedFilePaths, reviewGeometry, materializationWindow]);
|
||||
}
|
||||
|
||||
function typographyKey(typography: DiffTypography): string {
|
||||
|
||||
@@ -78,7 +78,6 @@ describe("ensureAgentIsInitialized", () => {
|
||||
cursor: { epoch: "epoch-1", seq: 42 },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -106,7 +105,6 @@ describe("ensureAgentIsInitialized", () => {
|
||||
direction: "tail",
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -150,7 +148,6 @@ describe("ensureAgentIsInitialized", () => {
|
||||
direction: "tail",
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -195,7 +192,6 @@ describe("ensureAgentIsInitialized", () => {
|
||||
cursor: { epoch: "epoch-1", seq: 12 },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -287,7 +283,6 @@ describe("refreshAgent", () => {
|
||||
direction: "tail",
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -152,7 +152,6 @@ describe("loadOlderAgentHistory", () => {
|
||||
cursor: { epoch: "epoch-1", seq: 10 },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -49,7 +49,6 @@ import {
|
||||
type KeyValueStorage,
|
||||
type ReleaseChannel,
|
||||
type SendBehavior,
|
||||
type SendShortcut,
|
||||
type ServiceUrlBehavior,
|
||||
type Settings,
|
||||
type SidebarWorkspaceTrailing,
|
||||
@@ -94,7 +93,6 @@ export type {
|
||||
KeyValueStorage,
|
||||
ReleaseChannel,
|
||||
SendBehavior,
|
||||
SendShortcut,
|
||||
ServiceUrlBehavior,
|
||||
Settings,
|
||||
SettingsDeps,
|
||||
|
||||
@@ -227,39 +227,6 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
expect(result.chatOutlineEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("uses Enter to send by default", async () => {
|
||||
const deps = makeDeps();
|
||||
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
expect(result.sendShortcut).toBe("enter");
|
||||
expect(DEFAULT_CLIENT_SETTINGS.sendShortcut).toBe("enter");
|
||||
});
|
||||
|
||||
it("loads the modifier Enter send preference", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ sendShortcut: "mod-enter" }),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
expect(result.sendShortcut).toBe("mod-enter");
|
||||
});
|
||||
|
||||
it("falls back to Enter when the stored send shortcut is invalid", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ sendShortcut: "yes" }),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
expect(result.sendShortcut).toBe("enter");
|
||||
});
|
||||
|
||||
it("defaults sidebar navigation items to an empty preference list", async () => {
|
||||
const deps = makeDeps();
|
||||
|
||||
|
||||
@@ -33,8 +33,6 @@ import { migrateAppSettings } from "./migrations";
|
||||
export { APP_SETTINGS_KEY } from "./keys";
|
||||
export const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
||||
|
||||
export type SendShortcut = "enter" | "mod-enter";
|
||||
|
||||
export type SendBehavior = ActiveTurnBehavior | "queue";
|
||||
export type ReleaseChannel = "stable" | "beta";
|
||||
export type ServiceUrlBehavior = "ask" | "in-app" | "external";
|
||||
@@ -106,7 +104,6 @@ export interface AppSettings {
|
||||
chatDisplay: ChatDisplay;
|
||||
chatOutlineEnabled: boolean;
|
||||
vimKeybindings: boolean;
|
||||
sendShortcut: SendShortcut;
|
||||
filePanelAssociations: Record<string, string>;
|
||||
/** Desktop-only preferences for implicit opens into the ordinary side pane. */
|
||||
openInSidePane: OpenInSidePanePreferences;
|
||||
@@ -163,7 +160,6 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
chatDisplay: "collapseTurns",
|
||||
chatOutlineEnabled: true,
|
||||
vimKeybindings: false,
|
||||
sendShortcut: "enter",
|
||||
filePanelAssociations: {},
|
||||
openInSidePane: DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES,
|
||||
pullRequestOpenLocation: "explorer",
|
||||
@@ -268,7 +264,6 @@ const StoredAppSettingsSchema = z
|
||||
compactToolCalls: z.boolean().optional().catch(undefined),
|
||||
chatOutlineEnabled: z.boolean().catch(true),
|
||||
vimKeybindings: z.boolean().catch(false),
|
||||
sendShortcut: z.enum(["enter", "mod-enter"]).catch("enter"),
|
||||
filePanelAssociations: z.record(z.string(), z.string()).catch({}),
|
||||
openInSidePane: z
|
||||
.object({
|
||||
|
||||
@@ -67,7 +67,6 @@ export const ar: TranslationResources = {
|
||||
daemonClientUnavailable: "عميل Daemon غير متوفر",
|
||||
daemonClientDisconnected: "تم قطع اتصال عميل Daemon",
|
||||
noFileFound: "لم يتم العثور على ملف لـ{{token}}",
|
||||
folderOutsideWorkspace: "{{path}} خارج مساحة العمل هذه",
|
||||
unexpectedDictationError: "حدث خطأ غير متوقع أثناء معالجة الإملاء.",
|
||||
},
|
||||
connectionStatus: {
|
||||
@@ -2019,13 +2018,12 @@ export const ar: TranslationResources = {
|
||||
output: "الإخراج",
|
||||
},
|
||||
turnSummary: {
|
||||
details: "عرض تفاصيل الجولة",
|
||||
sentBy: "أرسلها {{name}}",
|
||||
showMore: "عرض المزيد",
|
||||
showLess: "عرض أقل",
|
||||
messages: {
|
||||
one: "كتب {{count}} رسالة",
|
||||
other: "كتب {{count}} رسائل",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
thought: "فكّر",
|
||||
editedFiles: {
|
||||
one: "حرّر {{count}} ملفًا",
|
||||
other: "حرّر {{count}} ملفات",
|
||||
@@ -2371,25 +2369,12 @@ export const ar: TranslationResources = {
|
||||
"عند تشغيل الوكيل، يوجّه Enter الجولة النشطة. يضيف Command/Ctrl+Enter إلى قائمة الانتظار.",
|
||||
queue: "عند تشغيل الوكيل، يضيف Enter إلى قائمة الانتظار. Command/Ctrl+Enter يرسل.",
|
||||
},
|
||||
commandEnterDescriptions: {
|
||||
interrupt: "عند تشغيل الوكيل، يوقف {{modifier}}+Enter التشغيل. يُدرج Enter سطرًا جديدًا.",
|
||||
steer: "عند تشغيل الوكيل، يوجّه {{modifier}}+Enter الجولة النشطة. يُدرج Enter سطرًا جديدًا.",
|
||||
queue:
|
||||
"عند تشغيل الوكيل، يضيف {{modifier}}+Enter إلى قائمة الانتظار. يُدرج Enter سطرًا جديدًا.",
|
||||
},
|
||||
options: {
|
||||
interrupt: "مقاطعة",
|
||||
steer: "توجيه",
|
||||
queue: "طابور",
|
||||
},
|
||||
},
|
||||
sendShortcut: {
|
||||
label: "اختصار الإرسال",
|
||||
options: {
|
||||
enter: "Enter",
|
||||
"mod-enter": "{{modifier}}+Enter",
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
options: {
|
||||
ask: "بسأل",
|
||||
|
||||
@@ -63,7 +63,6 @@ export const en = {
|
||||
daemonClientUnavailable: "Daemon client unavailable",
|
||||
daemonClientDisconnected: "Daemon client is disconnected",
|
||||
noFileFound: "No file found for {{token}}",
|
||||
folderOutsideWorkspace: "{{path}} is outside this workspace",
|
||||
unexpectedDictationError: "An unexpected error occurred while handling dictation.",
|
||||
},
|
||||
connectionStatus: {
|
||||
@@ -2044,13 +2043,12 @@ export const en = {
|
||||
output: "Output",
|
||||
},
|
||||
turnSummary: {
|
||||
details: "View turn details",
|
||||
sentBy: "Sent by {{name}}",
|
||||
showMore: "Show more",
|
||||
showLess: "Show less",
|
||||
messages: {
|
||||
one: "wrote {{count}} message",
|
||||
other: "wrote {{count}} messages",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
thought: "Thought",
|
||||
editedFiles: {
|
||||
one: "edited {{count}} file",
|
||||
other: "edited {{count}} files",
|
||||
@@ -2459,26 +2457,12 @@ export const en = {
|
||||
"When the agent is running, Enter steers the active turn. Command/Ctrl+Enter queues.",
|
||||
queue: "When the agent is running, Enter queues. Command/Ctrl+Enter submits.",
|
||||
},
|
||||
commandEnterDescriptions: {
|
||||
interrupt:
|
||||
"When the agent is running, {{modifier}}+Enter interrupts. Enter inserts a new line.",
|
||||
steer:
|
||||
"When the agent is running, {{modifier}}+Enter steers the active turn. Enter inserts a new line.",
|
||||
queue: "When the agent is running, {{modifier}}+Enter queues. Enter inserts a new line.",
|
||||
},
|
||||
options: {
|
||||
interrupt: "Interrupt",
|
||||
steer: "Steer",
|
||||
queue: "Queue",
|
||||
},
|
||||
},
|
||||
sendShortcut: {
|
||||
label: "Send shortcut",
|
||||
options: {
|
||||
enter: "Enter",
|
||||
"mod-enter": "{{modifier}}+Enter",
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
options: {
|
||||
ask: "Ask",
|
||||
|
||||
@@ -68,7 +68,6 @@ export const es: TranslationResources = {
|
||||
daemonClientUnavailable: "ClienteDaemonno disponible",
|
||||
daemonClientDisconnected: "El clienteDaemonestá desconectado",
|
||||
noFileFound: "No se encontró ningún archivo para{{token}}",
|
||||
folderOutsideWorkspace: "{{path}} está fuera de este espacio de trabajo",
|
||||
unexpectedDictationError: "Se produjo un error inesperado al manejar el dictado.",
|
||||
},
|
||||
connectionStatus: {
|
||||
@@ -2067,13 +2066,12 @@ export const es: TranslationResources = {
|
||||
output: "Producción",
|
||||
},
|
||||
turnSummary: {
|
||||
details: "Ver detalles del turno",
|
||||
sentBy: "Enviado por {{name}}",
|
||||
showMore: "Mostrar más",
|
||||
showLess: "Mostrar menos",
|
||||
messages: {
|
||||
one: "escribió {{count}} mensaje",
|
||||
other: "escribió {{count}} mensajes",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
thought: "Razonó",
|
||||
editedFiles: {
|
||||
one: "editó {{count}} archivo",
|
||||
other: "editó {{count}} archivos",
|
||||
@@ -2386,27 +2384,12 @@ export const es: TranslationResources = {
|
||||
queue:
|
||||
"Cuando el agente se está ejecutando, Enter pone en cola. Command/Ctrl+Enter envía.",
|
||||
},
|
||||
commandEnterDescriptions: {
|
||||
interrupt:
|
||||
"Cuando el agente se está ejecutando, {{modifier}}+Enter interrumpe. Enter inserta una línea nueva.",
|
||||
steer:
|
||||
"Cuando el agente se está ejecutando, {{modifier}}+Enter dirige el turno activo. Enter inserta una línea nueva.",
|
||||
queue:
|
||||
"Cuando el agente se está ejecutando, {{modifier}}+Enter pone en cola. Enter inserta una línea nueva.",
|
||||
},
|
||||
options: {
|
||||
interrupt: "Interrumpir",
|
||||
steer: "Dirigir",
|
||||
queue: "Cola",
|
||||
},
|
||||
},
|
||||
sendShortcut: {
|
||||
label: "Atajo de envío",
|
||||
options: {
|
||||
enter: "Enter",
|
||||
"mod-enter": "{{modifier}}+Enter",
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
options: {
|
||||
ask: "Preguntar",
|
||||
|
||||
@@ -68,7 +68,6 @@ export const fr: TranslationResources = {
|
||||
daemonClientUnavailable: "Client du daemon indisponible",
|
||||
daemonClientDisconnected: "Le client du daemon est déconnecté",
|
||||
noFileFound: "Aucun fichier trouvé pour {{token}}",
|
||||
folderOutsideWorkspace: "{{path}} se trouve en dehors de cet espace de travail",
|
||||
unexpectedDictationError: "Une erreur inattendue s’est produite pendant la dictée.",
|
||||
},
|
||||
connectionStatus: {
|
||||
@@ -2066,13 +2065,12 @@ export const fr: TranslationResources = {
|
||||
output: "Sortie",
|
||||
},
|
||||
turnSummary: {
|
||||
details: "Voir les détails du tour",
|
||||
sentBy: "Envoyé par {{name}}",
|
||||
showMore: "Afficher plus",
|
||||
showLess: "Afficher moins",
|
||||
messages: {
|
||||
one: "{{count}} message écrit",
|
||||
other: "{{count}} messages écrits",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
thought: "Réfléchi",
|
||||
editedFiles: {
|
||||
one: "{{count}} fichier modifié",
|
||||
other: "{{count}} fichiers modifiés",
|
||||
@@ -2385,27 +2383,12 @@ export const fr: TranslationResources = {
|
||||
queue:
|
||||
"Quand l’agent est en cours d’exécution, Entrée met en file d’attente. Cmd/Ctrl+Entrée envoie.",
|
||||
},
|
||||
commandEnterDescriptions: {
|
||||
interrupt:
|
||||
"Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée interrompt. Entrée insère une nouvelle ligne.",
|
||||
steer:
|
||||
"Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée oriente le tour actif. Entrée insère une nouvelle ligne.",
|
||||
queue:
|
||||
"Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée met en file d'attente. Entrée insère une nouvelle ligne.",
|
||||
},
|
||||
options: {
|
||||
interrupt: "Interrompre",
|
||||
steer: "Réorienter",
|
||||
queue: "File d’attente",
|
||||
},
|
||||
},
|
||||
sendShortcut: {
|
||||
label: "Raccourci d’envoi",
|
||||
options: {
|
||||
enter: "Enter",
|
||||
"mod-enter": "{{modifier}}+Enter",
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
options: {
|
||||
ask: "Demander",
|
||||
|
||||
@@ -68,7 +68,6 @@ export const ja: TranslationResources = {
|
||||
daemonClientUnavailable: "デーモンクライアントが利用できません",
|
||||
daemonClientDisconnected: "デーモンクライアントが切断されています",
|
||||
noFileFound: "{{token}}のファイルが見つかりません",
|
||||
folderOutsideWorkspace: "{{path}} はこのワークスペースの外にあります",
|
||||
unexpectedDictationError: "音声入力処理中に予期しないエラーが発生しました。",
|
||||
},
|
||||
connectionStatus: {
|
||||
@@ -2039,13 +2038,12 @@ export const ja: TranslationResources = {
|
||||
output: "出力",
|
||||
},
|
||||
turnSummary: {
|
||||
details: "ターンの詳細を表示",
|
||||
sentBy: "{{name}} から送信",
|
||||
showMore: "もっと見る",
|
||||
showLess: "折りたたむ",
|
||||
messages: {
|
||||
one: "{{count}}件のメッセージを送信",
|
||||
other: "{{count}}件のメッセージを送信",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
thought: "思考",
|
||||
editedFiles: {
|
||||
one: "{{count}}個のファイルを編集",
|
||||
other: "{{count}}個のファイルを編集",
|
||||
@@ -2355,25 +2353,12 @@ export const ja: TranslationResources = {
|
||||
"エージェント実行中、Enterで現在のターンに指示を送ります。Command/Ctrl+Enterでキューに追加。",
|
||||
queue: "エージェント実行中、Enterでキューに追加します。Command/Ctrl+Enterで送信。",
|
||||
},
|
||||
commandEnterDescriptions: {
|
||||
interrupt: "エージェント実行中、{{modifier}}+Enterで中断します。Enterで改行します。",
|
||||
steer:
|
||||
"エージェント実行中、{{modifier}}+Enterで現在のターンに指示を送ります。Enterで改行します。",
|
||||
queue: "エージェント実行中、{{modifier}}+Enterでキューに追加します。Enterで改行します。",
|
||||
},
|
||||
options: {
|
||||
interrupt: "中断",
|
||||
steer: "指示",
|
||||
queue: "キュー",
|
||||
},
|
||||
},
|
||||
sendShortcut: {
|
||||
label: "送信ショートカット",
|
||||
options: {
|
||||
enter: "Enter",
|
||||
"mod-enter": "{{modifier}}+Enter",
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
options: {
|
||||
ask: "確認する",
|
||||
|
||||
@@ -67,7 +67,6 @@ export const ko: TranslationResources = {
|
||||
daemonClientUnavailable: "데몬 클라이언트를 사용할 수 없습니다",
|
||||
daemonClientDisconnected: "데몬 클라이언트 연결이 끊어졌습니다",
|
||||
noFileFound: "{{token}}에 해당하는 파일을 찾을 수 없습니다",
|
||||
folderOutsideWorkspace: "{{path}}은(는) 이 워크스페이스 밖에 있습니다",
|
||||
unexpectedDictationError: "받아쓰기를 처리하는 중 예기치 않은 오류가 발생했습니다.",
|
||||
},
|
||||
connectionStatus: {
|
||||
@@ -2029,13 +2028,12 @@ export const ko: TranslationResources = {
|
||||
output: "출력",
|
||||
},
|
||||
turnSummary: {
|
||||
details: "턴 세부 정보 보기",
|
||||
sentBy: "{{name}}이(가) 보냄",
|
||||
showMore: "더 보기",
|
||||
showLess: "접기",
|
||||
messages: {
|
||||
one: "{{count}} 메시지를 작성했습니다.",
|
||||
other: "{{count}} 메시지를 작성했습니다.",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
thought: "생각함",
|
||||
editedFiles: {
|
||||
one: "{{count}} 파일을 편집했습니다.",
|
||||
other: "{{count}} 파일을 편집했습니다.",
|
||||
@@ -2346,27 +2344,12 @@ export const ko: TranslationResources = {
|
||||
queue:
|
||||
"에이전트가 실행 중일 때 Enter는 대기열에 추가합니다. Command/Ctrl+Enter는 제출합니다.",
|
||||
},
|
||||
commandEnterDescriptions: {
|
||||
interrupt:
|
||||
"에이전트가 실행 중일 때 {{modifier}}+Enter는 중단합니다. Enter는 줄바꿈합니다.",
|
||||
steer:
|
||||
"에이전트가 실행 중일 때 {{modifier}}+Enter는 현재 턴에 지시합니다. Enter는 줄바꿈합니다.",
|
||||
queue:
|
||||
"에이전트가 실행 중일 때 {{modifier}}+Enter는 대기열에 추가합니다. Enter는 줄바꿈합니다.",
|
||||
},
|
||||
options: {
|
||||
interrupt: "중단",
|
||||
steer: "지시",
|
||||
queue: "대기열",
|
||||
},
|
||||
},
|
||||
sendShortcut: {
|
||||
label: "전송 단축키",
|
||||
options: {
|
||||
enter: "Enter",
|
||||
"mod-enter": "{{modifier}}+Enter",
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
options: {
|
||||
ask: "물어보기",
|
||||
|
||||
@@ -68,7 +68,6 @@ export const ptBR: TranslationResources = {
|
||||
daemonClientUnavailable: "Cliente do daemon indisponível",
|
||||
daemonClientDisconnected: "O cliente do daemon está desconectado",
|
||||
noFileFound: "Nenhum arquivo encontrado para {{token}}",
|
||||
folderOutsideWorkspace: "{{path}} está fora deste workspace",
|
||||
unexpectedDictationError: "Ocorreu um erro inesperado ao processar o ditado.",
|
||||
},
|
||||
connectionStatus: {
|
||||
@@ -2052,13 +2051,12 @@ export const ptBR: TranslationResources = {
|
||||
output: "Saída",
|
||||
},
|
||||
turnSummary: {
|
||||
details: "Ver detalhes do turno",
|
||||
sentBy: "Enviado por {{name}}",
|
||||
showMore: "Mostrar mais",
|
||||
showLess: "Mostrar menos",
|
||||
messages: {
|
||||
one: "escreveu {{count}} mensagem",
|
||||
other: "escreveu {{count}} mensagens",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
thought: "Pensou",
|
||||
editedFiles: {
|
||||
one: "editou {{count}} arquivo",
|
||||
other: "editou {{count}} arquivos",
|
||||
@@ -2369,27 +2367,12 @@ export const ptBR: TranslationResources = {
|
||||
"Quando o agente está em execução, Enter orienta o turno ativo. Command/Ctrl+Enter enfileira.",
|
||||
queue: "Quando o agente está em execução, Enter enfileira. Command/Ctrl+Enter envia.",
|
||||
},
|
||||
commandEnterDescriptions: {
|
||||
interrupt:
|
||||
"Quando o agente está em execução, {{modifier}}+Enter interrompe. Enter insere uma nova linha.",
|
||||
steer:
|
||||
"Quando o agente está em execução, {{modifier}}+Enter orienta o turno ativo. Enter insere uma nova linha.",
|
||||
queue:
|
||||
"Quando o agente está em execução, {{modifier}}+Enter enfileira. Enter insere uma nova linha.",
|
||||
},
|
||||
options: {
|
||||
interrupt: "Interromper",
|
||||
steer: "Orientar",
|
||||
queue: "Fila",
|
||||
},
|
||||
},
|
||||
sendShortcut: {
|
||||
label: "Atalho de envio",
|
||||
options: {
|
||||
enter: "Enter",
|
||||
"mod-enter": "{{modifier}}+Enter",
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
options: {
|
||||
ask: "Perguntar",
|
||||
|
||||
@@ -68,7 +68,6 @@ export const ru: TranslationResources = {
|
||||
daemonClientUnavailable: "Daemon клиента недоступен",
|
||||
daemonClientDisconnected: "Daemon клиента отключен",
|
||||
noFileFound: "Файл для {{token}} не найден",
|
||||
folderOutsideWorkspace: "{{path}} находится вне этого рабочего пространства",
|
||||
unexpectedDictationError: "При обработке диктовки произошла непредвиденная ошибка.",
|
||||
},
|
||||
connectionStatus: {
|
||||
@@ -2052,13 +2051,12 @@ export const ru: TranslationResources = {
|
||||
output: "Выходные данные",
|
||||
},
|
||||
turnSummary: {
|
||||
details: "Показать подробности хода",
|
||||
sentBy: "Отправлено агентом {{name}}",
|
||||
showMore: "Показать больше",
|
||||
showLess: "Свернуть",
|
||||
messages: {
|
||||
one: "написано {{count}} сообщение",
|
||||
other: "написаны сообщения ({{count}})",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
thought: "Обдумал",
|
||||
editedFiles: {
|
||||
one: "изменён {{count}} файл",
|
||||
other: "изменены файлы ({{count}})",
|
||||
@@ -2372,27 +2370,12 @@ export const ru: TranslationResources = {
|
||||
queue:
|
||||
"Когда агент работает, Enter ставит сообщение в очередь, а Command/Ctrl+Enter отправляет его.",
|
||||
},
|
||||
commandEnterDescriptions: {
|
||||
interrupt:
|
||||
"Когда агент работает, {{modifier}}+Enter прерывает его работу, а Enter вставляет новую строку.",
|
||||
steer:
|
||||
"Когда агент работает, {{modifier}}+Enter дополняет текущий ответ новым указанием, а Enter вставляет новую строку.",
|
||||
queue:
|
||||
"Когда агент работает, {{modifier}}+Enter ставит сообщение в очередь, а Enter вставляет новую строку.",
|
||||
},
|
||||
options: {
|
||||
interrupt: "Прерывать",
|
||||
steer: "Направить",
|
||||
queue: "Поставить в очередь",
|
||||
},
|
||||
},
|
||||
sendShortcut: {
|
||||
label: "Сочетание клавиш для отправки",
|
||||
options: {
|
||||
enter: "Enter",
|
||||
"mod-enter": "{{modifier}}+Enter",
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
options: {
|
||||
ask: "Спрашивать",
|
||||
|
||||
@@ -67,7 +67,6 @@ export const zhCN: TranslationResources = {
|
||||
daemonClientUnavailable: "Daemon client 不可用",
|
||||
daemonClientDisconnected: "Daemon client 已断开连接",
|
||||
noFileFound: "未找到 {{token}} 对应的文件",
|
||||
folderOutsideWorkspace: "{{path}} 不在此工作区内",
|
||||
unexpectedDictationError: "处理听写时发生意外错误。",
|
||||
},
|
||||
connectionStatus: {
|
||||
@@ -1997,13 +1996,12 @@ export const zhCN: TranslationResources = {
|
||||
output: "输出",
|
||||
},
|
||||
turnSummary: {
|
||||
details: "查看轮次详情",
|
||||
sentBy: "由 {{name}} 发送",
|
||||
showMore: "展开",
|
||||
showLess: "收起",
|
||||
messages: {
|
||||
one: "写了 {{count}} 条消息",
|
||||
other: "写了 {{count}} 条消息",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
thought: "已思考",
|
||||
editedFiles: {
|
||||
one: "编辑了 {{count}} 个文件",
|
||||
other: "编辑了 {{count}} 个文件",
|
||||
@@ -2310,24 +2308,12 @@ export const zhCN: TranslationResources = {
|
||||
steer: "Agent 运行时,Enter 会引导当前回合。Command/Ctrl+Enter 会排队。",
|
||||
queue: "Agent 运行时,Enter 会排队。Command/Ctrl+Enter 会提交。",
|
||||
},
|
||||
commandEnterDescriptions: {
|
||||
interrupt: "Agent 运行时,{{modifier}}+Enter 会中断。Enter 会插入换行。",
|
||||
steer: "Agent 运行时,{{modifier}}+Enter 会引导当前回合。Enter 会插入换行。",
|
||||
queue: "Agent 运行时,{{modifier}}+Enter 会排队。Enter 会插入换行。",
|
||||
},
|
||||
options: {
|
||||
interrupt: "中断",
|
||||
steer: "引导",
|
||||
queue: "排队",
|
||||
},
|
||||
},
|
||||
sendShortcut: {
|
||||
label: "发送快捷键",
|
||||
options: {
|
||||
enter: "Enter",
|
||||
"mod-enter": "{{modifier}}+Enter",
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
options: {
|
||||
ask: "询问",
|
||||
|
||||
@@ -306,21 +306,6 @@ async function expectLegacyWorkspaceRefresh({
|
||||
}
|
||||
|
||||
describe("ReplicaCache", () => {
|
||||
it("persists the complete newest turn beyond the cache item target", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
const items = Array.from({ length: 60 }, (_, index) => ({
|
||||
...timelineItem(`item-${index}`),
|
||||
id: `item-${index}`,
|
||||
turnId: "long-run",
|
||||
}));
|
||||
writer.commitTimeline(SERVER_ID, "agent-1", { ...timeline(), items });
|
||||
await writer.flush();
|
||||
const restored = await createCache(storage).readTimeline(SERVER_ID, "agent-1");
|
||||
expect(restored?.items.map((item) => item.id)).toEqual(items.map((item) => item.id));
|
||||
expect(restored?.range).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves workspace sorting timestamps across cache reopen", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { findMountedWindowStart } from "@/agent-stream/history-window";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AgentStatusSchema,
|
||||
@@ -767,14 +766,7 @@ function serializeTimeline(timeline: CachedTimeline): StoredTimeline | null {
|
||||
const canonicalItems = timeline.items.filter(
|
||||
(item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item),
|
||||
);
|
||||
const start = findMountedWindowStart({
|
||||
items: canonicalItems,
|
||||
minMountedCount: MAX_TIMELINE_ITEMS,
|
||||
});
|
||||
const items = canonicalItems
|
||||
.slice(start)
|
||||
.map(serializeTimelineItem)
|
||||
.filter((item) => item !== null);
|
||||
const items = canonicalItems.map(serializeTimelineItem).filter((item) => item !== null);
|
||||
const range = timeline.range;
|
||||
const canPersistCoverage =
|
||||
range !== null &&
|
||||
@@ -791,7 +783,7 @@ function serializeTimeline(timeline: CachedTimeline): StoredTimeline | null {
|
||||
canonicalItems.some((item) => item.timelineCursor?.seq === range.endSeq);
|
||||
return {
|
||||
agentId: timeline.agentId,
|
||||
items,
|
||||
items: items.slice(-MAX_TIMELINE_ITEMS),
|
||||
range: canPersistCoverage
|
||||
? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq }
|
||||
: null,
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsCard, SettingsSection, SettingsSelect } from "@/components/settings";
|
||||
import { useAppSettings, type SendShortcut, type SendBehavior } from "@/hooks/use-settings";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { useAppSettings, type SendBehavior } from "@/hooks/use-settings";
|
||||
|
||||
const SEND_BEHAVIORS: readonly SendBehavior[] = ["interrupt", "steer", "queue"];
|
||||
|
||||
export function SendingSection() {
|
||||
const { t } = useTranslation();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const modifier = getShortcutOs() === "mac" ? "⌘" : "Ctrl";
|
||||
const options = useMemo(
|
||||
() =>
|
||||
SEND_BEHAVIORS.map((value) => ({
|
||||
@@ -24,37 +20,12 @@ export function SendingSection() {
|
||||
(sendBehavior: SendBehavior) => void updateSettings({ sendBehavior }),
|
||||
[updateSettings],
|
||||
);
|
||||
const shortcutOptions = useMemo(
|
||||
() =>
|
||||
(["enter", "mod-enter"] as const).map((value) => ({
|
||||
value,
|
||||
label: t(`settings.general.sendShortcut.options.${value}`, { modifier }),
|
||||
})),
|
||||
[t, modifier],
|
||||
);
|
||||
const changeSendShortcut = useCallback(
|
||||
(sendShortcut: SendShortcut) => void updateSettings({ sendShortcut }),
|
||||
[updateSettings],
|
||||
);
|
||||
return (
|
||||
<SettingsSection title={t("settings.general.sending")}>
|
||||
<SettingsCard>
|
||||
{shortcutsAvailable ? (
|
||||
<SettingsSelect
|
||||
label={t("settings.general.sendShortcut.label")}
|
||||
value={settings.sendShortcut}
|
||||
options={shortcutOptions}
|
||||
onValueChange={changeSendShortcut}
|
||||
/>
|
||||
) : null}
|
||||
<SettingsSelect
|
||||
label={t("settings.general.defaultSend.label")}
|
||||
hint={t(
|
||||
shortcutsAvailable && settings.sendShortcut === "mod-enter"
|
||||
? `settings.general.defaultSend.commandEnterDescriptions.${settings.sendBehavior}`
|
||||
: `settings.general.defaultSend.descriptions.${settings.sendBehavior}`,
|
||||
{ modifier },
|
||||
)}
|
||||
hint={t(`settings.general.defaultSend.descriptions.${settings.sendBehavior}`)}
|
||||
value={settings.sendBehavior}
|
||||
options={options}
|
||||
onValueChange={change}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import {
|
||||
AssistantFileLinkResolverProvider,
|
||||
@@ -64,7 +63,6 @@ import { WorkspaceScriptsButton } from "@/screens/workspace/scripts";
|
||||
import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||
import { useNavigateToImportedAgent } from "@/hooks/use-import-session";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder";
|
||||
import { getOrCreateClientId } from "@/utils/client-id";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||
@@ -2181,7 +2179,8 @@ function WorkspaceScreenContent({
|
||||
const sessionState = useSessionStore.getState().sessions[normalizedServerId];
|
||||
const currentCursor = sessionState?.agentTimelineCursor.get(agentId);
|
||||
await getHostRuntimeStore().fetchAgentTimeline(normalizedServerId, agentId, {
|
||||
...planTimelineTailFetch(),
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
...(currentCursor
|
||||
? { cursor: { epoch: currentCursor.epoch, seq: currentCursor.endSeq } }
|
||||
: {}),
|
||||
@@ -3683,7 +3682,6 @@ function WorkspaceScreenContent({
|
||||
view: "files",
|
||||
});
|
||||
});
|
||||
const openMarkdownOutsideDirectory = useOpenOutsideWorkspaceFolder(normalizedServerId);
|
||||
const markdownPathOpener = useMemo(
|
||||
() =>
|
||||
createInlinePathOpener({
|
||||
@@ -3691,15 +3689,8 @@ function WorkspaceScreenContent({
|
||||
client,
|
||||
openFile: openMarkdownFile,
|
||||
openDirectory: openMarkdownDirectory,
|
||||
openOutsideDirectory: openMarkdownOutsideDirectory,
|
||||
}),
|
||||
[
|
||||
workspaceDirectory,
|
||||
client,
|
||||
openMarkdownFile,
|
||||
openMarkdownDirectory,
|
||||
openMarkdownOutsideDirectory,
|
||||
],
|
||||
[workspaceDirectory, client, openMarkdownFile, openMarkdownDirectory],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isRouteFocused) markdownPathOpener.cancel();
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { page } from "vitest/browser";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
|
||||
|
||||
interface TerminalView {
|
||||
root: HTMLDivElement;
|
||||
host: HTMLDivElement;
|
||||
runtime: TerminalEmulatorRuntime;
|
||||
terminal: Terminal;
|
||||
}
|
||||
|
||||
interface RowImage {
|
||||
width: number;
|
||||
height: number;
|
||||
pixels: Uint8ClampedArray;
|
||||
}
|
||||
|
||||
const views: TerminalView[] = [];
|
||||
const header = "HEADER_0123456789 한글 日本語";
|
||||
|
||||
function mountTerminal(left: number): TerminalView {
|
||||
const root = document.createElement("div");
|
||||
Object.assign(root.style, {
|
||||
position: "fixed",
|
||||
left: `${left}px`,
|
||||
top: "0",
|
||||
width: "440px",
|
||||
height: "360px",
|
||||
overflow: "hidden",
|
||||
});
|
||||
const host = document.createElement("div");
|
||||
Object.assign(host.style, { width: "100%", height: "100%" });
|
||||
root.appendChild(host);
|
||||
document.body.appendChild(root);
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
runtime.mount({
|
||||
root,
|
||||
host,
|
||||
initialSnapshot: null,
|
||||
scrollback: 1000,
|
||||
fontFamily: "monospace",
|
||||
fontSize: 16,
|
||||
theme: { background: "#101010", foreground: "#eeeeee", cursor: "#eeeeee" },
|
||||
});
|
||||
const terminal = window.__paseoTerminal;
|
||||
if (!terminal) throw new Error("Terminal did not mount");
|
||||
const view = { root, host, runtime, terminal };
|
||||
views.push(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
function write(view: TerminalView, text: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
view.runtime.write({ data: encodeTerminalOutput(text), onCommitted: resolve });
|
||||
});
|
||||
}
|
||||
|
||||
function webglCanvas(view: TerminalView): HTMLCanvasElement {
|
||||
for (const canvas of view.host.querySelectorAll(".xterm-screen canvas")) {
|
||||
if (canvas instanceof HTMLCanvasElement && canvas.getContext("webgl2")) return canvas;
|
||||
}
|
||||
throw new Error("Real WebGL is required; DOM fallback cannot test glyph corruption");
|
||||
}
|
||||
|
||||
async function mountPair() {
|
||||
await page.viewport(1000, 600);
|
||||
const active = mountTerminal(0);
|
||||
const sibling = mountTerminal(460);
|
||||
await document.fonts.ready;
|
||||
await expect
|
||||
.poll(() => active.host.querySelectorAll(".xterm-screen canvas").length)
|
||||
.toBeGreaterThan(0);
|
||||
await expect
|
||||
.poll(() => sibling.host.querySelectorAll(".xterm-screen canvas").length)
|
||||
.toBeGreaterThan(0);
|
||||
webglCanvas(active);
|
||||
webglCanvas(sibling);
|
||||
await write(active, "\x1b[?25l");
|
||||
await write(sibling, "\x1b[?25l");
|
||||
return { active, sibling };
|
||||
}
|
||||
|
||||
function captureHeader(input: { view: TerminalView; output?: string }): Promise<RowImage> {
|
||||
const { view, output } = input;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
rendered.dispose();
|
||||
reject(new Error("Terminal did not render within 2 seconds"));
|
||||
}, 2000);
|
||||
const rendered = view.terminal.onRender(() => {
|
||||
rendered.dispose();
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
// Copy during onRender, before WebGL discards its default drawing buffer.
|
||||
const source = webglCanvas(view);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = source.width;
|
||||
canvas.height = Math.round(source.height / view.terminal.rows);
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Canvas 2D pixel capture is unavailable");
|
||||
context.drawImage(source, 0, 0);
|
||||
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
resolve({ width: canvas.width, height: canvas.height, pixels });
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
if (output === undefined) view.terminal.refresh(0, view.terminal.rows - 1);
|
||||
else view.runtime.write({ data: encodeTerminalOutput(output) });
|
||||
});
|
||||
}
|
||||
|
||||
function expectSamePixels(input: { expected: RowImage; actual: RowImage; label: string }): void {
|
||||
const { expected, actual, label } = input;
|
||||
expect(actual.width, label).toBe(expected.width);
|
||||
expect(actual.height, label).toBe(expected.height);
|
||||
let changed = 0;
|
||||
for (let index = 0; index < expected.pixels.length; index += 1) {
|
||||
if (expected.pixels[index] !== actual.pixels[index]) changed += 1;
|
||||
}
|
||||
expect(changed, label).toBe(0);
|
||||
}
|
||||
|
||||
function coloredOutput(input: { cells: number; offset: number }): string {
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let output = "";
|
||||
for (let index = 0; index < input.cells; index += 1) {
|
||||
const value = index + input.offset;
|
||||
const foreground = `${value % 256};${(value >> 8) % 256};${(value * 13) % 256}`;
|
||||
const background = (value * 7) % 256;
|
||||
output += `\x1b[38;2;${foreground}m\x1b[48;5;${background}m${alphabet[value % alphabet.length]}`;
|
||||
}
|
||||
return `${output}\x1b[0m`;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const view of views.splice(0)) {
|
||||
view.runtime.unmount();
|
||||
view.root.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves another terminal's glyph pixels when a shared atlas is cleared", async () => {
|
||||
const { active, sibling } = await mountPair();
|
||||
await write(sibling, header);
|
||||
const before = await captureHeader({ view: sibling });
|
||||
const visibleChannels = before.pixels.filter(
|
||||
(channel, index) => index % 4 !== 3 && channel > 100,
|
||||
);
|
||||
expect(visibleChannels.length).toBeGreaterThan(100);
|
||||
|
||||
active.terminal.clearTextureAtlas();
|
||||
await captureHeader({ view: active, output: coloredOutput({ cells: 2000, offset: 0 }) });
|
||||
const after = await captureHeader({
|
||||
view: sibling,
|
||||
output: `\x1b[${sibling.terminal.rows};1Hx`,
|
||||
});
|
||||
expect(sibling.terminal.buffer.active.getLine(0)?.translateToString(true)).toBe(header);
|
||||
expectSamePixels({ expected: before, actual: after, label: "sibling after atlas clear" });
|
||||
});
|
||||
|
||||
it("preserves both headers while ordinary output grows the shared glyph atlas", async () => {
|
||||
const { active, sibling } = await mountPair();
|
||||
const cells = active.terminal.cols * (active.terminal.rows - 2);
|
||||
// Startup glyphs can stay on an unmerged page and hide this bug. Allocate the
|
||||
// reference after some output, as a real long-running TUI does.
|
||||
for (let batch = 0; batch < 16; batch += 1) {
|
||||
await captureHeader({
|
||||
view: active,
|
||||
output: `\x1b[H${coloredOutput({ cells, offset: batch * cells })}`,
|
||||
});
|
||||
}
|
||||
const lateHeader = `\x1b[H\x1b[0m\x1b[2K\x1b[38;2;253;251;247m${header}\x1b[0m`;
|
||||
await write(active, lateHeader);
|
||||
await write(sibling, lateHeader);
|
||||
const activeBefore = await captureHeader({ view: active });
|
||||
const siblingBefore = await captureHeader({ view: sibling });
|
||||
|
||||
for (let batch = 0; batch < 256; batch += 1) {
|
||||
const activeFrame = await captureHeader({
|
||||
view: active,
|
||||
output: `\x1b[2;1H${coloredOutput({ cells, offset: (batch + 16) * cells })}`,
|
||||
});
|
||||
const siblingFrame = await captureHeader({
|
||||
view: sibling,
|
||||
output: `\x1b[${sibling.terminal.rows};1H${batch % 10}`,
|
||||
});
|
||||
expect(active.terminal.buffer.active.getLine(0)?.translateToString(true)).toBe(header);
|
||||
expect(sibling.terminal.buffer.active.getLine(0)?.translateToString(true)).toBe(header);
|
||||
expectSamePixels({
|
||||
expected: activeBefore,
|
||||
actual: activeFrame,
|
||||
label: `active frame ${batch}`,
|
||||
});
|
||||
expectSamePixels({
|
||||
expected: siblingBefore,
|
||||
actual: siblingFrame,
|
||||
label: `sibling frame ${batch}`,
|
||||
});
|
||||
}
|
||||
}, 30000);
|
||||
@@ -19,66 +19,6 @@ import {
|
||||
type TimelineCursor,
|
||||
} from "./session-stream-reducers";
|
||||
|
||||
it("keeps streamed reasoning together while the initial history is loading", () => {
|
||||
let head: StreamItem[] = [];
|
||||
const chunks = ["Checking a long oper", "ation before", " the re", "sult arrives."];
|
||||
for (const [index, text] of chunks.entries()) {
|
||||
const result = processAgentStreamEvent({
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
turnId: "turn-18",
|
||||
item: { type: "reasoning", text },
|
||||
},
|
||||
seq: index + 1,
|
||||
epoch: "epoch-1",
|
||||
currentTail: [],
|
||||
currentHead: head,
|
||||
currentCursor: undefined,
|
||||
timestamp: new Date(1000 + index),
|
||||
hasAuthoritativeBaseline: false,
|
||||
});
|
||||
head = result.head;
|
||||
expect(head).toHaveLength(1);
|
||||
expect(head[0]).toMatchObject({
|
||||
kind: "thought",
|
||||
text: chunks.slice(0, index + 1).join(""),
|
||||
turnId: "turn-18",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"keeps adjacent reasoning from different turns separate (history loaded: %s)",
|
||||
(hasAuthoritativeBaseline) => {
|
||||
let head: StreamItem[] = [];
|
||||
let tail: StreamItem[] = [];
|
||||
for (const [index, turnId] of ["turn-1", "turn-2"].entries()) {
|
||||
const result = processAgentStreamEvent({
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
turnId,
|
||||
item: { type: "reasoning", text: turnId },
|
||||
},
|
||||
seq: index + 1,
|
||||
epoch: "epoch-1",
|
||||
currentTail: tail,
|
||||
currentHead: head,
|
||||
currentCursor: undefined,
|
||||
timestamp: new Date(1000),
|
||||
hasAuthoritativeBaseline,
|
||||
});
|
||||
head = result.head;
|
||||
tail = result.tail;
|
||||
}
|
||||
expect([...tail, ...head]).toMatchObject([
|
||||
{ kind: "thought", text: "turn-1", turnId: "turn-1" },
|
||||
{ kind: "thought", text: "turn-2", turnId: "turn-2" },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1615,17 +1615,19 @@ export function processAgentStreamEvent(
|
||||
unmatchedUserMessageInsert: "head",
|
||||
});
|
||||
} else {
|
||||
// Before history arrives, the overlay is one ordered stream. Reducing it
|
||||
// directly keeps consecutive reasoning chunks in the same row.
|
||||
const overlay = reduceStreamUpdate(currentHead, event, timestamp, {
|
||||
const overlay = applyStreamEvent({
|
||||
tail: currentHead,
|
||||
head: [],
|
||||
event,
|
||||
timestamp,
|
||||
source: "live",
|
||||
timelineCursor,
|
||||
});
|
||||
streamResult = {
|
||||
tail: currentTail,
|
||||
head: overlay,
|
||||
head: [...overlay.tail, ...overlay.head],
|
||||
changedTail: false,
|
||||
changedHead: overlay !== currentHead,
|
||||
changedHead: overlay.changedTail || overlay.changedHead,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -15,7 +15,7 @@ describe("requestTimelineReplacement", () => {
|
||||
await expect(requestTimelineReplacement(client, "agent-1")).rejects.toThrow("fetch complete");
|
||||
|
||||
expect(requests).toEqual([
|
||||
["agent-1", { direction: "tail", projection: "projected", wholeTurns: true, limit: 40 }],
|
||||
["agent-1", { direction: "tail", projection: "projected", limit: 40 }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,6 @@ describe("timeline sync planning", () => {
|
||||
direction: "tail",
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +26,6 @@ describe("timeline sync planning", () => {
|
||||
cursor: { epoch: "epoch-1", seq: 49 },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +37,6 @@ describe("timeline sync planning", () => {
|
||||
cursor: { epoch: "epoch-1", seq: 25 },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,7 +49,6 @@ describe("timeline sync planning", () => {
|
||||
},
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
mergeWindow: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ export interface ProjectedTimelineTailFetchPlan {
|
||||
direction: "tail";
|
||||
limit: number;
|
||||
projection: "projected";
|
||||
wholeTurns: true;
|
||||
}
|
||||
|
||||
export interface ProjectedTimelineAfterFetchPlan {
|
||||
@@ -17,7 +16,6 @@ export interface ProjectedTimelineAfterFetchPlan {
|
||||
cursor: TimelineSyncCursor;
|
||||
limit: number;
|
||||
projection: "projected";
|
||||
wholeTurns: true;
|
||||
}
|
||||
|
||||
export interface ProjectedTimelineBeforeFetchPlan {
|
||||
@@ -25,7 +23,6 @@ export interface ProjectedTimelineBeforeFetchPlan {
|
||||
cursor: TimelineSyncCursor;
|
||||
limit: number;
|
||||
projection: "projected";
|
||||
wholeTurns: true;
|
||||
}
|
||||
|
||||
export type ProjectedTimelineFetchPlan =
|
||||
@@ -43,7 +40,6 @@ export function planTimelineCatchUpAfter(cursor: TimelineSyncCursor) {
|
||||
cursor,
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -52,7 +48,6 @@ export function planTimelineTailFetch() {
|
||||
direction: "tail",
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -70,7 +65,6 @@ export function planTimelineOlderFetch(cursor: TimelineSyncCursor) {
|
||||
cursor,
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -81,7 +75,6 @@ export function planTimelinePromptJump(target: TimelineSyncCursor) {
|
||||
cursor: { epoch: target.epoch, seq: target.seq + newerRows + 1 },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
mergeWindow: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("deriveStreamTurnTiming", () => {
|
||||
});
|
||||
|
||||
assert.equal(timing.runningStartedAt, secondUserAt);
|
||||
assert.equal(timing.byItemId.has("a2"), false);
|
||||
assert.equal(timing.byAssistantId.has("a2"), false);
|
||||
});
|
||||
|
||||
it("derives completed turn timing from user and assistant item timestamps", () => {
|
||||
@@ -69,7 +69,7 @@ describe("deriveStreamTurnTiming", () => {
|
||||
head: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(timing.byItemId.get("a1"), {
|
||||
assert.deepEqual(timing.byAssistantId.get("a1"), {
|
||||
completedAt: assistantAt,
|
||||
durationMs: 7000,
|
||||
});
|
||||
@@ -95,8 +95,8 @@ describe("deriveStreamTurnTiming", () => {
|
||||
completedAt: lastAssistantAt,
|
||||
durationMs: 7000,
|
||||
};
|
||||
assert.deepEqual(timing.byItemId.get("a1"), expected);
|
||||
assert.deepEqual(timing.byItemId.get("a2"), expected);
|
||||
assert.deepEqual(timing.byAssistantId.get("a1"), expected);
|
||||
assert.deepEqual(timing.byAssistantId.get("a2"), expected);
|
||||
});
|
||||
|
||||
it("preserves the completion timestamp when a canonical turn has no visible prompt", () => {
|
||||
@@ -120,7 +120,7 @@ describe("deriveStreamTurnTiming", () => {
|
||||
head: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(timing.byItemId.get("hidden-prompt-a2"), {
|
||||
assert.deepEqual(timing.byAssistantId.get("hidden-prompt-a2"), {
|
||||
completedAt: hiddenPromptTurnAt,
|
||||
durationMs: null,
|
||||
});
|
||||
@@ -143,12 +143,12 @@ describe("deriveStreamTurnTiming", () => {
|
||||
const first = derive("one", "2026-05-15T00:01:04.000Z");
|
||||
const second = derive("one two", "2026-05-15T00:01:05.000Z");
|
||||
|
||||
assert.equal(second.byItemId, first.byItemId);
|
||||
assert.deepEqual(first.byItemId.get("a1"), {
|
||||
assert.equal(second.byAssistantId, first.byAssistantId);
|
||||
assert.deepEqual(first.byAssistantId.get("a1"), {
|
||||
completedAt: tail[1]?.timestamp,
|
||||
durationMs: 7000,
|
||||
});
|
||||
assert.equal(first.byItemId.has("a2"), false);
|
||||
assert.equal(first.byAssistantId.has("a2"), false);
|
||||
});
|
||||
|
||||
it("keeps the timing map identity when the head closed the tail's open turn", () => {
|
||||
@@ -168,8 +168,8 @@ describe("deriveStreamTurnTiming", () => {
|
||||
const first = derive("one", "2026-05-15T00:01:04.000Z");
|
||||
const second = derive("one two", "2026-05-15T00:01:05.000Z");
|
||||
|
||||
assert.equal(second.byItemId, first.byItemId);
|
||||
assert.deepEqual(first.byItemId.get("a1"), {
|
||||
assert.equal(second.byAssistantId, first.byAssistantId);
|
||||
assert.deepEqual(first.byAssistantId.get("a1"), {
|
||||
completedAt: lastTailAt,
|
||||
durationMs: 7000,
|
||||
});
|
||||
@@ -193,9 +193,9 @@ describe("deriveStreamTurnTiming", () => {
|
||||
head,
|
||||
});
|
||||
|
||||
assert.notEqual(completed.byItemId, active.byItemId);
|
||||
assert.equal(active.byItemId.has("a1"), false);
|
||||
assert.deepEqual(completed.byItemId.get("a1"), { completedAt, durationMs: 9000 });
|
||||
assert.notEqual(completed.byAssistantId, active.byAssistantId);
|
||||
assert.equal(active.byAssistantId.has("a1"), false);
|
||||
assert.deepEqual(completed.byAssistantId.get("a1"), { completedAt, durationMs: 9000 });
|
||||
});
|
||||
|
||||
it("derives fresh timing when the tail itself changes", () => {
|
||||
@@ -211,39 +211,11 @@ describe("deriveStreamTurnTiming", () => {
|
||||
const first = derive(firstTail);
|
||||
const second = derive(secondTail);
|
||||
|
||||
assert.notEqual(second.byItemId, first.byItemId);
|
||||
assert.equal(first.byItemId.has("a2"), false);
|
||||
assert.deepEqual(second.byItemId.get("a2"), {
|
||||
assert.notEqual(second.byAssistantId, first.byAssistantId);
|
||||
assert.equal(first.byAssistantId.has("a2"), false);
|
||||
assert.deepEqual(second.byAssistantId.get("a2"), {
|
||||
completedAt: secondTail[3]?.timestamp,
|
||||
durationMs: 3000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("times a heartbeat that ends with tools and no assistant text", () => {
|
||||
const trigger: StreamItem = {
|
||||
kind: "tool_call",
|
||||
id: "trigger",
|
||||
timestamp: new Date("2026-05-15T00:00:00Z"),
|
||||
turnId: "heartbeat",
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
provider: "claude",
|
||||
callId: "trigger",
|
||||
name: "heartbeat",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "plain_text", text: "Check progress" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const tool: StreamItem = { ...trigger, id: "last", timestamp: new Date("2026-05-15T00:00:05Z") };
|
||||
const timing = deriveStreamTurnTiming({
|
||||
tail: [trigger, tool],
|
||||
head: [],
|
||||
isTurnActive: false,
|
||||
activeTurnStartedAt: null,
|
||||
});
|
||||
assert.equal(timing.byItemId.get("last")?.durationMs, 5000);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { startsNewTurn, isTurnTrigger } from "@/agent-stream/turn-membership";
|
||||
import { startsNewTurn } from "@/agent-stream/turn-membership";
|
||||
|
||||
export interface TurnTiming {
|
||||
completedAt: Date;
|
||||
@@ -7,7 +7,7 @@ export interface TurnTiming {
|
||||
}
|
||||
|
||||
export interface StreamTurnTiming {
|
||||
byItemId: Map<string, TurnTiming>;
|
||||
byAssistantId: Map<string, TurnTiming>;
|
||||
runningStartedAt: Date | null;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ interface OpenTurn {
|
||||
|
||||
interface HeadTurnTiming {
|
||||
additions: Map<string, TurnTiming>;
|
||||
byItemId: Map<string, TurnTiming>;
|
||||
byAssistantId: Map<string, TurnTiming>;
|
||||
}
|
||||
|
||||
interface TailTurnTiming {
|
||||
@@ -37,18 +37,14 @@ const EMPTY_OPEN_TURN: OpenTurn = {
|
||||
};
|
||||
|
||||
function closeTurn(turn: OpenTurn, into: Map<string, TurnTiming>): void {
|
||||
if (!turn.lastItemAt) {
|
||||
if (!turn.lastItemAt || turn.assistantIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const timing: TurnTiming = {
|
||||
completedAt: turn.lastItemAt,
|
||||
durationMs: turn.userAt ? Math.max(0, turn.lastItemAt.getTime() - turn.userAt.getTime()) : null,
|
||||
};
|
||||
let footerIds = turn.assistantIds;
|
||||
if (footerIds.length === 0 && turn.previousItem?.kind === "tool_call") {
|
||||
footerIds = [turn.previousItem.id];
|
||||
}
|
||||
for (const id of footerIds) {
|
||||
for (const id of turn.assistantIds) {
|
||||
into.set(id, timing);
|
||||
}
|
||||
}
|
||||
@@ -65,7 +61,7 @@ function walkTurns(
|
||||
for (const item of items) {
|
||||
if (startsNewTurn(item, previousItem)) {
|
||||
closeTurn({ userAt, lastItemAt, assistantIds, previousItem }, into);
|
||||
userAt = isTurnTrigger(item) ? item.timestamp : null;
|
||||
userAt = item.kind === "user_message" ? item.timestamp : null;
|
||||
lastItemAt = null;
|
||||
assistantIds = [];
|
||||
}
|
||||
@@ -112,7 +108,7 @@ function getTailTurnTiming(tail: StreamItem[]): TailTurnTiming {
|
||||
return timing;
|
||||
}
|
||||
|
||||
function resolveByItemId(
|
||||
function resolveByAssistantId(
|
||||
tail: TailTurnTiming,
|
||||
additions: Map<string, TurnTiming>,
|
||||
): Map<string, TurnTiming> {
|
||||
@@ -120,14 +116,14 @@ function resolveByItemId(
|
||||
return tail.completed;
|
||||
}
|
||||
if (tail.lastHead && areTimingsEqual(tail.lastHead.additions, additions)) {
|
||||
return tail.lastHead.byItemId;
|
||||
return tail.lastHead.byAssistantId;
|
||||
}
|
||||
const byItemId = new Map(tail.completed);
|
||||
const byAssistantId = new Map(tail.completed);
|
||||
for (const [id, timing] of additions) {
|
||||
byItemId.set(id, timing);
|
||||
byAssistantId.set(id, timing);
|
||||
}
|
||||
tail.lastHead = { additions, byItemId };
|
||||
return byItemId;
|
||||
tail.lastHead = { additions, byAssistantId };
|
||||
return byAssistantId;
|
||||
}
|
||||
|
||||
export function deriveStreamTurnTiming(params: {
|
||||
@@ -143,7 +139,7 @@ export function deriveStreamTurnTiming(params: {
|
||||
closeTurn(openTurn, additions);
|
||||
}
|
||||
return {
|
||||
byItemId: resolveByItemId(tail, additions),
|
||||
byAssistantId: resolveByAssistantId(tail, additions),
|
||||
runningStartedAt: params.isTurnActive ? params.activeTurnStartedAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -189,7 +189,6 @@ class TimelineWorld {
|
||||
direction: "tail",
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
});
|
||||
} finally {
|
||||
this.forcedTimelineTailReplacements.delete(agentId);
|
||||
@@ -342,12 +341,7 @@ test("uses a tail fetch when an agent becomes visible", async () => {
|
||||
membership.succeed();
|
||||
|
||||
const fetch = await world.nextFetch("agent-a");
|
||||
expect(fetch.request).toEqual({
|
||||
direction: "tail",
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
});
|
||||
expect(fetch.request).toEqual({ direction: "tail", limit: 40, projection: "projected" });
|
||||
fetch.respond({ hasNewer: false });
|
||||
});
|
||||
|
||||
@@ -372,7 +366,6 @@ test("loads the cache before choosing the authoritative network request", async
|
||||
cursor: { epoch: "cached-epoch", seq: 17 },
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
});
|
||||
fetch.respond({ hasNewer: false });
|
||||
});
|
||||
@@ -391,7 +384,6 @@ test("catches up after the restored cursor when an agent becomes visible", async
|
||||
cursor: { epoch: "epoch-agent-a", seq: 42 },
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
});
|
||||
fetch.respond({ hasNewer: false });
|
||||
});
|
||||
@@ -409,12 +401,7 @@ test("falls back to the latest tail when a restored cursor has more than one cat
|
||||
probe.respond({ hasNewer: true, seq: 82 });
|
||||
|
||||
const fallback = await world.nextFetch("agent-a");
|
||||
expect(fallback.request).toEqual({
|
||||
direction: "tail",
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
});
|
||||
expect(fallback.request).toEqual({ direction: "tail", limit: 40, projection: "projected" });
|
||||
expect(
|
||||
world.applyTimelineResponse({
|
||||
requestId: "fallback-tail",
|
||||
@@ -457,7 +444,6 @@ test("a gap absorbed by a running tail is recovered after the tail completes", a
|
||||
cursor: { epoch: "epoch-agent-a", seq: 9 },
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
});
|
||||
recovery.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
@@ -541,7 +527,6 @@ test("unchanged visible-set publication does not cancel paged catch-up", async (
|
||||
cursor: { epoch: "epoch-agent-a", seq: 5 },
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
});
|
||||
world.expectNoPendingMembership();
|
||||
});
|
||||
@@ -912,14 +897,12 @@ test("gap recovery supersedes completed catch-up and pages through the current t
|
||||
cursor: { epoch: "epoch-agent-a", seq: 10 },
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
},
|
||||
{
|
||||
direction: "after",
|
||||
cursor: { epoch: "epoch-agent-a", seq: 15 },
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { isTurnTrigger, isTurnBoundary } from "@/agent-stream/turn-membership";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import type { StreamItem, ToolCallItem, ThoughtItem } from "@/types/stream";
|
||||
import type { StreamItem, ToolCallItem } from "@/types/stream";
|
||||
|
||||
export interface ToolCallDescriptor {
|
||||
detail: ToolCallDetail;
|
||||
@@ -10,19 +9,17 @@ export interface ToolCallDescriptor {
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type GroupableItem = ToolCallItem | ThoughtItem;
|
||||
|
||||
export interface ToolCallRun {
|
||||
id: string;
|
||||
calls: readonly GroupableItem[];
|
||||
latest: GroupableItem;
|
||||
calls: readonly ToolCallItem[];
|
||||
latest: ToolCallItem;
|
||||
isSealed: boolean;
|
||||
}
|
||||
|
||||
export interface GroupedHistory<TGroup> {
|
||||
tail: StreamItem[];
|
||||
groupsByHostId: Map<string, TGroup>;
|
||||
pendingCalls: readonly GroupableItem[];
|
||||
pendingCalls: readonly ToolCallItem[];
|
||||
}
|
||||
|
||||
export interface GroupedToolCalls<TGroup> {
|
||||
@@ -65,19 +62,15 @@ export function describeToolCall(item: ToolCallItem): ToolCallDescriptor {
|
||||
};
|
||||
}
|
||||
|
||||
export function isGroupableToolCall(
|
||||
item: StreamItem,
|
||||
autoExpandReasoning = false,
|
||||
): item is GroupableItem {
|
||||
if (item.kind === "thought") return !autoExpandReasoning;
|
||||
if (item.kind !== "tool_call" || isTurnTrigger(item)) {
|
||||
export function isGroupableToolCall(item: StreamItem): item is ToolCallItem {
|
||||
if (item.kind !== "tool_call") {
|
||||
return false;
|
||||
}
|
||||
const descriptor = describeToolCall(item);
|
||||
return descriptor.detail.type !== "plan" && descriptor.name.trim().toLowerCase() !== "speak";
|
||||
}
|
||||
|
||||
function createRun(calls: readonly GroupableItem[], isSealed: boolean): ToolCallRun {
|
||||
function createRun(calls: readonly ToolCallItem[], isSealed: boolean): ToolCallRun {
|
||||
const first = calls[0];
|
||||
const latest = calls.at(-1);
|
||||
if (!first || !latest) {
|
||||
@@ -86,21 +79,20 @@ function createRun(calls: readonly GroupableItem[], isSealed: boolean): ToolCall
|
||||
return { id: first.id, calls, latest, isSealed };
|
||||
}
|
||||
|
||||
function createHost(run: ToolCallRun): GroupableItem {
|
||||
function createHost(run: ToolCallRun): ToolCallItem {
|
||||
if (run.calls.length === 1) {
|
||||
return run.latest;
|
||||
}
|
||||
return { ...run.latest, id: run.id };
|
||||
}
|
||||
|
||||
function isRunning(call: GroupableItem): boolean {
|
||||
if (call.kind === "thought") return call.status !== "ready";
|
||||
function isRunning(call: ToolCallItem): boolean {
|
||||
const status = describeToolCall(call).status;
|
||||
return status === "running" || status === "executing";
|
||||
}
|
||||
|
||||
function appendRun<TGroup>(input: {
|
||||
calls: readonly GroupableItem[];
|
||||
calls: readonly ToolCallItem[];
|
||||
isSealed: boolean;
|
||||
output: StreamItem[];
|
||||
groups: Map<string, TGroup>;
|
||||
@@ -118,17 +110,13 @@ function appendRun<TGroup>(input: {
|
||||
export function prepareGroupedHistory<TGroup>(input: {
|
||||
tail: StreamItem[];
|
||||
buildGroup: (run: ToolCallRun) => TGroup;
|
||||
autoExpandReasoning?: boolean;
|
||||
}): GroupedHistory<TGroup> {
|
||||
const output: StreamItem[] = [];
|
||||
const groups = new Map<string, TGroup>();
|
||||
let pending: GroupableItem[] = [];
|
||||
let pending: ToolCallItem[] = [];
|
||||
|
||||
for (const item of input.tail) {
|
||||
if (
|
||||
isGroupableToolCall(item, input.autoExpandReasoning) &&
|
||||
!isTurnBoundary(pending.at(-1) ?? null, item)
|
||||
) {
|
||||
if (isGroupableToolCall(item)) {
|
||||
pending.push(item);
|
||||
continue;
|
||||
}
|
||||
@@ -140,8 +128,7 @@ export function prepareGroupedHistory<TGroup>(input: {
|
||||
buildGroup: input.buildGroup,
|
||||
});
|
||||
pending = [];
|
||||
if (isGroupableToolCall(item, input.autoExpandReasoning)) pending.push(item);
|
||||
else output.push(item);
|
||||
output.push(item);
|
||||
}
|
||||
|
||||
appendRun({
|
||||
@@ -164,7 +151,6 @@ export function groupLiveToolCalls<TGroup>(input: {
|
||||
head: StreamItem[];
|
||||
isTurnActive: boolean;
|
||||
buildGroup: (run: ToolCallRun) => TGroup;
|
||||
autoExpandReasoning?: boolean;
|
||||
}): GroupedToolCalls<TGroup> {
|
||||
const head: StreamItem[] = [];
|
||||
const liveGroups = new Map<string, TGroup>();
|
||||
@@ -189,8 +175,7 @@ export function groupLiveToolCalls<TGroup>(input: {
|
||||
};
|
||||
|
||||
for (const item of input.head) {
|
||||
if (isTurnBoundary(pending.at(-1) ?? null, item)) flush(true);
|
||||
if (isGroupableToolCall(item, input.autoExpandReasoning)) {
|
||||
if (isGroupableToolCall(item)) {
|
||||
if (pending.length === 0) {
|
||||
hostPlacement = "head";
|
||||
}
|
||||
|
||||
@@ -33,18 +33,13 @@ export function useOverviewSummaryLabel(
|
||||
[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 }));
|
||||
}
|
||||
}
|
||||
for (const [action, count] of Object.entries(summary.paseoActions)) {
|
||||
parts.push(
|
||||
count === 1 ? action.toLocaleLowerCase() : `${action.toLocaleLowerCase()} ×${count}`,
|
||||
);
|
||||
}
|
||||
if (summary.thoughtCount > 0) parts.push(t("toolCallGroup.thought").toLocaleLowerCase());
|
||||
return joinSummaryParts(parts, t("toolCallGroup.and"));
|
||||
}, [leadingParts, summary, t]);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { getPaseoToolActionLabel } from "@getpaseo/protocol/paseo-tool-presentation/index";
|
||||
import { isTurnTrigger } from "@/agent-stream/turn-membership";
|
||||
|
||||
import { describeToolCall, type GroupableItem, type ToolCallRun } from "../grouping";
|
||||
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_";
|
||||
const DIRECT_SEARCH_TOOL_SUFFIX_PATTERN = /(?:^|[_.:/])(?:web_search|llm_context)$/;
|
||||
|
||||
export interface OverviewSummary {
|
||||
thoughtCount: number;
|
||||
editedFileCount: number;
|
||||
commandCount: number;
|
||||
readFileCount: number;
|
||||
searchCount: number;
|
||||
otherToolCount: number;
|
||||
paseoActions: Record<string, number>;
|
||||
paseoCallCount: number;
|
||||
}
|
||||
|
||||
export interface OverviewToolCallGroup {
|
||||
@@ -22,31 +21,28 @@ export interface OverviewToolCallGroup {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function isPaseoCall(name: string, normalizedName: string): boolean {
|
||||
return isPaseoToolName(name) || normalizedName.startsWith(DIRECT_PASEO_TOOL_PREFIX);
|
||||
}
|
||||
|
||||
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 GroupableItem[]): OverviewSummary {
|
||||
export function summarizeToolCalls(calls: readonly ToolCallItem[]): OverviewSummary {
|
||||
const editedFiles = new Set<string>();
|
||||
const readFiles = new Set<string>();
|
||||
let thoughtCount = 0;
|
||||
let commandCount = 0;
|
||||
let searchCount = 0;
|
||||
let otherToolCount = 0;
|
||||
const paseoActions: Record<string, number> = {};
|
||||
let paseoCallCount = 0;
|
||||
|
||||
for (const call of calls) {
|
||||
if (call.kind === "thought") {
|
||||
thoughtCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (isTurnTrigger(call)) continue;
|
||||
const descriptor = describeToolCall(call);
|
||||
const normalizedName = descriptor.name.trim().toLowerCase();
|
||||
const action = getPaseoToolActionLabel(descriptor.name);
|
||||
if (action) {
|
||||
paseoActions[action] = (paseoActions[action] ?? 0) + 1;
|
||||
if (isPaseoCall(descriptor.name, normalizedName)) {
|
||||
paseoCallCount += 1;
|
||||
} else if (descriptor.detail.type === "edit" || descriptor.detail.type === "write") {
|
||||
editedFiles.add(descriptor.detail.filePath);
|
||||
} else if (descriptor.detail.type === "shell") {
|
||||
@@ -61,19 +57,17 @@ export function summarizeToolCalls(calls: readonly GroupableItem[]): OverviewSum
|
||||
}
|
||||
|
||||
return {
|
||||
thoughtCount,
|
||||
editedFileCount: editedFiles.size,
|
||||
commandCount,
|
||||
readFileCount: readFiles.size,
|
||||
searchCount,
|
||||
otherToolCount,
|
||||
paseoActions,
|
||||
paseoCallCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup {
|
||||
const isLoading = run.calls.some((call) => {
|
||||
if (call.kind === "thought") return call.status !== "ready";
|
||||
const status = describeToolCall(call).status;
|
||||
return status === "running" || status === "executing";
|
||||
});
|
||||
|
||||
@@ -255,13 +255,12 @@ describe("tool call detail-level projection", () => {
|
||||
run: expect.any(Object),
|
||||
isLoading: false,
|
||||
summary: {
|
||||
thoughtCount: 0,
|
||||
editedFileCount: 1,
|
||||
commandCount: 1,
|
||||
readFileCount: 2,
|
||||
searchCount: 0,
|
||||
otherToolCount: 0,
|
||||
paseoActions: {},
|
||||
paseoCallCount: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -329,7 +328,7 @@ describe("tool call detail-level projection", () => {
|
||||
const result = project({ level: "overview", head: calls });
|
||||
|
||||
expect(result.groupsByHostId.get("1")).toMatchObject({
|
||||
summary: { otherToolCount: 2, paseoActions: { "Listed agents": 1, "List worktrees": 1 } },
|
||||
summary: { otherToolCount: 2, paseoCallCount: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -347,11 +346,7 @@ describe("tool call detail-level projection", () => {
|
||||
const result = project({ level: "overview", head: calls });
|
||||
|
||||
expect(result.groupsByHostId.get("1")).toMatchObject({
|
||||
summary: {
|
||||
searchCount: 3,
|
||||
otherToolCount: 0,
|
||||
paseoActions: { "Listed providers": 1, "List worktrees": 2 },
|
||||
},
|
||||
summary: { searchCount: 3, otherToolCount: 0, paseoCallCount: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -485,56 +480,3 @@ describe("tool call detail-level projection", () => {
|
||||
expect(result.groupsByHostId.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a heartbeat trigger visible outside grouped work", () => {
|
||||
const trigger = toolCall(
|
||||
"1",
|
||||
{ type: "plain_text", text: "Check progress" },
|
||||
{ name: "heartbeat" },
|
||||
);
|
||||
const work = toolCall("2", { type: "shell", command: "pwd" });
|
||||
const result = project({ level: "overview", tail: [trigger, work] });
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["1", "2"]);
|
||||
expect(result.groupsByHostId.has("1")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not merge tools from different canonical turns", () => {
|
||||
const first = toolCall("1", { type: "shell", command: "pwd" });
|
||||
first.turnId = "first";
|
||||
const second = toolCall("2", { type: "shell", command: "ls" });
|
||||
second.turnId = "second";
|
||||
const result = project({ level: "overview", tail: [first, second] });
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["1", "2"]);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"handles streaming reasoning across retained history (Expand thinking %s)",
|
||||
(autoExpandReasoning) => {
|
||||
const call = { ...toolCall("1", { type: "shell", command: "echo ok" }), turnId: "run" };
|
||||
const thought: StreamItem = {
|
||||
kind: "thought",
|
||||
id: "reasoning",
|
||||
text: "Checking",
|
||||
status: "loading",
|
||||
timestamp: new Date(),
|
||||
turnId: "run",
|
||||
};
|
||||
const tail = [call];
|
||||
const result = project({
|
||||
level: "overview",
|
||||
tail,
|
||||
head: [thought],
|
||||
isTurnActive: true,
|
||||
preparedHistory: prepareToolCallHistory("overview", tail, autoExpandReasoning),
|
||||
});
|
||||
expect(result.head).toEqual(autoExpandReasoning ? [thought] : []);
|
||||
expect(result.groupsByHostId.get(call.id)?.run.calls).toEqual(
|
||||
autoExpandReasoning ? [call] : [call, thought],
|
||||
);
|
||||
expect(result.groupsByHostId.get(call.id)?.summary).toMatchObject({
|
||||
commandCount: 1,
|
||||
otherToolCount: 0,
|
||||
thoughtCount: autoExpandReasoning ? 0 : 1,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -13,7 +13,6 @@ export type ToolCallDetailGroup = OverviewToolCallGroup;
|
||||
|
||||
export interface PreparedToolCallHistory {
|
||||
mode: "overview";
|
||||
autoExpandReasoning: boolean;
|
||||
grouped: GroupedHistory<ToolCallDetailGroup>;
|
||||
}
|
||||
|
||||
@@ -42,18 +41,15 @@ function visibleToolCallItems(items: StreamItem[]): StreamItem[] {
|
||||
export function prepareToolCallHistory(
|
||||
level: ToolCallDetailLevel,
|
||||
tail: StreamItem[],
|
||||
autoExpandReasoning = false,
|
||||
): PreparedToolCallHistory | null {
|
||||
if (level === "detailed") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
mode: "overview",
|
||||
autoExpandReasoning,
|
||||
grouped: prepareGroupedHistory({
|
||||
tail: visibleToolCallItems(tail),
|
||||
buildGroup: buildOverviewGroup,
|
||||
autoExpandReasoning,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -78,7 +74,6 @@ export function projectToolCallDetailLevel(input: {
|
||||
}
|
||||
return groupLiveToolCalls({
|
||||
history: input.preparedHistory.grouped,
|
||||
autoExpandReasoning: input.preparedHistory.autoExpandReasoning,
|
||||
head: visibleToolCallItems(input.head),
|
||||
isTurnActive: input.isTurnActive,
|
||||
buildGroup: buildOverviewGroup,
|
||||
|
||||
@@ -661,82 +661,6 @@ describe("stream reducer canonical tool calls", () => {
|
||||
expect(new Set(tools.map((tool) => tool.id)).size).toBe(2);
|
||||
});
|
||||
|
||||
it.each(["live", "canonical"] as const)(
|
||||
"keeps current tool identity during %s updates when an older turn reports late",
|
||||
(source) => {
|
||||
let state: StreamItem[] = [];
|
||||
const events = [
|
||||
{ callId: "background", turnId: "codex-turn-17", status: "running" as const },
|
||||
{ callId: "exec-current", turnId: "codex-turn-18", status: "running" as const },
|
||||
{ callId: "background", turnId: "codex-turn-17", status: "completed" as const },
|
||||
{ callId: "exec-current", turnId: "codex-turn-18", status: "completed" as const },
|
||||
];
|
||||
for (const [index, item] of events.entries()) {
|
||||
state = reduceStreamUpdate(
|
||||
state,
|
||||
canonicalToolTimeline({
|
||||
provider: "codex",
|
||||
name: "exec_command",
|
||||
...item,
|
||||
}),
|
||||
new Date(index * 1000),
|
||||
{ source },
|
||||
);
|
||||
}
|
||||
expect(
|
||||
state.filter(isAgentToolCallItem).map((item) => ({
|
||||
id: item.id,
|
||||
turnId: item.turnId,
|
||||
status: item.payload.data.status,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
id: "agent_tool_turn:codex-turn-17/background",
|
||||
turnId: "codex-turn-17",
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
id: "agent_tool_turn:codex-turn-18/exec-current",
|
||||
turnId: "codex-turn-18",
|
||||
status: "completed",
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not move reasoning into an older turn when a late tool update finalizes it", () => {
|
||||
const background = canonicalToolTimeline({
|
||||
provider: "codex",
|
||||
name: "exec_command",
|
||||
callId: "background",
|
||||
turnId: "old",
|
||||
status: "running",
|
||||
});
|
||||
let state = reduceStreamUpdate([], background, new Date(0));
|
||||
state = reduceStreamUpdate(
|
||||
state,
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
turnId: "current",
|
||||
item: { type: "reasoning", text: "Working on the current request" },
|
||||
},
|
||||
new Date(1),
|
||||
);
|
||||
state = reduceStreamUpdate(
|
||||
state,
|
||||
canonicalToolTimeline({
|
||||
provider: "codex",
|
||||
name: "exec_command",
|
||||
callId: "background",
|
||||
turnId: "old",
|
||||
status: "completed",
|
||||
}),
|
||||
new Date(2),
|
||||
);
|
||||
expect(state.at(-1)).toMatchObject({ kind: "thought", turnId: "current", status: "ready" });
|
||||
});
|
||||
|
||||
it("merges lifecycle updates for the same call occurrence", () => {
|
||||
const callId = "tool-updated-within-turn";
|
||||
const turnId = "autonomous-turn-1";
|
||||
|
||||
@@ -91,8 +91,6 @@ export type StreamItem =
|
||||
export type UserMessageImageAttachment = AttachmentMetadata;
|
||||
|
||||
export interface UserMessageItem {
|
||||
/** Attribution added by presentation for received agent messages. */
|
||||
sender?: AgentMessage["sender"];
|
||||
kind: "user_message";
|
||||
id: string;
|
||||
clientMessageId?: string;
|
||||
@@ -988,7 +986,6 @@ function appendThought(
|
||||
text: string,
|
||||
timestamp: Date,
|
||||
timelineCursor?: TimelinePosition,
|
||||
turnId?: string,
|
||||
): StreamItem[] {
|
||||
const { chunk, hasContent } = normalizeChunk(text);
|
||||
if (!chunk) {
|
||||
@@ -996,7 +993,7 @@ function appendThought(
|
||||
}
|
||||
|
||||
const last = state[state.length - 1];
|
||||
if (last && last.kind === "thought" && last.turnId === turnId) {
|
||||
if (last && last.kind === "thought") {
|
||||
const updated: ThoughtItem = {
|
||||
...last,
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
@@ -1514,30 +1511,36 @@ function reduceTimelineEvent(
|
||||
const item = event.item;
|
||||
switch (item.type) {
|
||||
case "user_message":
|
||||
return appendUserMessage(
|
||||
state,
|
||||
item.text,
|
||||
timestamp,
|
||||
source,
|
||||
item.messageId,
|
||||
item.clientMessageId,
|
||||
timelineCursor,
|
||||
event.turnId,
|
||||
return finalizeActiveThoughts(
|
||||
appendUserMessage(
|
||||
state,
|
||||
item.text,
|
||||
timestamp,
|
||||
source,
|
||||
item.messageId,
|
||||
item.clientMessageId,
|
||||
timelineCursor,
|
||||
event.turnId,
|
||||
),
|
||||
);
|
||||
case "assistant_message":
|
||||
return appendAssistantMessage(
|
||||
state,
|
||||
item.text,
|
||||
timestamp,
|
||||
source,
|
||||
item.messageId,
|
||||
reservedItemIds,
|
||||
timelineCursor,
|
||||
return finalizeActiveThoughts(
|
||||
appendAssistantMessage(
|
||||
state,
|
||||
item.text,
|
||||
timestamp,
|
||||
source,
|
||||
item.messageId,
|
||||
reservedItemIds,
|
||||
timelineCursor,
|
||||
),
|
||||
);
|
||||
case "reasoning":
|
||||
return appendThought(state, item.text, timestamp, timelineCursor, event.turnId);
|
||||
return appendThought(state, item.text, timestamp, timelineCursor);
|
||||
case "tool_call":
|
||||
return reduceTimelineToolCall(state, event, item, timestamp, timelineCursor);
|
||||
return finalizeActiveThoughts(
|
||||
reduceTimelineToolCall(state, event, item, timestamp, timelineCursor),
|
||||
);
|
||||
case "todo": {
|
||||
const items: TodoEntry[] = (item.items ?? []).map((todo) => ({
|
||||
text: todo.text,
|
||||
@@ -1546,7 +1549,9 @@ function reduceTimelineEvent(
|
||||
status: todo.status,
|
||||
activeForm: todo.activeForm,
|
||||
}));
|
||||
return appendTodoList(state, event.provider, items, timestamp, timelineCursor);
|
||||
return finalizeActiveThoughts(
|
||||
appendTodoList(state, event.provider, items, timestamp, timelineCursor),
|
||||
);
|
||||
}
|
||||
case "error": {
|
||||
const notification: NotificationItem = {
|
||||
@@ -1558,7 +1563,7 @@ function reduceTimelineEvent(
|
||||
level: "error",
|
||||
message: item.message ?? "Unknown error",
|
||||
};
|
||||
return appendNotification(state, notification);
|
||||
return finalizeActiveThoughts(appendNotification(state, notification));
|
||||
}
|
||||
case "notification": {
|
||||
const notification: NotificationItem = {
|
||||
@@ -1570,12 +1575,16 @@ function reduceTimelineEvent(
|
||||
level: item.level,
|
||||
message: item.message,
|
||||
};
|
||||
return appendNotification(state, notification);
|
||||
return finalizeActiveThoughts(appendNotification(state, notification));
|
||||
}
|
||||
case "compaction":
|
||||
return reduceTimelineCompaction(state, item, timestamp, timelineCursor);
|
||||
return finalizeActiveThoughts(
|
||||
reduceTimelineCompaction(state, item, timestamp, timelineCursor),
|
||||
);
|
||||
case "plugin":
|
||||
return appendPluginTimelineItem(state, item, timestamp, timelineCursor);
|
||||
return finalizeActiveThoughts(
|
||||
appendPluginTimelineItem(state, item, timestamp, timelineCursor),
|
||||
);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -1592,9 +1601,8 @@ export function reduceStreamUpdate(
|
||||
): StreamItem[] {
|
||||
const source = options?.source ?? "live";
|
||||
switch (event.type) {
|
||||
case "timeline": {
|
||||
const updated = applyTimelineTurnId(
|
||||
state,
|
||||
case "timeline":
|
||||
return applyTimelineTurnId(
|
||||
reduceTimelineEvent(
|
||||
state,
|
||||
event,
|
||||
@@ -1605,8 +1613,6 @@ export function reduceStreamUpdate(
|
||||
),
|
||||
event,
|
||||
);
|
||||
return event.item.type === "reasoning" ? updated : finalizeActiveThoughts(updated);
|
||||
}
|
||||
case "thread_started":
|
||||
case "turn_started":
|
||||
case "turn_completed":
|
||||
@@ -1622,7 +1628,6 @@ export function reduceStreamUpdate(
|
||||
}
|
||||
|
||||
function applyTimelineTurnId(
|
||||
previous: StreamItem[],
|
||||
items: StreamItem[],
|
||||
event: Extract<AgentStreamEventPayload, { type: "timeline" }>,
|
||||
): StreamItem[] {
|
||||
@@ -1632,14 +1637,15 @@ function applyTimelineTurnId(
|
||||
return reconcileCanonicalUserTurnMembership(items, clientMessageId, event.turnId);
|
||||
}
|
||||
|
||||
if (!event.turnId || items === previous) return items;
|
||||
// Assign membership only to rows written by this event, before finalizing
|
||||
// unrelated reasoning rows. Late lifecycle updates can replace an earlier row.
|
||||
return items.map((item, index) =>
|
||||
item === previous[index] || item.turnId === event.turnId
|
||||
? item
|
||||
: ({ ...item, turnId: event.turnId } as StreamItem),
|
||||
);
|
||||
if (!event.turnId || items.length === 0) return items;
|
||||
const index = items.length - 1;
|
||||
const last = items[index];
|
||||
if (!last || last.turnId === event.turnId) return items;
|
||||
return [
|
||||
...items.slice(0, index),
|
||||
{ ...last, turnId: event.turnId } as StreamItem,
|
||||
...items.slice(index + 1),
|
||||
];
|
||||
}
|
||||
|
||||
function reconcileCanonicalUserTurnMembership(
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
|
||||
|
||||
/**
|
||||
* Opens a folder outside the workspace in the system file manager. The Files
|
||||
* panel only lists the workspace, so hosts without a local file manager get a
|
||||
* toast instead.
|
||||
*/
|
||||
export function useOpenOutsideWorkspaceFolder(serverId: string): (folderPath: string) => void {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const isLocalExecution = useIsLocalDaemon(serverId);
|
||||
const { targets } = useDesktopOpenTargets({ isLocalExecution });
|
||||
const fileManagerTarget = targets.find((target) => target.kind === "file-manager");
|
||||
|
||||
return useCallback(
|
||||
(folderPath: string) => {
|
||||
const showOutsideWorkspace = () =>
|
||||
toast.show(t("common.errors.folderOutsideWorkspace", { path: folderPath }));
|
||||
if (!fileManagerTarget) {
|
||||
showOutsideWorkspace();
|
||||
return;
|
||||
}
|
||||
void openDesktopTarget({ editorId: fileManagerTarget.id, workspacePath: folderPath }).catch(
|
||||
(error: unknown) => {
|
||||
console.warn("[open-outside-workspace-folder] open failed", error);
|
||||
showOutsideWorkspace();
|
||||
},
|
||||
);
|
||||
},
|
||||
[fileManagerTarget, t, toast],
|
||||
);
|
||||
}
|
||||
@@ -96,10 +96,7 @@ export default defineConfig({
|
||||
include: ["src/**/*.browser.{test,spec}.{ts,tsx}"],
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: playwright({
|
||||
// Exercise real WebGL glyph rendering on GPU-less CI runners.
|
||||
launchOptions: { args: ["--enable-unsafe-swiftshader"] },
|
||||
}),
|
||||
provider: playwright(),
|
||||
headless: true,
|
||||
connectTimeout: 180_000,
|
||||
instances: [{ browser: "chromium" }],
|
||||
|
||||
@@ -1640,77 +1640,6 @@ test("preserves legacy fetchAgent id overload", async () => {
|
||||
await expect(responsePromise).rejects.toThrow("legacy fetch sentinel");
|
||||
});
|
||||
|
||||
test.each([undefined, false, true])(
|
||||
"loads history with whole-turn support advertised as %s",
|
||||
async (wholeTurnTimeline) => {
|
||||
const transport = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "history-compatibility",
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => transport.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
const connected = client.connect();
|
||||
transport.triggerOpen({
|
||||
features: wholeTurnTimeline === undefined ? {} : { wholeTurnTimeline },
|
||||
});
|
||||
await connected;
|
||||
|
||||
const requests = [
|
||||
{ direction: "tail" as const },
|
||||
{ direction: "before" as const, cursor: { epoch: "epoch-1", seq: 50 } },
|
||||
{ direction: "after" as const, cursor: { epoch: "epoch-1", seq: 50 } },
|
||||
{ direction: "before" as const, cursor: { epoch: "epoch-1", seq: 80 }, mergeWindow: true },
|
||||
];
|
||||
for (const [index, request] of requests.entries()) {
|
||||
const requestId = `history-${index}`;
|
||||
const result = client.fetchAgentTimeline("agent-1", {
|
||||
...request,
|
||||
requestId,
|
||||
wholeTurns: true,
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
});
|
||||
// Attach immediately so the old rejection is captured by the regression.
|
||||
const completed = expect(result).resolves.toMatchObject({ requestId, entries: [] });
|
||||
expect(parseSentFrame(transport.sent.at(-1))).toEqual({
|
||||
type: "fetch_agent_timeline_request",
|
||||
agentId: "agent-1",
|
||||
requestId,
|
||||
...request,
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
...(wholeTurnTimeline === true ? { wholeTurns: true } : {}),
|
||||
});
|
||||
transport.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "fetch_agent_timeline_response",
|
||||
payload: {
|
||||
requestId,
|
||||
agentId: "agent-1",
|
||||
agent: null,
|
||||
direction: request.direction,
|
||||
projection: "projected",
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 0, maxSeq: 0, nextSeq: 1 },
|
||||
startCursor: null,
|
||||
endCursor: null,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
entries: [],
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await completed;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("honors explicit fetchAgentTimeline timeout below the session RPC default", async () => {
|
||||
useHeartbeatClock();
|
||||
const logger = createMockLogger();
|
||||
|
||||
@@ -686,8 +686,6 @@ export interface FetchAgentOptions {
|
||||
}
|
||||
type LegacyFetchAgentOptions = Omit<FetchAgentOptions, "agentId">;
|
||||
export interface FetchAgentTimelineOptions {
|
||||
/** Prefer whole-turn pages when supported; older hosts retain item-based paging. */
|
||||
wholeTurns?: boolean;
|
||||
direction?: FetchAgentTimelineDirection;
|
||||
cursor?: FetchAgentTimelineCursor;
|
||||
limit?: number;
|
||||
@@ -3550,15 +3548,9 @@ export class DaemonClient {
|
||||
agentId: string,
|
||||
options: FetchAgentTimelineOptions = {},
|
||||
): Promise<FetchAgentTimelinePayload> {
|
||||
// COMPAT(wholeTurnTimeline): added after v0.13.2 (#8010); remove after 2027-04-27 once the daemon floor supports whole-turn pages.
|
||||
// Reading history predates this capability and must keep working on older hosts.
|
||||
const supportsWholeTurns = this.lastServerInfoMessage?.features?.wholeTurnTimeline === true;
|
||||
const resolvedRequestId = this.createRequestId(options.requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "fetch_agent_timeline_request",
|
||||
...(supportsWholeTurns && options.wholeTurns !== undefined
|
||||
? { wholeTurns: options.wholeTurns }
|
||||
: {}),
|
||||
agentId,
|
||||
requestId: resolvedRequestId,
|
||||
...(options.direction ? { direction: options.direction } : {}),
|
||||
|
||||
@@ -1770,66 +1770,3 @@ test("qualified parents preserve foreign identity without resolving an equal loc
|
||||
await client.close();
|
||||
expect(localParent.address).toEqual({ serverId: "srv_sdk_test", agentId: "same-parent" });
|
||||
});
|
||||
|
||||
test("agent reload reopens through the daemon RPC then returns the observed snapshot; refresh remains a read", async () => {
|
||||
const { client, ws } = await connectClient();
|
||||
try {
|
||||
const agent = client.agents.ref("agent_sdk");
|
||||
const snapshot = createAgent();
|
||||
const reloaded = agent.reload({ requestId: "reload-sdk" });
|
||||
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
|
||||
type: "refresh_agent_request",
|
||||
agentId: "agent_sdk",
|
||||
requestId: "reload-sdk",
|
||||
});
|
||||
ws.message(
|
||||
sessionMessage({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_refreshed",
|
||||
requestId: "reload-sdk",
|
||||
agentId: "agent_sdk",
|
||||
timelineSize: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect
|
||||
.poll(() => parseSentSessionMessage(ws.sent.at(-1)).type)
|
||||
.toBe("fetch_agent_request");
|
||||
const fetched = parseSentSessionMessage(ws.sent.at(-1));
|
||||
ws.message(
|
||||
sessionMessage({
|
||||
type: "fetch_agent_response",
|
||||
payload: {
|
||||
requestId: fetched.requestId,
|
||||
agentId: "agent_sdk",
|
||||
agent: snapshot,
|
||||
project: null,
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(await reloaded).toEqual({ agent: snapshot, project: null });
|
||||
expect(agent.current()).toEqual(snapshot);
|
||||
const read = agent.refresh("snapshot-read");
|
||||
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
|
||||
type: "fetch_agent_request",
|
||||
requestId: "snapshot-read",
|
||||
});
|
||||
ws.message(
|
||||
sessionMessage({
|
||||
type: "fetch_agent_response",
|
||||
payload: {
|
||||
requestId: "snapshot-read",
|
||||
agentId: "agent_sdk",
|
||||
agent: snapshot,
|
||||
project: null,
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(await read).toEqual({ agent: snapshot, project: null });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -369,11 +369,6 @@ export interface PaseoAgentHandle {
|
||||
readonly timeline: PaseoAgentTimelineHandle;
|
||||
current(): PaseoAgent | null;
|
||||
refresh(requestId?: string): Promise<PaseoAgentRefetchResult | null>;
|
||||
/**
|
||||
* Reopens the provider session with current configuration, rerunning session-open hooks.
|
||||
* Interrupts a running turn and returns the refreshed snapshot.
|
||||
*/
|
||||
reload(options?: { requestId?: string }): Promise<PaseoAgentRefetchResult | null>;
|
||||
send(text: string, options?: PaseoAgentSendOptions): Promise<void>;
|
||||
respondToPermission(options: PaseoAgentRespondToPermissionOptions): Promise<void>;
|
||||
/** Sends a prompt and resolves when that turn finishes or needs attention. */
|
||||
@@ -986,12 +981,6 @@ function createAgentHandleFactory(
|
||||
current = result?.agent ?? null;
|
||||
return result;
|
||||
},
|
||||
reload: async (options) => {
|
||||
await daemonClient.refreshAgent(id, options?.requestId);
|
||||
const result = await daemonClient.fetchAgent({ agentId: id });
|
||||
current = result?.agent ?? null;
|
||||
return result;
|
||||
},
|
||||
send: async (text, options) => {
|
||||
await daemonClient.sendAgentMessage(id, text, options);
|
||||
},
|
||||
|
||||
@@ -86,10 +86,6 @@ class FakeEditorTargets implements EditorTargetRuntime {
|
||||
return this.paths.has(targetPath);
|
||||
}
|
||||
|
||||
expandHomePath(targetPath: string): string {
|
||||
return targetPath.startsWith("~/") ? `/home/me/${targetPath.slice(2)}` : targetPath;
|
||||
}
|
||||
|
||||
isAbsolutePath(targetPath: string): boolean {
|
||||
return targetPath.startsWith("/") || /^[A-Z]:\//u.test(targetPath);
|
||||
}
|
||||
@@ -517,19 +513,6 @@ describe("editor target registry", () => {
|
||||
expect(runtime.revealedPaths).toEqual(["C:/repo/src/app.ts"]);
|
||||
});
|
||||
|
||||
it("opens a home-relative folder in the file manager", async () => {
|
||||
const runtime = new FakeEditorTargets();
|
||||
runtime.addPath("/home/me/Downloads/contracts");
|
||||
|
||||
await openEditorTarget(
|
||||
{ editorId: "file-manager", workspacePath: "~/Downloads/contracts" },
|
||||
runtime,
|
||||
[fileManagerTarget],
|
||||
);
|
||||
|
||||
expect(runtime.openedPaths).toEqual(["/home/me/Downloads/contracts"]);
|
||||
});
|
||||
|
||||
it("keeps the platform file-manager ids used by stored preferences", async () => {
|
||||
const macTargets = await listAvailableEditorTargets(new FakeEditorTargets("darwin"), [
|
||||
finderTarget,
|
||||
|
||||
@@ -97,16 +97,10 @@ export function getEditorTarget(
|
||||
}
|
||||
|
||||
export async function openEditorTarget(
|
||||
request: EditorTargetLaunchInput & { editorId: string },
|
||||
input: EditorTargetLaunchInput & { editorId: string },
|
||||
runtime: EditorTargetRuntime,
|
||||
targets: readonly EditorTarget[] = EDITOR_TARGETS,
|
||||
): Promise<void> {
|
||||
// Assistant replies name folders like `~/Downloads/x`; expand them before the absolute-path guard.
|
||||
const input = {
|
||||
...request,
|
||||
workspacePath: runtime.expandHomePath(request.workspacePath),
|
||||
filePath: request.filePath === undefined ? undefined : runtime.expandHomePath(request.filePath),
|
||||
};
|
||||
if (!runtime.isAbsolutePath(input.workspacePath)) {
|
||||
throw new Error("Editor target workspace path must be an absolute local path");
|
||||
}
|
||||
|
||||
@@ -61,14 +61,6 @@ function isAbsolutePath(value: string, platform: NodeJS.Platform): boolean {
|
||||
return platform === "win32" ? win32.isAbsolute(value) : posix.isAbsolute(value);
|
||||
}
|
||||
|
||||
function expandHomePath(value: string, homeDirectory: string): string {
|
||||
if (value === "~") return homeDirectory;
|
||||
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
||||
return path.join(homeDirectory, value.slice(2));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveExecutable(
|
||||
commands: readonly string[],
|
||||
input: {
|
||||
@@ -174,7 +166,6 @@ export function createEditorTargetRuntime(
|
||||
pathExists,
|
||||
isAbsolutePath: (targetPath) => isAbsolutePath(targetPath, platform),
|
||||
listDirectory,
|
||||
expandHomePath: (targetPath) => expandHomePath(targetPath, homeDirectory),
|
||||
resolveCommand: (commands) => resolveExecutable(commands, { env, pathExists, platform }),
|
||||
async spawnDetached({ command, args }) {
|
||||
const commandScript = isWindowsCommandScript(command, platform);
|
||||
|
||||
@@ -30,8 +30,6 @@ export interface EditorTargetRuntime {
|
||||
pathExists(path: string): boolean;
|
||||
isAbsolutePath(path: string): boolean;
|
||||
listDirectory(path: string): EditorTargetDirectoryEntry[];
|
||||
/** Resolves a leading `~` to the user's home directory. */
|
||||
expandHomePath(path: string): string;
|
||||
resolveCommand(commands: readonly string[]): string | null;
|
||||
spawnDetached(input: { command: string; args: readonly string[] }): Promise<void>;
|
||||
openPath(path: string): Promise<void>;
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/react": "^18.0.25",
|
||||
"expo-module-scripts": "^5.0.8",
|
||||
"expo-modules-core": "^2.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"typeRoots": [
|
||||
"../../node_modules/expo-module-scripts/ts-declarations",
|
||||
"./node_modules/expo-module-scripts/ts-declarations",
|
||||
"./node_modules/@types",
|
||||
"../../node_modules/@types"
|
||||
]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user